mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
fix(desktop): launch spaced REA archives and Electron apps (#9085)
* fix(desktop): launch spaced REA archives and Electron apps Link the unpacked REA entrypoint to a fixed path so a top folder with spaces is not split by the generated rea launcher. When a build request marks the app as Electron, rewrite its installed desktop command with the export path's launch policy before gtk-launch. * fix(desktop): escape rewritten Exec values for desktop files Command quoting alone left literal backslashes one escape short of the desktop-entry value layer, so gio rejected the rewritten entry. Escape the command with the same rules parseDesktopEntry undoes before writing the Exec line. --------- Co-authored-by: Cursor Agent <[email protected]>
This commit is contained in:
1 parent
c467cf8685
commit
8580f1430c
9 files changed
+472
-4
No files matched your search
@@ -40,6 +40,13 @@ no data hooks. The app is not open when the computer starts: agentenv lists it
|
||||
in the desktop's Applications menu under Other by the environment's name, such
|
||||
as `mousepad`, and that entry launches it through the app's server.
|
||||
|
||||
When the app is Electron, set `electron` to true. The install rewrites that
|
||||
desktop entry's command with the same policy an export uses: `--no-sandbox`
|
||||
immediately after the program, unless the command already has it. The
|
||||
computer's container does not grant the user namespaces Chromium's sandbox
|
||||
needs, and Electron exits without the flag. With the rewritten command, the
|
||||
Applications menu entry can open the app.
|
||||
|
||||
```json
|
||||
{
|
||||
"source": {
|
||||
|
||||
@@ -42,7 +42,10 @@ it for building, or from an existing bundle folder.
|
||||
repository with its key file, or a `.deb` URL with its SHA-256, dependencies
|
||||
and architecture, rendered with the export's install shapes and Python
|
||||
helpers; `Launch(at="demand")` through `gtk-launch`; no seed and no data
|
||||
hooks; REA in a build-only layer, with Node.js 24.22.1 downloaded per
|
||||
hooks. When the request sets `electron`, the install rewrites that desktop
|
||||
entry's command with the export's launch policy (`--no-sandbox` right after
|
||||
the program, unless the command already has it) before `gtk-launch`; REA in
|
||||
a build-only layer, with Node.js 24.22.1 downloaded per
|
||||
architecture and checked, and REA's payload a parameter: a local archive
|
||||
shipped in the environment folder (`REA_BUILD_COMPUTER_REA_ARCHIVE`) or,
|
||||
later, a released download.
|
||||
|
||||
@@ -25,7 +25,7 @@ export const DESKTOP_BUILD_TOOL_CONTRACTS = [
|
||||
...toolContractMetadata("start_build_computer"),
|
||||
kind: "application",
|
||||
description:
|
||||
"Start a local agentenv computer to build a desktop app environment on, and return its id with a first screenshot as an image. From a request, REA renders a bundle that installs the app from the Ubuntu archive, an apt repository or a .deb URL, opens it on demand from its desktop entry (it is not open at first: open it from the desktop's Applications menu, under Other, by the environment's name), and installs REA itself in a build-only layer from REA_BUILD_COMPUTER_REA_ARCHIVE; from a bundle folder, such as an exported one, it starts that bundle's smoke task as it is. Needs Docker and the agentenv runner named by REA_AGENT_ENV_COMMAND; the first start of a bundle builds its image, which downloads packages and can take minutes. screenshot sets the first screenshot's region, format and quality. The computer stays up, reachable on this host, until close_build_computer or the server closes; when a start fails before its teardown is confirmed, the error's computer_id names what close_build_computer retries.",
|
||||
"Start a local agentenv computer to build a desktop app environment on, and return its id with a first screenshot as an image. From a request, REA renders a bundle that installs the app from the Ubuntu archive, an apt repository or a .deb URL, opens it on demand from its desktop entry (it is not open at first: open it from the desktop's Applications menu, under Other, by the environment's name), and installs REA itself in a build-only layer from REA_BUILD_COMPUTER_REA_ARCHIVE. Set source.electron for an Electron app: the install rewrites that desktop entry's command with the same launch policy an export uses, adding --no-sandbox right after the program unless the command already has it, because the container does not grant the user namespaces Chromium needs and Electron exits without the flag. From a bundle folder, such as an exported one, it starts that bundle's smoke task as it is. Needs Docker and the agentenv runner named by REA_AGENT_ENV_COMMAND; the first start of a bundle builds its image, which downloads packages and can take minutes. screenshot sets the first screenshot's region, format and quality. The computer stays up, reachable on this host, until close_build_computer or the server closes; when a start fails before its teardown is confirmed, the error's computer_id names what close_build_computer retries.",
|
||||
inputSchema: z.strictObject({
|
||||
source: buildSourceSchema,
|
||||
screenshot: screenshotOptionsSchema.optional(),
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { chmod, mkdir, readFile, readlink, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
|
||||
import { PACKAGE_METADATA } from "../../generatedPackageMetadata.js";
|
||||
import {
|
||||
BUILD_NODE_VERSION,
|
||||
@@ -9,6 +16,8 @@ import {
|
||||
type BuildRequest,
|
||||
} from "./appBuild.js";
|
||||
|
||||
const execute = promisify(execFile);
|
||||
|
||||
const request = (install: BuildRequest["install"]): BuildRequest => ({
|
||||
kind: "request",
|
||||
desktop_entry: "org.xfce.mousepad.desktop",
|
||||
@@ -67,6 +76,7 @@ describe("build bundle renderer", () => {
|
||||
expect(script).toContain(
|
||||
`/opt/rea/node-v${BUILD_NODE_VERSION}-linux-*/bin/node`,
|
||||
);
|
||||
expect(script).toContain("exec /usr/local/bin/node /opt/rea/rea.mjs");
|
||||
});
|
||||
|
||||
it("pins the Node.js release REA itself pins", () => {
|
||||
@@ -187,3 +197,146 @@ describe("build bundle install sources", () => {
|
||||
expect(parse({ kind: "bundle", path: "/tmp/bundle" }).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
const shellQuote = (value: string): string =>
|
||||
`'${value.replaceAll("'", `'"'"'`)}'`;
|
||||
|
||||
describe("installed launch paths", () => {
|
||||
it("keeps an archive folder with spaces as the rea entrypoint", async () => {
|
||||
const root = await createTestTempDirectory("rea-launcher-spaces-");
|
||||
const nodeDir = join(
|
||||
root,
|
||||
"node-src",
|
||||
`node-v${BUILD_NODE_VERSION}-linux-x64`,
|
||||
"bin",
|
||||
);
|
||||
const payloadDir = join(root, "payload-src", "REA Build", "scripts");
|
||||
await mkdir(nodeDir, { recursive: true });
|
||||
await mkdir(payloadDir, { recursive: true });
|
||||
const marker = join(root, "node-args");
|
||||
const entrypoint = "export const marker = 'rea-entrypoint';\n";
|
||||
await writeFile(
|
||||
join(nodeDir, "node"),
|
||||
`#!/bin/sh\nprintf '%s\\n' "$@" > ${shellQuote(marker)}\n`,
|
||||
);
|
||||
await chmod(join(nodeDir, "node"), 0o755);
|
||||
await writeFile(join(payloadDir, "rea.mjs"), entrypoint);
|
||||
await execute("tar", [
|
||||
"-cJf",
|
||||
join(root, "node-amd64.tar.xz"),
|
||||
"-C",
|
||||
join(root, "node-src"),
|
||||
`node-v${BUILD_NODE_VERSION}-linux-x64`,
|
||||
]);
|
||||
await execute("tar", [
|
||||
"-cf",
|
||||
join(root, "rea-payload.tar"),
|
||||
"-C",
|
||||
join(root, "payload-src"),
|
||||
"REA Build",
|
||||
]);
|
||||
const bundle = renderBuildBundle(
|
||||
request({ kind: "archive", package: "mousepad" }),
|
||||
localPayload,
|
||||
);
|
||||
const script = text(bundle.files, "envs/mousepad/install-rea.sh")
|
||||
.replaceAll("/opt/rea", join(root, "opt/rea"))
|
||||
.replaceAll("/usr/local/bin", join(root, "bin"));
|
||||
await mkdir(join(root, "bin"), { recursive: true });
|
||||
const scriptPath = join(root, "install-rea.sh");
|
||||
await writeFile(scriptPath, script);
|
||||
await chmod(scriptPath, 0o755);
|
||||
await execute("sh", [scriptPath], { cwd: root });
|
||||
|
||||
const linked = join(root, "opt/rea/rea.mjs");
|
||||
expect(await readlink(linked)).toBe(
|
||||
join(root, "opt/rea/payload/REA Build/scripts/rea.mjs"),
|
||||
);
|
||||
expect(await readFile(linked, "utf8")).toBe(entrypoint);
|
||||
await execute(join(root, "bin/rea"), ["--version"]);
|
||||
expect(await readFile(marker, "utf8")).toBe(`${linked}\n--version\n`);
|
||||
});
|
||||
|
||||
it("rewrites an Electron desktop command with the shared launch policy", async () => {
|
||||
const bundle = renderBuildBundle(
|
||||
{
|
||||
...request({ kind: "archive", package: "typora" }),
|
||||
desktop_entry: "typora.desktop",
|
||||
electron: true,
|
||||
},
|
||||
localPayload,
|
||||
);
|
||||
expect(bundle.files.map((file) => file.path).sort()).toContain(
|
||||
"envs/typora/prepare-electron-launch.sh",
|
||||
);
|
||||
const module = text(bundle.files, "envs/typora/typora_app.py");
|
||||
expect(module).toContain(
|
||||
'launch=Launch(at="demand", command=["gtk-launch", "typora"]),',
|
||||
);
|
||||
expect(module).toContain(
|
||||
'run=[RunScript(script="install-rea.sh"), RunScript(script="prepare-electron-launch.sh")]',
|
||||
);
|
||||
const plain = renderBuildBundle(
|
||||
{
|
||||
...request({ kind: "archive", package: "typora" }),
|
||||
desktop_entry: "typora",
|
||||
electron: false,
|
||||
},
|
||||
localPayload,
|
||||
);
|
||||
expect(
|
||||
plain.files.some((file) =>
|
||||
file.path.endsWith("prepare-electron-launch.sh"),
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
const root = await createTestTempDirectory("rea-electron-launch-");
|
||||
const policy = join(
|
||||
root,
|
||||
"opt/rea/payload/rea/dist/domain/desktop/appExportInstall.js",
|
||||
);
|
||||
await mkdir(dirname(policy), { recursive: true });
|
||||
const source = fileURLToPath(
|
||||
new URL("./appExportInstall.ts", import.meta.url),
|
||||
);
|
||||
await writeFile(
|
||||
policy,
|
||||
`export { adjustInstalledDesktopText } from ${JSON.stringify(source)};\n`,
|
||||
);
|
||||
const applications = join(root, "share/applications");
|
||||
await mkdir(applications, { recursive: true });
|
||||
const desktop = join(applications, "typora.desktop");
|
||||
const original = [
|
||||
"[Desktop Entry]",
|
||||
"Type=Application",
|
||||
"Name=Typora",
|
||||
"Exec=typora %U",
|
||||
"[Desktop Action New]",
|
||||
"Name=New Window",
|
||||
"Exec=typora --new-window",
|
||||
"",
|
||||
].join("\n");
|
||||
await writeFile(desktop, original);
|
||||
const bin = join(root, "bin");
|
||||
await mkdir(bin, { recursive: true });
|
||||
await writeFile(
|
||||
join(bin, "node"),
|
||||
`#!/bin/sh\nexec ${shellQuote(process.execPath)} --import tsx "$@"\n`,
|
||||
);
|
||||
await chmod(join(bin, "node"), 0o755);
|
||||
const script = text(bundle.files, "envs/typora/prepare-electron-launch.sh")
|
||||
.replaceAll("/opt/rea", join(root, "opt/rea"))
|
||||
.replaceAll("/usr/local/bin", bin);
|
||||
const scriptPath = join(root, "prepare-electron-launch.sh");
|
||||
await writeFile(scriptPath, script);
|
||||
await chmod(scriptPath, 0o755);
|
||||
await execute("sh", [scriptPath], {
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, XDG_DATA_DIRS: join(root, "share") },
|
||||
});
|
||||
|
||||
expect(await readFile(desktop, "utf8")).toBe(
|
||||
original.replace("Exec=typora %U", "Exec=typora --no-sandbox %U"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -36,6 +36,7 @@ const COMPUTER_ARCHITECTURES = ["amd64", "arm64"] as const;
|
||||
/** The package of `gtk-launch`, which opens the app from its desktop entry. */
|
||||
const LAUNCHER_PACKAGE = "libgtk-3-bin";
|
||||
const REA_INSTALL_SCRIPT = "install-rea.sh";
|
||||
const ELECTRON_LAUNCH_SCRIPT = "prepare-electron-launch.sh";
|
||||
const REA_PAYLOAD = "rea-payload.tar";
|
||||
|
||||
/** A URL agentenv downloads from: http or https in lowercase, without a user name or password. */
|
||||
@@ -96,6 +97,12 @@ export const buildSourceSchema = z.discriminatedUnion("kind", [
|
||||
.describe(
|
||||
"The desktop entry id the package installs, such as org.xfce.mousepad; the menu opens it with gtk-launch.",
|
||||
),
|
||||
electron: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"True when the app is Electron. The install rewrites its desktop entry's command with the same launch policy an export uses, adding --no-sandbox right after the program unless the command already has it. The computer's container does not grant the user namespaces Chromium needs, and Electron exits without that flag.",
|
||||
),
|
||||
window_class: z
|
||||
.string()
|
||||
.min(1)
|
||||
@@ -156,7 +163,10 @@ if [ "$#" -ne 1 ] || [ ! -f "$1" ]; then
|
||||
exit 1
|
||||
fi
|
||||
ln -sf /opt/rea/node-v${BUILD_NODE_VERSION}-linux-*/bin/node /usr/local/bin/node
|
||||
printf '#!/bin/sh\\nexec /usr/local/bin/node %s "$@"\\n' "$1" > /usr/local/bin/rea
|
||||
# The archive's top folder is caller-chosen and may contain spaces. The launcher
|
||||
# runs a fixed path so the shell does not split that folder before Node opens it.
|
||||
ln -sfn "$1" /opt/rea/rea.mjs
|
||||
printf '#!/bin/sh\\nexec /usr/local/bin/node /opt/rea/rea.mjs "$@"\\n' > /usr/local/bin/rea
|
||||
chmod 755 /usr/local/bin/rea
|
||||
rm -f node-*.tar.xz ${REA_PAYLOAD}
|
||||
`;
|
||||
@@ -237,10 +247,59 @@ const buildInstall = (
|
||||
scripts: [
|
||||
...(packageFile === null ? [] : [INSTALL_SCRIPT]),
|
||||
REA_INSTALL_SCRIPT,
|
||||
...(request.electron === true ? [ELECTRON_LAUNCH_SCRIPT] : []),
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* The build step that rewrites an Electron app's installed desktop command
|
||||
* with REA's launch policy. It runs after Node.js and the REA payload are
|
||||
* unpacked, and before the Applications menu opens the entry with gtk-launch.
|
||||
*/
|
||||
const renderElectronLaunchScript = (entry: string): string => `#!/bin/sh
|
||||
# Applications menu -> gtk-launch of this entry. Chromium's sandbox needs user
|
||||
# namespaces the container does not grant, so adjust the installed command first.
|
||||
set -eu
|
||||
entry='${entry}'
|
||||
desktop=
|
||||
ifs=$IFS
|
||||
IFS=:
|
||||
for dir in \${XDG_DATA_DIRS:-/usr/local/share:/usr/share}; do
|
||||
if [ -n "$dir" ] && [ -f "$dir/applications/$entry.desktop" ]; then
|
||||
desktop="$dir/applications/$entry.desktop"
|
||||
break
|
||||
fi
|
||||
done
|
||||
IFS=$ifs
|
||||
if [ -z "$desktop" ]; then
|
||||
echo "No installed desktop entry $entry.desktop was found, so its command cannot be adjusted for Electron." >&2
|
||||
exit 1
|
||||
fi
|
||||
set -- /opt/rea/payload/*/dist/domain/desktop/appExportInstall.js
|
||||
if [ "$#" -ne 1 ] || [ ! -f "$1" ]; then
|
||||
echo "The REA archive must include dist/domain/desktop/appExportInstall.js so an Electron app can use REA's launch policy." >&2
|
||||
exit 1
|
||||
fi
|
||||
/usr/local/bin/node --input-type=module - "$1" "$desktop" <<'JS'
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const policy = await import(pathToFileURL(process.argv[2]).href);
|
||||
if (typeof policy.adjustInstalledDesktopText !== "function") {
|
||||
console.error("REA's desktop launch policy did not load from the archive.");
|
||||
process.exit(1);
|
||||
}
|
||||
const desktop = process.argv[3];
|
||||
const adjusted = policy.adjustInstalledDesktopText(readFileSync(desktop, "utf8"), true);
|
||||
if (!adjusted.ok) {
|
||||
console.error(\`The desktop entry \${desktop} could not be adjusted for Electron: \${adjusted.error}.\`);
|
||||
process.exit(1);
|
||||
}
|
||||
writeFileSync(desktop, adjusted.value);
|
||||
JS
|
||||
`;
|
||||
|
||||
/** The class module: the decorators that install and launch the app, and a class with no hooks. */
|
||||
const renderBuildModule = (
|
||||
request: BuildRequest,
|
||||
@@ -306,6 +365,9 @@ export const renderBuildBundle = (
|
||||
renderBuildModule(request, entry, plan, install),
|
||||
),
|
||||
file(REA_INSTALL_SCRIPT, renderReaInstallScript()),
|
||||
...(request.electron === true
|
||||
? [file(ELECTRON_LAUNCH_SCRIPT, renderElectronLaunchScript(entry))]
|
||||
: []),
|
||||
...(install.packageFile === null
|
||||
? []
|
||||
: [file(INSTALL_SCRIPT, renderInstallScript(install.packageFile))]),
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
adjustInstalledDesktopText,
|
||||
electronLaunchArguments,
|
||||
exportInstall,
|
||||
fromUbuntuNobleArchive,
|
||||
repositoryRefusal,
|
||||
} from "./appExportInstall.js";
|
||||
import { parseDesktopEntry, tokenizeDesktopExec } from "./desktopEntry.js";
|
||||
import { installLimitations } from "./appExportInstallLimitations.js";
|
||||
import {
|
||||
VARICAD_FILE,
|
||||
@@ -558,6 +560,74 @@ describe("electronLaunchArguments", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("adjustInstalledDesktopText", () => {
|
||||
const entry = (exec: string) =>
|
||||
`[Desktop Entry]\nType=Application\nName=Typora\nExec=${exec}\n[Desktop Action New]\nExec=typora --new-window\n`;
|
||||
|
||||
it("adds the shared Electron launch policy after the program", () => {
|
||||
const text = entry("typora %U");
|
||||
expect(adjustInstalledDesktopText(text, true)).toEqual({
|
||||
ok: true,
|
||||
value: entry("typora --no-sandbox %U"),
|
||||
});
|
||||
expect(adjustInstalledDesktopText(text, false)).toEqual({
|
||||
ok: true,
|
||||
value: text,
|
||||
});
|
||||
});
|
||||
|
||||
it("puts the flag after the program an env wrapper launches and keeps a path with spaces", () => {
|
||||
expect(
|
||||
adjustInstalledDesktopText(
|
||||
entry("env APP_MODE=desktop /opt/app/app %U"),
|
||||
true,
|
||||
),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
value: entry("env APP_MODE=desktop /opt/app/app --no-sandbox %U"),
|
||||
});
|
||||
expect(
|
||||
adjustInstalledDesktopText(entry('"/opt/REA App/typora" %U'), true),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
value: entry('"/opt/REA App/typora" --no-sandbox %U'),
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a quoted trailing backslash when the launch policy rewrites Exec", () => {
|
||||
const text = String.raw`[Desktop Entry]
|
||||
Type=Application
|
||||
Name=App
|
||||
Exec=/usr/bin/true "foo\\\\"
|
||||
`;
|
||||
const rewritten = adjustInstalledDesktopText(text, true);
|
||||
if (!rewritten.ok) throw new Error(rewritten.error);
|
||||
const parsed = parseDesktopEntry(rewritten.value);
|
||||
if (!parsed.ok) throw new Error(parsed.error);
|
||||
expect(tokenizeDesktopExec(parsed.value.exec ?? "")).toEqual({
|
||||
ok: true,
|
||||
value: ["/usr/bin/true", "--no-sandbox", "foo\\"],
|
||||
});
|
||||
expect(rewritten.value).toContain(
|
||||
String.raw`Exec=/usr/bin/true --no-sandbox "foo\\\\"`,
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a command that already passes the flag, and reports an unusable Exec", () => {
|
||||
const text = entry("typora --no-sandbox %U");
|
||||
expect(adjustInstalledDesktopText(text, true)).toEqual({
|
||||
ok: true,
|
||||
value: text,
|
||||
});
|
||||
expect(
|
||||
adjustInstalledDesktopText("[Desktop Entry]\nName=Typora\n", true),
|
||||
).toEqual({ ok: false, error: "missing_exec" });
|
||||
expect(
|
||||
adjustInstalledDesktopText('[Desktop Entry]\nExec="typora\n', true),
|
||||
).toEqual({ ok: false, error: "unterminated_quote" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("installLimitations", () => {
|
||||
it("says where the package comes from, which libraries are unmapped and that the sandbox is off", () => {
|
||||
const installed: DesktopPackage = {
|
||||
|
||||
@@ -2,7 +2,13 @@ import { err, ok, type Result } from "../result.js";
|
||||
import { withoutCredentials, withoutCredentialsIn } from "./aptRepositories.js";
|
||||
import { dependencyPackages } from "./debianPackageFile.js";
|
||||
import type { AptRepository, DesktopPackage } from "./desktopCapture.js";
|
||||
import { unwrapDesktopCommand } from "./desktopEntry.js";
|
||||
import {
|
||||
formatDesktopExec,
|
||||
parseDesktopEntry,
|
||||
replaceDesktopExec,
|
||||
tokenizeDesktopExec,
|
||||
unwrapDesktopCommand,
|
||||
} from "./desktopEntry.js";
|
||||
|
||||
const NOBLE_SUITE =
|
||||
/ noble(?:-updates|-security|-backports)?\/(?:main|restricted|universe|multiverse) /u;
|
||||
@@ -475,3 +481,31 @@ export const electronLaunchArguments = (
|
||||
const at = wrapper.kind === "direct" ? wrapper.index + 1 : 1;
|
||||
return [...argv.slice(0, at), "--no-sandbox", ...argv.slice(at)];
|
||||
};
|
||||
|
||||
/**
|
||||
* The desktop entry after its main `Exec` command has been prepared with
|
||||
* `electronLaunchArguments`. A non-Electron entry, and an Electron command
|
||||
* that already passes `--no-sandbox`, keep their original text.
|
||||
*/
|
||||
export const adjustInstalledDesktopText = (
|
||||
text: string,
|
||||
electron: boolean,
|
||||
): Result<
|
||||
string,
|
||||
| "missing_desktop_entry_group"
|
||||
| "missing_exec"
|
||||
| "missing_exec_line"
|
||||
| "unterminated_quote"
|
||||
| "empty_command"
|
||||
> => {
|
||||
if (!electron) return ok(text);
|
||||
const parsed = parseDesktopEntry(text);
|
||||
if (!parsed.ok) return parsed;
|
||||
const exec = parsed.value.exec;
|
||||
if (exec === null) return err("missing_exec");
|
||||
const tokens = tokenizeDesktopExec(exec);
|
||||
if (!tokens.ok) return tokens;
|
||||
const adjusted = electronLaunchArguments(tokens.value, true);
|
||||
if (adjusted === tokens.value) return ok(text);
|
||||
return replaceDesktopExec(text, formatDesktopExec(adjusted));
|
||||
};
|
||||
@@ -1,8 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
escapeDesktopEntryValue,
|
||||
expandDesktopExec,
|
||||
formatDesktopExec,
|
||||
parseDesktopEntry,
|
||||
replaceDesktopExec,
|
||||
tokenizeDesktopExec,
|
||||
unwrapDesktopCommand,
|
||||
} from "./desktopEntry.js";
|
||||
@@ -81,6 +84,53 @@ describe("tokenizeDesktopExec", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDesktopExec", () => {
|
||||
it("round-trips arguments that contain spaces and quotes", () => {
|
||||
const exec = '"/opt/My App/run" --title "say \\"hi\\"" %F';
|
||||
const tokens = tokenizeDesktopExec(exec);
|
||||
if (!tokens.ok) throw new Error(tokens.error);
|
||||
expect(tokenizeDesktopExec(formatDesktopExec(tokens.value))).toEqual(
|
||||
tokens,
|
||||
);
|
||||
});
|
||||
|
||||
it("escapes the desktop-entry value so a trailing backslash survives a second parse", () => {
|
||||
const program = "/opt/app\\dir\\";
|
||||
const quoted = 'say "hi"\\';
|
||||
const argv = [program, "--title", quoted, "%U"];
|
||||
// Command quoting doubles each backslash; the value layer doubles them
|
||||
// again, which is the form gio accepts. One fewer level leaves a trailing
|
||||
// backslash that escapes the closing quote.
|
||||
const escaped = String.raw`Exec="/opt/app\\\\dir\\\\" --title "say \\"hi\\"\\\\" %U`;
|
||||
const text = `[Desktop Entry]\nType=Application\nName=App\n${escaped}\n`;
|
||||
|
||||
const parsed = parseDesktopEntry(text);
|
||||
if (!parsed.ok) throw new Error(parsed.error);
|
||||
expect(tokenizeDesktopExec(parsed.value.exec ?? "")).toEqual({
|
||||
ok: true,
|
||||
value: argv,
|
||||
});
|
||||
expect(escapeDesktopEntryValue(formatDesktopExec(argv))).toBe(
|
||||
escaped.slice("Exec=".length),
|
||||
);
|
||||
|
||||
const rewritten = replaceDesktopExec(
|
||||
text,
|
||||
formatDesktopExec([program, "--no-sandbox", "--title", quoted, "%U"]),
|
||||
);
|
||||
if (!rewritten.ok) throw new Error(rewritten.error);
|
||||
const again = parseDesktopEntry(rewritten.value);
|
||||
if (!again.ok) throw new Error(again.error);
|
||||
expect(tokenizeDesktopExec(again.value.exec ?? "")).toEqual({
|
||||
ok: true,
|
||||
value: [program, "--no-sandbox", "--title", quoted, "%U"],
|
||||
});
|
||||
expect(rewritten.value).toContain(
|
||||
String.raw`Exec="/opt/app\\\\dir\\\\" --no-sandbox --title "say \\"hi\\"\\\\" %U`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("expandDesktopExec", () => {
|
||||
const context = { icon: "editor", name: "Editor", location: "/e.desktop" };
|
||||
|
||||
|
||||
@@ -32,6 +32,43 @@ const unescapeValue = (value: string): string =>
|
||||
(match, next: string) => VALUE_ESCAPES[next] ?? match,
|
||||
);
|
||||
|
||||
/**
|
||||
* Escape a string for a desktop-entry value. This is the inverse of the
|
||||
* escapes `parseDesktopEntry` removes, and the same rules GLib uses when it
|
||||
* writes a string: `\\`, `\n`, `\r`, a leading `\s` or `\t`. A backslash that
|
||||
* is only command-quoted would otherwise be eaten before `Exec` is tokenized,
|
||||
* and `gio launch` rejects the resulting entry.
|
||||
*/
|
||||
export const escapeDesktopEntryValue = (value: string): string => {
|
||||
let escaped = "";
|
||||
let leading = true;
|
||||
for (const character of value) {
|
||||
switch (character) {
|
||||
case " ":
|
||||
escaped += leading ? "\\s" : " ";
|
||||
break;
|
||||
case "\t":
|
||||
escaped += leading ? "\\t" : "\t";
|
||||
break;
|
||||
case "\n":
|
||||
escaped += "\\n";
|
||||
break;
|
||||
case "\r":
|
||||
escaped += "\\r";
|
||||
break;
|
||||
case "\\":
|
||||
escaped += "\\\\";
|
||||
leading = false;
|
||||
break;
|
||||
default:
|
||||
escaped += character;
|
||||
leading = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return escaped;
|
||||
};
|
||||
|
||||
const listValue = (value: string | undefined): readonly string[] =>
|
||||
value === undefined
|
||||
? []
|
||||
@@ -109,6 +146,58 @@ export const tokenizeDesktopExec = (
|
||||
return tokens.length === 0 ? err("empty_command") : ok(tokens);
|
||||
};
|
||||
|
||||
/** Reserved characters an unquoted `Exec` argument must not contain. */
|
||||
const EXEC_RESERVED = /[\t\n\r "'\\><~|&;$*?#()`]/u;
|
||||
|
||||
/**
|
||||
* Quote `Exec` arguments so `tokenizeDesktopExec` reads them back. The
|
||||
* desktop-entry value escapes around that command are applied when the line
|
||||
* is written.
|
||||
*/
|
||||
export const formatDesktopExec = (argv: readonly string[]): string =>
|
||||
argv
|
||||
.map((argument) =>
|
||||
argument.length > 0 && !EXEC_RESERVED.test(argument)
|
||||
? argument
|
||||
: `"${argument.replace(/[\\"`$]/gu, (character) => `\\${character}`)}"`,
|
||||
)
|
||||
.join(" ");
|
||||
|
||||
const EXEC_LINE = /^Exec\s*=/u;
|
||||
|
||||
/**
|
||||
* Replace the first `Exec` line of the main group, leaving other groups
|
||||
* alone. `exec` is the command `tokenizeDesktopExec` reads; it is escaped for
|
||||
* the desktop-entry value layer before it is written.
|
||||
*/
|
||||
export const replaceDesktopExec = (
|
||||
text: string,
|
||||
exec: string,
|
||||
): Result<string, "missing_exec_line"> => {
|
||||
const newline = text.includes("\r\n") ? "\r\n" : "\n";
|
||||
let group: string | undefined;
|
||||
let replaced = false;
|
||||
const lines = text.split(/\r?\n/u).map((line) => {
|
||||
const trimmed = line.trim();
|
||||
const header = GROUP.exec(trimmed);
|
||||
if (header?.groups?.name !== undefined) {
|
||||
group = header.groups.name;
|
||||
return line;
|
||||
}
|
||||
if (
|
||||
replaced ||
|
||||
group !== "Desktop Entry" ||
|
||||
trimmed.length === 0 ||
|
||||
trimmed.startsWith("#") ||
|
||||
!EXEC_LINE.test(trimmed)
|
||||
)
|
||||
return line;
|
||||
replaced = true;
|
||||
return `Exec=${escapeDesktopEntryValue(exec)}`;
|
||||
});
|
||||
return replaced ? ok(lines.join(newline)) : err("missing_exec_line");
|
||||
};
|
||||
|
||||
/** Facts substituted for the `%i`, `%c` and `%k` field codes. */
|
||||
export interface DesktopExecContext {
|
||||
readonly icon: string | null;
|
||||
|
||||
Reference in new issue
Block a user