@domicile-desktop/system-apps 0.0.0-alpha-a330bf5f3aae

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,50 @@
1
+ # @domicile-desktop/system-apps
2
+
3
+ Installed applications and bookmarks for a shell's launcher, read through
4
+ [`@domicile-desktop/sdk/system`](../chrome-sdk/README.md). Manganese's launcher
5
+ uses it (`src/launcher/app-search.ts`).
6
+
7
+ ## Usage
8
+
9
+ ```ts
10
+ import { system } from "@domicile-desktop/sdk/system";
11
+ import { appIcons } from "@domicile-desktop/system-apps/app-icons";
12
+ import { dataDirs } from "@domicile-desktop/system-apps/data-dirs";
13
+ import { findApps } from "@domicile-desktop/system-apps/find-apps";
14
+ import { installedApps } from "@domicile-desktop/system-apps/installed";
15
+
16
+ const host = system(domicile);
17
+ const dirs = (await dataDirs(host)).unwrapOr([]);
18
+ const apps = (await installedApps(host, dirs)).unwrapOr([]);
19
+ const icon = appIcons(host, dirs);
20
+ const editors = findApps(apps, "edit", 50);
21
+ ```
22
+
23
+ ## Modules
24
+
25
+ | Module | What it is |
26
+ | --- | --- |
27
+ | `./data-dirs` | The XDG data directories, from the desktop's environment (`env -0`). |
28
+ | `./installed` | Every desktop entry under them; an earlier directory hides a later one's ID. |
29
+ | `./desktop-entry` | One entry parsed: name, comment, argv, icon and `X-Domicile-Preview`. |
30
+ | `./find-apps`, `./bookmark` | The launcher's matching and ranking; bookmarks and their Zod schema. |
31
+ | `./mime-apps` | A content type's default applications from every `mimeapps.list`, the desktop's own (`domicile-mimeapps.list`) first. |
32
+ | `./omit` | Desktop file IDs left out, as globs. |
33
+ | `./app-icons` | Icon names resolved in `hicolor` and `pixmaps` to `data:` URLs (PNG, SVG, 128 KiB at most). |
34
+ | `./favicon`, `./curl`, `./favicons` | A bookmark's icon: picked from the page, fetched with `curl`, kept and retried. |
35
+ | `./fake-system` | A `System` over an in-memory tree, for tests. |
36
+
37
+ - Calls resolve `Result`s with the SDK's `SystemError`; a missing or
38
+ unreadable file or directory is skipped, as the specs say.
39
+ - `./curl` needs `curl` on the desktop's `PATH`. Domicile's Nix package adds
40
+ it.
41
+ - Launching stays with the shell: pass an entry's `command` to `spawn`.
42
+
43
+ ## Test
44
+
45
+ ```sh
46
+ bun run turbo test --filter @domicile-desktop/system-apps
47
+ ```
48
+
49
+ Entry parsing is tested on files under `src/recorded/`, copied from real
50
+ installs.
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "dependencies": {
3
+ "@cprussin/option-result": "^2.0.1",
4
+ "@domicile-desktop/sdk": "0.0.0-alpha-a330bf5f3aae",
5
+ "zod": "^4.5.4"
6
+ },
7
+ "description": "Installed applications and bookmarks for a Domicile shell's launcher, read through @domicile-desktop/sdk/system.",
8
+ "devDependencies": {
9
+ "@cprussin/tsconfig": "^5.0.0",
10
+ "@domicile-desktop/test-support": "0.0.0",
11
+ "@types/bun": "^1.4.2",
12
+ "typescript": "^7.0.2"
13
+ },
14
+ "exports": {
15
+ "./app-icons": "./src/app-icons.ts",
16
+ "./bookmark": "./src/bookmark.ts",
17
+ "./curl": "./src/curl.ts",
18
+ "./data-dirs": "./src/data-dirs.ts",
19
+ "./desktop-entry": "./src/desktop-entry.ts",
20
+ "./fake-system": "./src/fake-system.ts",
21
+ "./favicon": "./src/favicon.ts",
22
+ "./favicons": "./src/favicons.ts",
23
+ "./find-apps": "./src/find-apps.ts",
24
+ "./installed": "./src/installed.ts",
25
+ "./mime-apps": "./src/mime-apps.ts",
26
+ "./omit": "./src/omit.ts"
27
+ },
28
+ "files": [
29
+ "src/**",
30
+ "!src/**/*.test.ts",
31
+ "!src/recorded/**"
32
+ ],
33
+ "license": "MIT",
34
+ "name": "@domicile-desktop/system-apps",
35
+ "private": false,
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "repository": {
40
+ "directory": "packages/system-apps",
41
+ "type": "git",
42
+ "url": "git+https://github.com/cprussin/domicile.git"
43
+ },
44
+ "scripts": {
45
+ "test:types": "tsc --noEmit",
46
+ "test:unit": "bun test"
47
+ },
48
+ "type": "module",
49
+ "version": "0.0.0-alpha-a330bf5f3aae"
50
+ }
@@ -0,0 +1,161 @@
1
+ // Desktop-entry icon names resolved to `data:` URLs, for `Icon` and
2
+ // `X-Domicile-Preview`.
3
+ //
4
+ // Follows the icon theme spec: `icons/hicolor/<size>/apps/<name>.<ext>` under
5
+ // each data directory, then `pixmaps/<name>.<ext>`. Only `hicolor` is
6
+ // searched: every application installs into it and every theme inherits it.
7
+
8
+ import type { Option, Result } from "@cprussin/option-result";
9
+ import { None, Ok, Some } from "@cprussin/option-result";
10
+ import type { System, SystemError } from "@domicile-desktop/sdk/system";
11
+
12
+ import { dataUrl } from "./data-url";
13
+ import { allOk, orAbsent } from "./results";
14
+
15
+ /**
16
+ * Icon sizes to try, most preferred first. The first few are large enough for
17
+ * a preview and small enough to draw in every row.
18
+ */
19
+ const SIZES = [
20
+ "48x48",
21
+ "64x64",
22
+ "32x32",
23
+ "96x96",
24
+ "128x128",
25
+ "scalable",
26
+ "256x256",
27
+ "24x24",
28
+ "22x22",
29
+ "16x16",
30
+ "512x512",
31
+ ];
32
+
33
+ /** Extensions a page can draw, preferred first, and their MIME types. */
34
+ const KINDS: Readonly<Record<string, string>> = {
35
+ png: "image/png",
36
+ svg: "image/svg+xml",
37
+ };
38
+
39
+ /** Largest icon read. Every matched row draws one, so a larger file is skipped. */
40
+ const LARGEST = 128 * 1024;
41
+
42
+ /** An icon by name, as a `data:` URL, or nothing if none is usable. */
43
+ export type IconLookup = (
44
+ name: string,
45
+ ) => Promise<Result<Option<string>, SystemError>>;
46
+
47
+ /** A directory icons may be in, and the file names it holds. */
48
+ type Listed = { dir: string; names: ReadonlySet<string> };
49
+
50
+ /**
51
+ * Icons under `dataDirs`, highest priority first. An absolute name is read as
52
+ * a file, as the spec allows.
53
+ *
54
+ * Lists the theme's directories once, on the first lookup, and each name
55
+ * once. Make a new lookup to see newly installed icons.
56
+ */
57
+ export const appIcons = (
58
+ system: System,
59
+ dataDirs: readonly string[],
60
+ ): IconLookup => {
61
+ let listed: Promise<Result<Listed[], SystemError>> | undefined;
62
+ const looked = new Map<
63
+ string,
64
+ Promise<Result<Option<string>, SystemError>>
65
+ >();
66
+ return (name) => {
67
+ const known = looked.get(name);
68
+ if (known === undefined) {
69
+ listed ??= listings(system, dataDirs);
70
+ const lookup = name.startsWith("/")
71
+ ? read(system, name)
72
+ : listed.then((dirs) =>
73
+ dirs.andThenAsync((each) => firstUsable(system, each, name)),
74
+ );
75
+ looked.set(name, lookup);
76
+ return lookup;
77
+ } else {
78
+ return known;
79
+ }
80
+ };
81
+ };
82
+
83
+ /** Every directory an icon may be in, in the order to search them. */
84
+ const listings = async (
85
+ system: System,
86
+ dataDirs: readonly string[],
87
+ ): Promise<Result<Listed[], SystemError>> => {
88
+ const themed = allOk(
89
+ await Promise.all(dataDirs.map((dir) => themeDirs(system, dir))),
90
+ ).map((dirs) => dirs.flat());
91
+ const pixmaps = dataDirs.map((dir) => `${dir}/pixmaps`);
92
+ return themed.andThenAsync(async (dirs) =>
93
+ allOk(
94
+ await Promise.all(
95
+ [...dirs, ...pixmaps].map(async (dir) =>
96
+ orAbsent(await system.readDir(dir), []).map(
97
+ (entries): Listed => ({
98
+ dir,
99
+ names: new Set(entries.map((entry) => entry.name)),
100
+ }),
101
+ ),
102
+ ),
103
+ ),
104
+ ),
105
+ );
106
+ };
107
+
108
+ /** `hicolor`'s `apps` directories under `dataDir`, by preferred size. */
109
+ const themeDirs = async (
110
+ system: System,
111
+ dataDir: string,
112
+ ): Promise<Result<string[], SystemError>> => {
113
+ const theme = `${dataDir}/icons/hicolor`;
114
+ return orAbsent(await system.readDir(theme), []).map((entries) => {
115
+ const sizes = new Set(entries.map((entry) => entry.name));
116
+ return SIZES.filter((size) => sizes.has(size)).map(
117
+ (size) => `${theme}/${size}/apps`,
118
+ );
119
+ });
120
+ };
121
+
122
+ /** The first of `name`'s files in `dirs` that can be drawn. */
123
+ const firstUsable = async (
124
+ system: System,
125
+ dirs: readonly Listed[],
126
+ name: string,
127
+ ): Promise<Result<Option<string>, SystemError>> => {
128
+ const candidates = dirs.flatMap(({ dir, names }) =>
129
+ Object.keys(KINDS)
130
+ .map((kind) => `${name}.${kind}`)
131
+ .filter((file) => names.has(file))
132
+ .map((file) => `${dir}/${file}`),
133
+ );
134
+ for (const path of candidates) {
135
+ const icon = await read(system, path);
136
+ if (icon.mapOr(true, (found) => found.isSome())) {
137
+ return icon;
138
+ }
139
+ }
140
+ return Ok(None());
141
+ };
142
+
143
+ /** The file at `path` as a `data:` URL, or nothing if it cannot be drawn. */
144
+ const read = async (
145
+ system: System,
146
+ path: string,
147
+ ): Promise<Result<Option<string>, SystemError>> => {
148
+ const mime = KINDS[path.slice(path.lastIndexOf(".") + 1)];
149
+ return mime === undefined
150
+ ? Ok(None())
151
+ : orAbsent(
152
+ await (await system.stat(path)).andThenAsync(async ({ size }) =>
153
+ size > LARGEST
154
+ ? Ok(None<string>())
155
+ : (await system.readFile(path)).map((bytes) =>
156
+ Some(dataUrl(mime, bytes)),
157
+ ),
158
+ ),
159
+ None(),
160
+ );
161
+ };
@@ -0,0 +1,39 @@
1
+ // Web pages a launcher offers by name, beside the applications.
2
+
3
+ import { z } from "zod";
4
+
5
+ import { ranked } from "./ranked";
6
+
7
+ /**
8
+ * A launcher row that opens `url`. Only `http` and `https`: the launcher opens
9
+ * it as a page and fetches the site's icon from it.
10
+ */
11
+ const bookmarkSchema = z.strictObject({
12
+ /** The row's label, which queries match. */
13
+ name: z.string(),
14
+ url: z
15
+ .string()
16
+ .regex(/^https?:\/\/./i, "a bookmark's URL must be http or https"),
17
+ });
18
+
19
+ /** A shell's bookmarks, as its config gives them. */
20
+ export const bookmarksSchema = z.array(bookmarkSchema).readonly();
21
+
22
+ export type Bookmark = z.infer<typeof bookmarkSchema>;
23
+
24
+ /**
25
+ * Up to `limit` bookmarks matching `query`, best first.
26
+ *
27
+ * Matches as `findApps` does, so rows agree: every query word must appear,
28
+ * case-insensitively, in the name or URL.
29
+ */
30
+ export const findBookmarks = (
31
+ bookmarks: readonly Bookmark[],
32
+ query: string,
33
+ limit: number,
34
+ ): Bookmark[] =>
35
+ ranked(bookmarks, query, limit, {
36
+ name: ({ name }) => name,
37
+ tieBreak: ({ url }) => url,
38
+ words: ({ name, url }) => `${name} ${url}`.toLowerCase(),
39
+ });
package/src/curl.ts ADDED
@@ -0,0 +1,54 @@
1
+ // Fetching a web page with `curl`. A shell page cannot: other sites do not
2
+ // allow its origin to read their answers.
3
+
4
+ import { None, Some } from "@cprussin/option-result";
5
+ import type { System } from "@domicile-desktop/sdk/system";
6
+
7
+ import type { Fetch, Page } from "./favicon";
8
+
9
+ /** Seconds a site may take. Lookups run one at a time. */
10
+ const PATIENCE = "10";
11
+
12
+ /** A browser's user agent, since some sites serve other clients a different page. */
13
+ const USER_AGENT =
14
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0 Safari/537.36";
15
+
16
+ /**
17
+ * Fetches with the `curl` on the desktop's `PATH`, following redirects. Sends
18
+ * no cookies. A site that does not answer, or answers with an error, is
19
+ * nothing.
20
+ */
21
+ export const curlFetch =
22
+ (system: System): Fetch =>
23
+ async (url) =>
24
+ (
25
+ await system.spawn([
26
+ "curl",
27
+ "--silent",
28
+ "--fail",
29
+ "--location",
30
+ "--max-time",
31
+ PATIENCE,
32
+ "--user-agent",
33
+ USER_AGENT,
34
+ // After the body, on stderr: where it landed, then its type.
35
+ "--write-out",
36
+ "%{stderr}%{url_effective}\n%{content_type}",
37
+ url,
38
+ ])
39
+ ).andThenAsync(async (curl) => {
40
+ const [body, written, exited] = await Promise.all([
41
+ new Response(curl.stdout).arrayBuffer(),
42
+ new Response(curl.stderr).text(),
43
+ curl.exited,
44
+ ]);
45
+ return exited.map(({ code }) => {
46
+ const [landed = url, contentType = ""] = written.split("\n");
47
+ const page: Page = {
48
+ body: new Uint8Array(body),
49
+ contentType: contentType === "" ? undefined : contentType,
50
+ url: landed,
51
+ };
52
+ return code === 0 ? Some(page) : None<Page>();
53
+ });
54
+ });
@@ -0,0 +1,28 @@
1
+ // The XDG data directories, where desktop entries and icons are installed. See
2
+ // https://specifications.freedesktop.org/basedir-spec/latest/.
3
+
4
+ import type { Result } from "@cprussin/option-result";
5
+ import type { System, SystemError } from "@domicile-desktop/sdk/system";
6
+
7
+ import { desktopEnvironment, setVariable } from "./environment";
8
+
9
+ /** The spec's default for an unset or empty `XDG_DATA_DIRS`. */
10
+ const DEFAULT_DATA_DIRS = "/usr/local/share:/usr/share";
11
+
12
+ /**
13
+ * The desktop's data directories, highest priority first: `XDG_DATA_HOME`,
14
+ * then each of `XDG_DATA_DIRS`.
15
+ *
16
+ * Read from the environment processes start in, which a page cannot see. An
17
+ * unset data home is `.local/share`, relative to the home as every system
18
+ * call's path is.
19
+ */
20
+ export const dataDirs = async (
21
+ system: System,
22
+ ): Promise<Result<string[], SystemError>> =>
23
+ (await desktopEnvironment(system)).map((variables) => [
24
+ setVariable(variables, "XDG_DATA_HOME") ?? ".local/share",
25
+ ...(setVariable(variables, "XDG_DATA_DIRS") ?? DEFAULT_DATA_DIRS).split(
26
+ ":",
27
+ ),
28
+ ]);
@@ -0,0 +1,12 @@
1
+ // Bytes as a `data:` URL, so a page can draw a file it cannot fetch.
2
+
3
+ /** Bytes passed to `String.fromCharCode` at once, under its argument limit. */
4
+ const CHUNK = 0x80_00;
5
+
6
+ export const dataUrl = (mime: string, bytes: Uint8Array): string => {
7
+ const chunks: string[] = [];
8
+ for (let at = 0; at < bytes.length; at += CHUNK) {
9
+ chunks.push(String.fromCharCode(...bytes.subarray(at, at + CHUNK)));
10
+ }
11
+ return `data:${mime};base64,${btoa(chunks.join(""))}`;
12
+ };
@@ -0,0 +1,85 @@
1
+ // A desktop entry file read into a launchable application. See
2
+ // https://specifications.freedesktop.org/desktop-entry-spec/latest/.
3
+
4
+ import type { Option } from "@cprussin/option-result";
5
+ import { None } from "@cprussin/option-result";
6
+
7
+ import { unescaped } from "./escapes";
8
+ import { commandOf } from "./exec";
9
+
10
+ /** The only group keys are read from. */
11
+ const GROUP = "[Desktop Entry]";
12
+
13
+ /** An application a desktop entry offers. */
14
+ export type DesktopEntry = {
15
+ /** The desktop file ID: its path under `applications/`, `/` read as `-`. */
16
+ id: string;
17
+ name: string;
18
+ /** Empty for an entry with no `Comment`. */
19
+ comment: string;
20
+ /** The argv it runs. */
21
+ command: readonly string[];
22
+ /** `Name`, `GenericName` and `Keywords`, in lower case, for `findApps`. */
23
+ words: string;
24
+ /** `Icon`: a theme name or an absolute path. See `./app-icons`. */
25
+ icon: string | undefined;
26
+ /** `X-Domicile-Preview`, in the same form as `icon`. */
27
+ preview: string | undefined;
28
+ };
29
+
30
+ /**
31
+ * Entry `text` as `id`, or nothing if a launcher should not offer it.
32
+ *
33
+ * Reads only unlocalized keys in `[Desktop Entry]`. Rejects non-applications,
34
+ * `Hidden` or `NoDisplay` entries, entries without a `Name` or a readable
35
+ * `Exec`, and `Terminal` entries, since no terminal is configured.
36
+ */
37
+ export const parseDesktopEntry = (
38
+ id: string,
39
+ text: string,
40
+ ): Option<DesktopEntry> => {
41
+ const keys = keysOf(text);
42
+ const flag = (key: string) => keys.get(key) === "true";
43
+ const name = keys.get("Name");
44
+ const exec = keys.get("Exec");
45
+ return keys.get("Type") !== "Application" ||
46
+ flag("Hidden") ||
47
+ flag("NoDisplay") ||
48
+ flag("Terminal") ||
49
+ name === undefined ||
50
+ exec === undefined
51
+ ? None()
52
+ : commandOf(exec).map((command) => ({
53
+ command,
54
+ comment: unescaped(keys.get("Comment") ?? ""),
55
+ icon: optional(keys.get("Icon")),
56
+ id,
57
+ name: unescaped(name),
58
+ preview: optional(keys.get("X-Domicile-Preview")),
59
+ words: [name, keys.get("GenericName") ?? "", keys.get("Keywords") ?? ""]
60
+ .map(unescaped)
61
+ .join("\n")
62
+ .toLowerCase(),
63
+ }));
64
+ };
65
+
66
+ /** The `[Desktop Entry]` group's keys; the first of a repeated key wins. */
67
+ const keysOf = (text: string): ReadonlyMap<string, string> => {
68
+ const keys = new Map<string, string>();
69
+ let group = "";
70
+ for (const line of text.split("\n").map((each) => each.trim())) {
71
+ if (line.startsWith("[")) {
72
+ group = line;
73
+ } else if (group === GROUP && !line.startsWith("#")) {
74
+ const equals = line.indexOf("=");
75
+ const key = line.slice(0, equals).trim();
76
+ if (equals !== -1 && !keys.has(key)) {
77
+ keys.set(key, line.slice(equals + 1).trim());
78
+ }
79
+ }
80
+ }
81
+ return keys;
82
+ };
83
+
84
+ const optional = (value: string | undefined): string | undefined =>
85
+ value === undefined ? undefined : unescaped(value);
@@ -0,0 +1,37 @@
1
+ // The desktop's environment, which a page cannot see.
2
+
3
+ import type { Result } from "@cprussin/option-result";
4
+ import type { System, SystemError } from "@domicile-desktop/sdk/system";
5
+
6
+ /** The variables the shell's processes start with, read with `env -0`. */
7
+ export const desktopEnvironment = async (
8
+ system: System,
9
+ ): Promise<Result<ReadonlyMap<string, string>, SystemError>> =>
10
+ (await system.run(["env", "-0"])).map((ran) => {
11
+ if (ran.code === 0) {
12
+ return environmentOf(ran.stdout);
13
+ } else {
14
+ throw new Error(`env failed: ${ran.stderr}`);
15
+ }
16
+ });
17
+
18
+ /** Variable `name`, or `undefined` when unset or, per the XDG specs, empty. */
19
+ export const setVariable = (
20
+ variables: ReadonlyMap<string, string>,
21
+ name: string,
22
+ ): string | undefined => {
23
+ const value = variables.get(name);
24
+ return value === "" ? undefined : value;
25
+ };
26
+
27
+ /** `env -0`'s output: `NAME=value`, each ended by a NUL. */
28
+ const environmentOf = (output: string): ReadonlyMap<string, string> =>
29
+ new Map(
30
+ output
31
+ .split("\0")
32
+ .filter((variable) => variable.includes("="))
33
+ .map((variable) => {
34
+ const equals = variable.indexOf("=");
35
+ return [variable.slice(0, equals), variable.slice(equals + 1)];
36
+ }),
37
+ );
package/src/escapes.ts ADDED
@@ -0,0 +1,16 @@
1
+ // The desktop entry spec's string escapes.
2
+
3
+ const ESCAPES: Readonly<Record<string, string>> = {
4
+ "\\": "\\",
5
+ n: "\n",
6
+ r: "\r",
7
+ s: " ",
8
+ t: "\t",
9
+ };
10
+
11
+ /** `value` with `\s`, `\n`, `\t`, `\r` and `\\` resolved; any other `\` kept. */
12
+ export const unescaped = (value: string): string =>
13
+ value.replaceAll(
14
+ /\\(.?)/gs,
15
+ (sequence, next: string) => ESCAPES[next] ?? sequence,
16
+ );
package/src/exec.ts ADDED
@@ -0,0 +1,44 @@
1
+ // An `Exec` value read into argv, per the desktop entry spec.
2
+
3
+ import type { Option } from "@cprussin/option-result";
4
+ import { None, Some } from "@cprussin/option-result";
5
+
6
+ import { unescaped } from "./escapes";
7
+
8
+ /**
9
+ * `exec` as argv, or nothing if it is malformed or empty.
10
+ *
11
+ * Unescapes strings, then quoting, in the spec's order. Drops field codes,
12
+ * since a launcher passes no files, URLs or icon, along with any argument
13
+ * that was only a field code.
14
+ */
15
+ export const commandOf = (exec: string): Option<string[]> => {
16
+ const words: string[] = [];
17
+ let word = "";
18
+ let quoted = false;
19
+ // Whether the previous character was a `\` inside quotes, or a `%`.
20
+ let escaping = false;
21
+ let fieldCode = false;
22
+ for (const char of unescaped(exec)) {
23
+ if (escaping) {
24
+ word += char;
25
+ escaping = false;
26
+ } else if (fieldCode) {
27
+ word += char === "%" ? "%" : "";
28
+ fieldCode = false;
29
+ } else if (char === '"') {
30
+ quoted = !quoted;
31
+ } else if (char === "\\" && quoted) {
32
+ escaping = true;
33
+ } else if (char === "%") {
34
+ fieldCode = true;
35
+ } else if (char === " " && !quoted) {
36
+ words.push(...(word === "" ? [] : [word]));
37
+ word = "";
38
+ } else {
39
+ word += char;
40
+ }
41
+ }
42
+ words.push(...(word === "" ? [] : [word]));
43
+ return quoted || words.length === 0 ? None() : Some(words);
44
+ };