359 lines
15 KiB
JavaScript
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,
|
|
);
|
|
});
|
|
});
|