Files
openwork/apps/desktop/electron/linux-desktop-integration.test.mjs

359 lines
15 KiB
JavaScript

import assert from "node:assert/strict";
import { mkdtemp, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, it } from "node:test";
import {
OPENWORK_DESKTOP_ID,
buildOpenworkDesktopEntry,
createLinuxDesktopIntegration,
quoteDesktopExec,
} from "./linux-desktop-integration.mjs";
const temporaryRoots = [];
const iconSizes = [16, 24, 32, 48, 64, 96, 128, 256, 512];
async function createHarness(options = {}) {
const root = await mkdtemp(path.join(os.tmpdir(), "openwork-appimage-integration-"));
temporaryRoots.push(root);
const homeDir = path.join(root, "home");
const dataHome = path.join(root, "data");
const configHome = path.join(root, "config");
const resourcesPath = path.join(root, "resources");
const appImagePath = options.appImagePath ?? path.join(root, "OpenWork AppImage");
await mkdir(path.join(resourcesPath, "icons", "linux"), { recursive: true });
await Promise.all(iconSizes.map((size) => (
writeFile(path.join(resourcesPath, "icons", "linux", `${size}x${size}.png`), String(size))
)));
await writeFile(appImagePath, "appimage");
let defaultHandler = options.defaultHandler ?? null;
const commands = [];
const runCommand = async (command, args) => {
commands.push([command, args]);
if (command === "xdg-mime" && args[0] === "query") {
return { ok: true, stdout: defaultHandler ? `${defaultHandler}\n` : "", stderr: "" };
}
if (command === "xdg-mime" && args[0] === "default") {
defaultHandler = args[1];
}
return { ok: true, stdout: "", stderr: "" };
};
const dialogResponses = [...(options.dialogResponses ?? [])];
const dialogs = [];
const dialog = {
async showMessageBox(_window, dialogOptions) {
dialogs.push(dialogOptions);
return dialogResponses.shift() ?? { response: 0, checkboxChecked: false };
},
};
const app = {
isPackaged: options.isPackaged ?? true,
getVersion: () => options.version ?? "0.18.7",
};
const integration = createLinuxDesktopIntegration({
app,
dialog,
appName: "OpenWork",
distribution: "public",
env: {
APPIMAGE: appImagePath,
XDG_DATA_HOME: dataHome,
XDG_CONFIG_HOME: configHome,
XDG_DATA_DIRS: path.join(root, "system-data"),
},
platform: options.platform ?? "linux",
homeDir,
resourcesPath,
runCommand,
});
return {
appImagePath,
commands,
configHome,
dataHome,
dialogs,
getDefaultHandler: () => defaultHandler,
integration,
root,
};
}
// Simulates the next launch of the same install: same XDG dirs and state, but a
// possibly different AppImage path and app version.
function relaunchAt(harness, appImagePath, options = {}) {
let defaultHandler = options.defaultHandler ?? OPENWORK_DESKTOP_ID;
const dialogs = [];
const integration = createLinuxDesktopIntegration({
app: { isPackaged: true, getVersion: () => options.version ?? "0.18.7" },
dialog: {
async showMessageBox(_window, dialogOptions) {
dialogs.push(dialogOptions);
return { response: 0, checkboxChecked: false };
},
},
appName: "OpenWork",
distribution: "public",
env: {
APPIMAGE: appImagePath,
XDG_DATA_HOME: harness.dataHome,
XDG_CONFIG_HOME: harness.configHome,
XDG_DATA_DIRS: path.join(harness.root, "system-data"),
},
platform: "linux",
resourcesPath: path.join(harness.root, "resources"),
runCommand: async (command, args) => {
if (command === "xdg-mime" && args[0] === "query") {
return { ok: true, stdout: defaultHandler ? `${defaultHandler}\n` : "", stderr: "" };
}
if (command === "xdg-mime" && args[0] === "default") defaultHandler = args[1];
return { ok: true, stdout: "", stderr: "" };
},
});
return { dialogs, getDefaultHandler: () => defaultHandler, integration };
}
afterEach(async () => {
await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
describe("Linux AppImage desktop integration", () => {
it("quotes AppImage paths for Desktop Entry Exec fields", () => {
const appImagePath = String.raw`/home/alice/Applications/Open Work "daily"$%draft\archive`;
assert.equal(
quoteDesktopExec(appImagePath),
String.raw`"/home/alice/Applications/Open Work \"daily\"\$%%draft\\archive"`,
);
assert.equal(quoteDesktopExec("`"), '"\\`"');
const entry = buildOpenworkDesktopEntry({
appImagePath,
appName: "OpenWork",
appVersion: "0.18.7",
distribution: "public",
});
assert.match(entry, /^Exec=".*" %U$/m);
assert.match(entry, /^MimeType=x-scheme-handler\/openwork;$/m);
assert.match(entry, /^StartupWMClass=com\.differentai\.openwork$/m);
});
it("is unavailable outside a packaged Linux AppImage", async () => {
const harness = await createHarness({ isPackaged: false });
const status = await harness.integration.getStatus();
assert.equal(status.supported, false);
assert.equal(status.state, "unsupported");
});
it("installs its launcher, icons, and openwork handler", async () => {
const harness = await createHarness();
const result = await harness.integration.install();
assert.equal(result.ok, true);
assert.equal(result.status.state, "integrated");
assert.equal(harness.getDefaultHandler(), OPENWORK_DESKTOP_ID);
const desktopEntry = await readFile(harness.integration.paths.desktopEntryPath, "utf8");
assert.match(desktopEntry, new RegExp(`^Exec=${quoteDesktopExec(harness.appImagePath)} %U$`, "m"));
assert.match(desktopEntry, /^X-OpenWork-Managed=true$/m);
for (const size of iconSizes) {
assert.equal(await readFile(harness.integration.paths.iconPaths[size], "utf8"), String(size));
}
});
it("detects a moved AppImage and repairs the launcher for the new path", async () => {
const first = await createHarness();
assert.equal((await first.integration.install()).ok, true);
const movedPath = path.join(first.root, "Applications", "OpenWork.AppImage");
await mkdir(path.dirname(movedPath), { recursive: true });
await rename(first.appImagePath, movedPath);
const moved = createLinuxDesktopIntegration({
app: { isPackaged: true, getVersion: () => "0.18.7" },
dialog: { showMessageBox: async () => ({ response: 0, checkboxChecked: false }) },
appName: "OpenWork",
distribution: "public",
env: {
APPIMAGE: movedPath,
XDG_DATA_HOME: first.dataHome,
XDG_CONFIG_HOME: first.configHome,
XDG_DATA_DIRS: path.join(first.root, "system-data"),
},
platform: "linux",
resourcesPath: path.join(first.root, "resources"),
runCommand: async (command, args) => {
if (command === "xdg-mime" && args[0] === "query") {
return { ok: true, stdout: `${OPENWORK_DESKTOP_ID}\n`, stderr: "" };
}
return { ok: true, stdout: "", stderr: "" };
},
});
const before = await moved.getStatus();
assert.equal(before.state, "needs_repair");
assert.deepEqual(before.issues, ["appimage-path"]);
assert.equal((await moved.install()).ok, true);
assert.match(
await readFile(moved.paths.desktopEntryPath, "utf8"),
new RegExp(`^TryExec=${movedPath}$`, "m"),
);
});
it("silently repairs an owned launcher after a self-update lands a new filename", async () => {
const harness = await createHarness();
assert.equal((await harness.integration.install()).ok, true);
// Linux artifacts carry the version in their filename, so electron-updater
// installs each release at a new path and stales the launcher.
const updatedPath = path.join(harness.root, "openwork-linux-x86_64-0.18.8.AppImage");
await rename(harness.appImagePath, updatedPath);
const relaunched = relaunchAt(harness, updatedPath, { version: "0.18.8" });
assert.equal((await relaunched.integration.getStatus()).state, "needs_repair");
const status = await relaunched.integration.maybePrompt({});
assert.equal(status.state, "integrated");
assert.equal(relaunched.dialogs.length, 0);
const entry = await readFile(relaunched.integration.paths.desktopEntryPath, "utf8");
assert.match(entry, new RegExp(`^TryExec=${updatedPath}$`, "m"));
assert.match(entry, /^X-OpenWork-Version=0\.18\.8$/m);
});
it("silently refreshes an owned launcher when only the version changed", async () => {
const harness = await createHarness();
assert.equal((await harness.integration.install()).ok, true);
const relaunched = relaunchAt(harness, harness.appImagePath, { version: "0.18.8" });
assert.deepEqual((await relaunched.integration.getStatus()).issues, ["version"]);
const status = await relaunched.integration.maybePrompt({});
assert.equal(status.state, "integrated");
assert.equal(relaunched.dialogs.length, 0);
});
it("keeps a failed silent repair quiet and leaves it to Settings", async () => {
const harness = await createHarness();
assert.equal((await harness.integration.install()).ok, true);
const movedPath = path.join(harness.root, "openwork-linux-x86_64-0.18.8.AppImage");
await rename(harness.appImagePath, movedPath);
const relaunched = relaunchAt(harness, movedPath, { version: "0.18.8" });
await rm(path.join(harness.root, "resources"), { recursive: true, force: true });
const status = await relaunched.integration.maybePrompt({});
assert.equal(status.state, "needs_repair");
assert.equal(relaunched.dialogs.length, 0);
});
it("does not prompt after a remembered Not now response", async () => {
const harness = await createHarness({
dialogResponses: [{ response: 0, checkboxChecked: true }],
});
await harness.integration.maybePrompt({});
await harness.integration.maybePrompt({});
assert.equal(harness.dialogs.length, 1);
assert.equal(harness.dialogs[0].buttons[1], "Integrate");
});
it("integrates from the first-run prompt and points to the matching Settings control", async () => {
const harness = await createHarness({
dialogResponses: [{ response: 1, checkboxChecked: false }],
});
const status = await harness.integration.maybePrompt({});
assert.equal(status.state, "integrated");
assert.equal(harness.dialogs.length, 1);
assert.match(harness.dialogs[0].detail, /Settings → Preferences → AppImage desktop integration/);
});
it("recognizes an external manager and does not duplicate its integration", async () => {
const managerDesktopId = "it.mijorus.gearlever.openwork.desktop";
const harness = await createHarness({ defaultHandler: managerDesktopId });
const applications = path.join(harness.dataHome, "applications");
await mkdir(applications, { recursive: true });
await writeFile(path.join(applications, managerDesktopId), `[Desktop Entry]
Type=Application
Name=OpenWork
Exec=${quoteDesktopExec(harness.appImagePath)} %U
TryExec=${harness.appImagePath}
MimeType=x-scheme-handler/openwork;
`);
const status = await harness.integration.getStatus();
assert.equal(status.state, "managed_externally");
assert.equal(status.ownership, "external");
assert.equal((await harness.integration.install()).ok, false);
await harness.integration.maybePrompt({});
assert.equal(harness.dialogs.length, 0);
});
it("registers an externally managed canonical launcher without rewriting it", async () => {
const harness = await createHarness({ defaultHandler: OPENWORK_DESKTOP_ID });
await mkdir(path.dirname(harness.integration.paths.desktopEntryPath), { recursive: true });
const original = `[Desktop Entry]
Type=Application
Name=Manager-owned OpenWork
Exec=${quoteDesktopExec(harness.appImagePath)} %U
TryExec=${harness.appImagePath}
MimeType=x-scheme-handler/openwork;
`;
await writeFile(harness.integration.paths.desktopEntryPath, original);
const result = await harness.integration.install({ useExternalLauncher: true });
assert.equal(result.ok, true);
assert.equal(await readFile(harness.integration.paths.desktopEntryPath, "utf8"), original);
});
it("never overwrites a canonical launcher it does not own", async () => {
const harness = await createHarness();
await mkdir(path.dirname(harness.integration.paths.desktopEntryPath), { recursive: true });
const original = "[Desktop Entry]\nType=Application\nName=Unrelated launcher\nExec=/usr/bin/false\n";
await writeFile(harness.integration.paths.desktopEntryPath, original);
const result = await harness.integration.install();
assert.equal(result.ok, false);
assert.match(result.error, /will not be overwritten/);
assert.equal(await readFile(harness.integration.paths.desktopEntryPath, "utf8"), original);
});
it("registers an existing manager launcher without creating a duplicate", async () => {
const managerDesktopId = "appimagelauncher-openwork.desktop";
const harness = await createHarness({ defaultHandler: "firefox.desktop" });
const managerPath = path.join(harness.dataHome, "applications", managerDesktopId);
await mkdir(path.dirname(managerPath), { recursive: true });
const managerEntry = `[Desktop Entry]
Type=Application
Name=OpenWork
Exec=${quoteDesktopExec(harness.appImagePath)} %U
TryExec=${harness.appImagePath}
MimeType=x-scheme-handler/openwork;
`;
await writeFile(managerPath, managerEntry);
const before = await harness.integration.getStatus();
assert.equal(before.state, "needs_repair");
assert.equal(before.ownership, "external");
const result = await harness.integration.install({ useExternalLauncher: true });
assert.equal(result.ok, true);
assert.equal(result.status.state, "managed_externally");
assert.equal(harness.getDefaultHandler(), managerDesktopId);
assert.equal(await readFile(managerPath, "utf8"), managerEntry);
await assert.rejects(readFile(harness.integration.paths.desktopEntryPath, "utf8"));
});
it("removes only owned files and restores the previous protocol handler", async () => {
const previousHandler = "firefox.desktop";
const harness = await createHarness({ defaultHandler: previousHandler });
const systemApplications = path.join(harness.root, "system-data", "applications");
await mkdir(systemApplications, { recursive: true });
await writeFile(path.join(systemApplications, previousHandler), "[Desktop Entry]\nType=Application\n");
assert.equal((await harness.integration.install()).ok, true);
const result = await harness.integration.remove();
assert.equal(result.ok, true);
assert.equal(harness.getDefaultHandler(), previousHandler);
assert.equal(result.status.state, "not_integrated");
assert.equal(
harness.commands.some(([command, args]) => (
command === "xdg-mime"
&& args[0] === "default"
&& args[1] === previousHandler
)),
true,
);
});
});