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:
morlutoandCursor Agent authored and GitHub committed 2026-11-06 14:07:01 +08:00
1 parent c467cf8685
commit 8580f1430c
9 files changed
+472 -4

No files matched your search

+7
View File
@@ -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.
+1 -1
View File
@@ -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(),
+153
View File
@@ -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"),
);
});
});
+63 -1
View File
@@ -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 = {
+35 -1
View File
@@ -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));
};
+50
View File
@@ -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" };
+89
View File
@@ -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;