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

590 lines
20 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { execFile } from "node:child_process";
import { constants as fsConstants } from "node:fs";
import {
access,
copyFile,
mkdir,
readFile,
readdir,
rename,
rm,
writeFile,
} from "node:fs/promises";
import os from "node:os";
import path from "node:path";
/** @typedef {import("@openwork/types/desktop-ipc").DesktopIntegrationIssue} DesktopIntegrationIssue */
/** @typedef {import("@openwork/types/desktop-ipc").DesktopIntegrationResult} DesktopIntegrationResult */
/** @typedef {import("@openwork/types/desktop-ipc").DesktopIntegrationStatus} DesktopIntegrationStatus */
export const OPENWORK_DESKTOP_ID = "com.differentai.openwork.desktop";
export const OPENWORK_DESKTOP_NAME = "com.differentai.openwork";
export const OPENWORK_PROTOCOL_MIME = "x-scheme-handler/openwork";
const INTEGRATION_STATE_VERSION = 1;
const OWNERSHIP_MARKER = "X-OpenWork-Managed";
const MANAGED_VERSION_MARKER = "X-OpenWork-Version";
const ICON_SIZES = [16, 24, 32, 48, 64, 96, 128, 256, 512];
function defaultCommandRunner(command, args) {
return new Promise((resolve) => {
execFile(command, args, { encoding: "utf8", timeout: 10_000 }, (error, stdout, stderr) => {
resolve({
ok: !error,
stdout: typeof stdout === "string" ? stdout : "",
stderr: typeof stderr === "string" ? stderr : error?.message ?? "",
});
});
});
}
function cleanDesktopValue(value) {
return String(value ?? "").replace(/[\0\r\n]/g, " ").trim();
}
export function quoteDesktopExec(value) {
const cleaned = cleanDesktopValue(value);
const quoted = cleaned
.replaceAll("%", "%%")
.replace(/([\\`"$])/g, "\\$1");
return `"${quoted}"`;
}
function parseDesktopEntry(content) {
const fields = new Map();
let inDesktopEntry = false;
for (const rawLine of String(content ?? "").split(/\r?\n/)) {
const line = rawLine.trim();
if (line.startsWith("[") && line.endsWith("]")) {
inDesktopEntry = line === "[Desktop Entry]";
continue;
}
if (!inDesktopEntry || !line || line.startsWith("#")) continue;
const separator = line.indexOf("=");
if (separator <= 0) continue;
fields.set(line.slice(0, separator).trim(), line.slice(separator + 1).trim());
}
return fields;
}
function unquoteDesktopValue(value) {
const trimmed = String(value ?? "").trim();
if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
return trimmed.slice(1, -1).replace(/\\([\\`"$])/g, "$1");
}
if (trimmed.startsWith("'") && trimmed.endsWith("'")) {
return trimmed.slice(1, -1);
}
return trimmed;
}
function entryTargetsAppImage(fields, appImagePath) {
const tryExec = unquoteDesktopValue(fields.get("TryExec"));
if (tryExec && path.resolve(tryExec) === appImagePath) return true;
const exec = fields.get("Exec") ?? "";
return exec.includes(appImagePath)
|| exec.includes(quoteDesktopExec(appImagePath))
|| exec.includes(`'${appImagePath.replaceAll("'", "'\\''")}'`);
}
function entryHandlesOpenwork(fields) {
return (fields.get("MimeType") ?? "")
.split(";")
.map((value) => value.trim())
.includes(OPENWORK_PROTOCOL_MIME);
}
function entryAcceptsUrl(fields) {
return /%(?:u|U)(?:\s|$)/.test(fields.get("Exec") ?? "");
}
function emptyState() {
return {
version: INTEGRATION_STATE_VERSION,
dismissedAppImages: [],
previousProtocolHandler: null,
};
}
function normalizeState(value) {
if (!value || typeof value !== "object") return emptyState();
const dismissed = Array.isArray(value.dismissedAppImages)
? value.dismissedAppImages.filter((item) => typeof item === "string")
: [];
return {
version: INTEGRATION_STATE_VERSION,
dismissedAppImages: dismissed,
previousProtocolHandler:
typeof value.previousProtocolHandler === "string" && value.previousProtocolHandler.trim()
? value.previousProtocolHandler.trim()
: null,
};
}
async function fileExists(target) {
return access(target, fsConstants.F_OK).then(() => true).catch(() => false);
}
async function atomicWrite(target, content) {
const temporary = `${target}.${process.pid}.${Date.now()}.tmp`;
await mkdir(path.dirname(target), { recursive: true });
await writeFile(temporary, content, { encoding: "utf8", mode: 0o644 });
await rename(temporary, target);
}
async function removeDesktopAssociation(filePath, desktopId) {
const content = await readFile(filePath, "utf8").catch(() => null);
if (content == null) return false;
let section = "";
let changed = false;
const next = content.split(/\r?\n/).map((line) => {
const trimmed = line.trim();
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
section = trimmed;
return line;
}
if (
(section !== "[Default Applications]" && section !== "[Added Associations]")
|| !trimmed.startsWith(`${OPENWORK_PROTOCOL_MIME}=`)
) {
return line;
}
const separator = line.indexOf("=");
const handlers = line
.slice(separator + 1)
.split(";")
.map((value) => value.trim())
.filter((value) => value && value !== desktopId);
changed = true;
return `${line.slice(0, separator + 1)}${handlers.length ? `${handlers.join(";")};` : ""}`;
}).join("\n");
if (changed) await atomicWrite(filePath, next);
return changed;
}
/** @returns {DesktopIntegrationStatus} */
function unsupportedStatus() {
return {
supported: false,
state: "unsupported",
ownership: "none",
appImagePath: null,
desktopEntryPath: null,
handlerDesktopId: null,
issues: [],
};
}
export function buildOpenworkDesktopEntry({
appImagePath,
appName,
appVersion,
distribution,
}) {
return `[Desktop Entry]
Type=Application
Version=1.0
Name=${cleanDesktopValue(appName)}
Comment=Run agents, skills, and MCP with OpenWork
Exec=${quoteDesktopExec(appImagePath)} %U
TryExec=${cleanDesktopValue(appImagePath)}
Icon=${OPENWORK_DESKTOP_NAME}
StartupWMClass=${OPENWORK_DESKTOP_NAME}
Terminal=false
Categories=Development;Utility;
MimeType=${OPENWORK_PROTOCOL_MIME};
X-AppImage-Name=${cleanDesktopValue(appName)}
X-AppImage-Version=${cleanDesktopValue(appVersion)}
${OWNERSHIP_MARKER}=true
${MANAGED_VERSION_MARKER}=${cleanDesktopValue(appVersion)}
X-OpenWork-Distribution=${cleanDesktopValue(distribution)}
X-OpenWork-AppImage=${cleanDesktopValue(appImagePath)}
`;
}
export function createLinuxDesktopIntegration({
app,
dialog,
appName,
distribution,
env = process.env,
platform = process.platform,
homeDir = os.homedir(),
resourcesPath = process.resourcesPath,
runCommand = defaultCommandRunner,
}) {
const rawAppImagePath = typeof env.APPIMAGE === "string" ? env.APPIMAGE.trim() : "";
const appImagePath = rawAppImagePath && path.isAbsolute(rawAppImagePath)
? path.resolve(rawAppImagePath)
: null;
const supported = platform === "linux" && app.isPackaged && appImagePath != null;
const dataHome = env.XDG_DATA_HOME?.trim() || path.join(homeDir, ".local", "share");
const configHome = env.XDG_CONFIG_HOME?.trim() || path.join(homeDir, ".config");
const desktopEntryPath = path.join(dataHome, "applications", OPENWORK_DESKTOP_ID);
const iconPaths = Object.fromEntries(ICON_SIZES.map((size) => [
size,
path.join(dataHome, "icons", "hicolor", `${size}x${size}`, "apps", `${OPENWORK_DESKTOP_NAME}.png`),
]));
const iconSources = Object.fromEntries(ICON_SIZES.map((size) => [
size,
path.join(resourcesPath ?? "", "icons", "linux", `${size}x${size}.png`),
]));
const statePath = path.join(configHome, "openwork", "desktop-integration.json");
async function readState() {
const raw = await readFile(statePath, "utf8").catch(() => null);
if (raw == null) return emptyState();
try {
return normalizeState(JSON.parse(raw));
} catch {
return emptyState();
}
}
async function writeState(state) {
await atomicWrite(statePath, `${JSON.stringify(normalizeState(state), null, 2)}\n`);
}
async function queryDefaultHandler() {
const result = await runCommand("xdg-mime", ["query", "default", OPENWORK_PROTOCOL_MIME]);
return result.ok && result.stdout.trim() ? result.stdout.trim() : null;
}
function desktopSearchRoots() {
const systemRoots = (env.XDG_DATA_DIRS?.trim() || "/usr/local/share:/usr/share")
.split(":")
.filter(Boolean);
return [dataHome, ...systemRoots].map((root) => path.join(root, "applications"));
}
async function locateDesktopEntry(desktopId) {
if (!desktopId || desktopId.includes("/") || desktopId.includes("\\")) return null;
for (const root of desktopSearchRoots()) {
const candidate = path.join(root, desktopId);
if (await fileExists(candidate)) return candidate;
}
return null;
}
async function inspectDesktopFile(candidate, desktopId) {
const content = await readFile(candidate, "utf8").catch(() => null);
if (content == null) return null;
const fields = parseDesktopEntry(content);
if (!entryTargetsAppImage(fields, appImagePath)) return null;
return {
desktopId,
path: candidate,
managed: fields.get(OWNERSHIP_MARKER) === "true",
acceptsUrl: entryAcceptsUrl(fields),
handlesProtocol: entryHandlesOpenwork(fields),
};
}
async function findExternalEntry(handlerDesktopId) {
const inspected = handlerDesktopId
? await locateDesktopEntry(handlerDesktopId)
.then((candidate) => candidate && inspectDesktopFile(candidate, handlerDesktopId))
: null;
if (inspected && !inspected.managed) return inspected;
const applicationsRoot = path.join(dataHome, "applications");
const entries = await readdir(applicationsRoot, { withFileTypes: true }).catch(() => []);
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith(".desktop") || entry.name === OPENWORK_DESKTOP_ID) continue;
const candidate = await inspectDesktopFile(path.join(applicationsRoot, entry.name), entry.name);
if (candidate && !candidate.managed) return candidate;
}
return null;
}
/** @returns {Promise<DesktopIntegrationStatus>} */
async function getStatus() {
if (!supported) return unsupportedStatus();
const handlerDesktopId = await queryDefaultHandler();
const ownContent = await readFile(desktopEntryPath, "utf8").catch(() => null);
const ownFields = ownContent == null ? null : parseDesktopEntry(ownContent);
const ownManaged = ownFields?.get(OWNERSHIP_MARKER) === "true";
if (ownFields && ownManaged) {
/** @type {DesktopIntegrationIssue[]} */
const issues = [];
if (!entryTargetsAppImage(ownFields, appImagePath)) issues.push("appimage-path");
if (!entryHandlesOpenwork(ownFields)) issues.push("desktop-entry");
if (ownFields.get(MANAGED_VERSION_MARKER) !== app.getVersion()) issues.push("version");
const iconsPresent = await Promise.all(
ICON_SIZES.map((size) => fileExists(iconPaths[size])),
);
if (!iconsPresent.every(Boolean)) issues.push("icon");
if (handlerDesktopId !== OPENWORK_DESKTOP_ID) issues.push("protocol-handler");
return {
supported: true,
state: issues.length ? "needs_repair" : "integrated",
ownership: "openwork",
appImagePath,
desktopEntryPath,
handlerDesktopId,
issues,
};
}
const external = await findExternalEntry(handlerDesktopId);
if (external) {
/** @type {DesktopIntegrationIssue[]} */
const issues = [];
if (!external.acceptsUrl || !external.handlesProtocol) issues.push("desktop-entry");
if (handlerDesktopId !== external.desktopId) issues.push("protocol-handler");
return {
supported: true,
state: issues.length ? "needs_repair" : "managed_externally",
ownership: "external",
appImagePath,
desktopEntryPath: external.path,
handlerDesktopId,
issues,
};
}
return {
supported: true,
state: "not_integrated",
ownership: "none",
appImagePath,
desktopEntryPath: null,
handlerDesktopId,
issues: ["desktop-entry", "icon", "protocol-handler"],
};
}
async function refreshDesktopCaches() {
await runCommand("update-desktop-database", [path.join(dataHome, "applications")]);
await runCommand("gtk-update-icon-cache", ["-f", "-t", path.join(dataHome, "icons", "hicolor")]);
}
/**
* @param {{ useExternalLauncher?: boolean }} [options]
* @returns {Promise<DesktopIntegrationResult>}
*/
async function install(options = {}) {
if (!supported) {
return { ok: false, status: unsupportedStatus(), error: "Desktop integration is available only for packaged Linux AppImages." };
}
const before = await getStatus();
if (before.ownership === "external" && options.useExternalLauncher !== true) {
return {
ok: false,
status: before,
error: "This AppImage is managed by an external integration tool.",
};
}
if (before.ownership === "external") {
if (before.issues.includes("desktop-entry")) {
return {
ok: false,
status: before,
error: "The external launcher cannot accept browser callback URLs. Repair it with the tool that manages this AppImage.",
};
}
if (!before.desktopEntryPath) {
return {
ok: false,
status: before,
error: "The external launcher path is unavailable.",
};
}
const registration = await runCommand("xdg-mime", [
"default",
path.basename(before.desktopEntryPath),
OPENWORK_PROTOCOL_MIME,
]);
if (!registration.ok) {
return {
ok: false,
status: await getStatus(),
error: registration.stderr || "xdg-mime could not register the external launcher.",
};
}
const status = await getStatus();
if (status.state === "managed_externally") return { ok: true, status };
return {
ok: false,
status,
error: "The external launcher was not selected for openwork:// callbacks.",
};
}
if (
await fileExists(desktopEntryPath)
&& before.ownership !== "openwork"
) {
return {
ok: false,
status: before,
error: "The canonical OpenWork launcher is externally managed and will not be overwritten.",
};
}
try {
for (const size of ICON_SIZES) {
await mkdir(path.dirname(iconPaths[size]), { recursive: true });
await copyFile(iconSources[size], iconPaths[size]);
}
await atomicWrite(desktopEntryPath, buildOpenworkDesktopEntry({
appImagePath,
appName,
appVersion: app.getVersion(),
distribution,
}));
const state = await readState();
if (before.handlerDesktopId && before.handlerDesktopId !== OPENWORK_DESKTOP_ID) {
state.previousProtocolHandler = before.handlerDesktopId;
}
state.dismissedAppImages = state.dismissedAppImages.filter((candidate) => candidate !== appImagePath);
await writeState(state);
await refreshDesktopCaches();
const registration = await runCommand("xdg-mime", ["default", OPENWORK_DESKTOP_ID, OPENWORK_PROTOCOL_MIME]);
if (!registration.ok) {
throw new Error(registration.stderr || "xdg-mime could not register openwork://.");
}
const status = await getStatus();
if (status.state !== "integrated") {
throw new Error("The desktop entry was installed, but the desktop did not select it as the openwork:// handler.");
}
return { ok: true, status };
} catch (error) {
return {
ok: false,
status: await getStatus(),
error: error instanceof Error ? error.message : String(error),
};
}
}
/** @returns {Promise<DesktopIntegrationResult>} */
async function remove() {
if (!supported) {
return { ok: false, status: unsupportedStatus(), error: "Desktop integration is unavailable." };
}
const before = await getStatus();
if (before.ownership !== "openwork") {
return {
ok: false,
status: before,
error: before.ownership === "external"
? "Remove this AppImage with the tool that manages it."
: "OpenWork does not own a desktop integration to remove.",
};
}
try {
const state = await readState();
await rm(desktopEntryPath, { force: true });
await Promise.all(Object.values(iconPaths).map((target) => rm(target, { force: true })));
if (before.handlerDesktopId === OPENWORK_DESKTOP_ID) {
const previousPath = await locateDesktopEntry(state.previousProtocolHandler);
if (state.previousProtocolHandler && previousPath) {
await runCommand("xdg-mime", ["default", state.previousProtocolHandler, OPENWORK_PROTOCOL_MIME]);
} else {
await removeDesktopAssociation(path.join(configHome, "mimeapps.list"), OPENWORK_DESKTOP_ID);
await removeDesktopAssociation(path.join(dataHome, "applications", "mimeapps.list"), OPENWORK_DESKTOP_ID);
}
}
state.previousProtocolHandler = null;
if (!state.dismissedAppImages.includes(appImagePath)) state.dismissedAppImages.push(appImagePath);
await writeState(state);
await refreshDesktopCaches();
return { ok: true, status: await getStatus() };
} catch (error) {
return {
ok: false,
status: await getStatus(),
error: error instanceof Error ? error.message : String(error),
};
}
}
async function shouldSkipPrompt() {
if (!supported) return true;
const state = await readState();
return state.dismissedAppImages.includes(appImagePath);
}
async function rememberPromptDismissal() {
if (!supported) return;
const state = await readState();
if (!state.dismissedAppImages.includes(appImagePath)) state.dismissedAppImages.push(appImagePath);
await writeState(state);
}
async function maybePrompt(window) {
const status = await getStatus();
if (
!status.supported
|| status.state === "integrated"
|| status.state === "managed_externally"
|| status.ownership === "external"
) {
return status;
}
// The user already accepted this integration, so drift in the files OpenWork
// owns is maintenance rather than a new decision. A self-update lands under a
// new versioned filename and a moved AppImage changes its path; both stale the
// launcher. Repair silently instead of prompting after every release.
if (status.ownership === "openwork") {
const repaired = await install();
if (!repaired.ok) {
console.warn("[desktop-integration] silent repair failed", repaired.error);
}
return repaired.status;
}
if (await shouldSkipPrompt()) return status;
const { response, checkboxChecked } = await dialog.showMessageBox(window, {
type: "question",
title: "Add OpenWork to your applications?",
message: "Add OpenWork to your application launcher and register browser sign-in callbacks?",
detail: `The launcher will use this AppImage in its current location:\n${appImagePath}\n\nIf you move or update it later, OpenWork repairs the launcher automatically. You can change or remove this in Settings → Preferences → AppImage desktop integration.`,
buttons: ["Not now", "Integrate"],
defaultId: 1,
cancelId: 0,
checkboxLabel: "Don’t ask again for this AppImage",
checkboxChecked: false,
});
if (response !== 1) {
if (checkboxChecked) await rememberPromptDismissal();
return status;
}
const result = await install();
if (!result.ok) {
await dialog.showMessageBox(window, {
type: "warning",
title: "Desktop integration failed",
message: "OpenWork could not complete desktop integration.",
detail: result.error ?? "Unknown error",
buttons: ["OK"],
});
}
return result.status;
}
return {
getStatus,
install,
remove,
maybePrompt,
paths: {
dataHome,
configHome,
desktopEntryPath,
iconPaths,
statePath,
},
};
}