@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.
@@ -0,0 +1,176 @@
1
+ // A `System` over an in-memory tree, for a shell's tests.
2
+
3
+ import type { Result } from "@cprussin/option-result";
4
+ import { Err, Ok } from "@cprussin/option-result";
5
+ import type {
6
+ DirEntry,
7
+ Ran,
8
+ Stat,
9
+ Subprocess,
10
+ System,
11
+ SystemError,
12
+ } from "@domicile-desktop/sdk/system";
13
+ import { FileType, SystemErrorKind } from "@domicile-desktop/sdk/system";
14
+
15
+ /**
16
+ * A file's contents, a symlink to another path in the tree, or a path every
17
+ * call on fails.
18
+ */
19
+ export type Node =
20
+ | string
21
+ | Uint8Array
22
+ | { linksTo: string }
23
+ | { fails: SystemErrorKind };
24
+
25
+ /** What a spawned argv writes and how it ends. */
26
+ export type Spawned = {
27
+ stdout: Uint8Array | string;
28
+ stderr: string;
29
+ code: number;
30
+ };
31
+
32
+ export type FakeSystem = System & {
33
+ /** Every path read, listed or statted, in order. */
34
+ touched: string[];
35
+ };
36
+
37
+ type Tree = Readonly<Record<string, Node>>;
38
+
39
+ /**
40
+ * A system whose files are `tree`, keyed by path, with the directories
41
+ * between them implied. `spawned` answers `spawn` and `run`.
42
+ */
43
+ export const fakeSystem = (
44
+ tree: Tree,
45
+ spawned: (argv: readonly string[]) => Spawned = unexpected,
46
+ ): FakeSystem => {
47
+ const touched: string[] = [];
48
+ const at = (path: string): Result<Found, SystemError> => {
49
+ touched.push(path);
50
+ return found(tree, path);
51
+ };
52
+ const readFile = (path: string): Result<Uint8Array, SystemError> =>
53
+ at(path).andThen((node) =>
54
+ typeof node === "string" || node instanceof Uint8Array
55
+ ? Ok(bytesOf(node))
56
+ : Err({ kind: SystemErrorKind.IsADirectory, message: path }),
57
+ );
58
+ return {
59
+ dbusCall: unexpected,
60
+ dbusMatch: unexpected,
61
+ readDir: (path) =>
62
+ Promise.resolve(
63
+ at(path).andThen((node) =>
64
+ typeof node === "string" || node instanceof Uint8Array
65
+ ? Err({ kind: SystemErrorKind.NotADirectory, message: path })
66
+ : Ok(listing(tree, node.directory)),
67
+ ),
68
+ ),
69
+ readFile: (path) => Promise.resolve(readFile(path)),
70
+ readTextFile: (path) =>
71
+ Promise.resolve(
72
+ readFile(path).map((bytes) => new TextDecoder().decode(bytes)),
73
+ ),
74
+ run: (argv) => {
75
+ const { code, stderr, stdout } = spawned(argv);
76
+ const ran: Ran = {
77
+ code,
78
+ signal: undefined,
79
+ stderr,
80
+ stdout: new TextDecoder().decode(bytesOf(stdout)),
81
+ };
82
+ return Promise.resolve(Ok(ran));
83
+ },
84
+ spawn: (argv) => Promise.resolve(Ok(subprocess(spawned(argv)))),
85
+ stat: (path) =>
86
+ Promise.resolve(
87
+ at(path).map(
88
+ (node): Stat =>
89
+ typeof node === "string" || node instanceof Uint8Array
90
+ ? {
91
+ fileType: FileType.File,
92
+ modifiedMs: undefined,
93
+ size: bytesOf(node).byteLength,
94
+ }
95
+ : {
96
+ fileType: FileType.Directory,
97
+ modifiedMs: undefined,
98
+ size: 0,
99
+ },
100
+ ),
101
+ ),
102
+ touched,
103
+ watch: unexpected,
104
+ writeFile: unexpected,
105
+ };
106
+ };
107
+
108
+ /** A file's contents, or the directory a path resolved to. */
109
+ type Found = string | Uint8Array | { directory: string };
110
+
111
+ /** What is at `path`, following symlinks. */
112
+ const found = (tree: Tree, path: string): Result<Found, SystemError> => {
113
+ const node = tree[path];
114
+ const linkedParent = Object.entries(tree)
115
+ .flatMap(([link, each]) =>
116
+ typeof each === "object" && "linksTo" in each
117
+ ? [{ link, to: each.linksTo }]
118
+ : [],
119
+ )
120
+ .find(({ link }) => path.startsWith(`${link}/`));
121
+ if (linkedParent !== undefined) {
122
+ const { link, to } = linkedParent;
123
+ return found(tree, `${to}${path.slice(link.length)}`);
124
+ } else if (node === undefined) {
125
+ return Object.keys(tree).some((each) => each.startsWith(`${path}/`))
126
+ ? Ok({ directory: path })
127
+ : Err({ kind: SystemErrorKind.NotFound, message: path });
128
+ } else if (typeof node === "string" || node instanceof Uint8Array) {
129
+ return Ok(node);
130
+ } else {
131
+ return "linksTo" in node
132
+ ? found(tree, node.linksTo)
133
+ : Err({ kind: node.fails, message: path });
134
+ }
135
+ };
136
+
137
+ const listing = (tree: Tree, path: string): DirEntry[] => {
138
+ const names = new Map<string, FileType>();
139
+ for (const [each, node] of Object.entries(tree)) {
140
+ if (each.startsWith(`${path}/`)) {
141
+ const [name = "", ...below] = each.slice(path.length + 1).split("/");
142
+ names.set(name, below.length > 0 ? FileType.Directory : ownType(node));
143
+ }
144
+ }
145
+ return [...names].map(([name, fileType]) => ({ fileType, name }));
146
+ };
147
+
148
+ /** A node's type without following it. */
149
+ const ownType = (node: Node): FileType =>
150
+ typeof node === "object" && "linksTo" in node
151
+ ? FileType.Symlink
152
+ : FileType.File;
153
+
154
+ const subprocess = ({ code, stderr, stdout }: Spawned): Subprocess => ({
155
+ closeStdin: unexpected,
156
+ exited: Promise.resolve(Ok({ code, signal: undefined })),
157
+ kill: unexpected,
158
+ stderr: streamOf(bytesOf(stderr)),
159
+ stdout: streamOf(bytesOf(stdout)),
160
+ write: unexpected,
161
+ });
162
+
163
+ const bytesOf = (data: string | Uint8Array): Uint8Array =>
164
+ typeof data === "string" ? new TextEncoder().encode(data) : data;
165
+
166
+ const streamOf = (bytes: Uint8Array): ReadableStream<Uint8Array> =>
167
+ new ReadableStream({
168
+ start: (controller) => {
169
+ controller.enqueue(bytes);
170
+ controller.close();
171
+ },
172
+ });
173
+
174
+ const unexpected = (): never => {
175
+ throw new Error("the code under test made a call this fake does not take");
176
+ };
package/src/favicon.ts ADDED
@@ -0,0 +1,164 @@
1
+ // A bookmark's icon, picked as a browser picks one: the page's
2
+ // `<link rel="icon">`, else `/favicon.ico`. The caller does the fetching.
3
+
4
+ import type { Option, Result } from "@cprussin/option-result";
5
+ import { None, Ok, Some } from "@cprussin/option-result";
6
+ import type { SystemError } from "@domicile-desktop/sdk/system";
7
+
8
+ import { dataUrl } from "./data-url";
9
+
10
+ /** Largest icon drawn, as for an application's. */
11
+ const LARGEST = 128 * 1024;
12
+
13
+ /** Assumed size of an unsized touch icon, per Apple. */
14
+ const TOUCH_ICON_SIZE = 180;
15
+
16
+ /** Assumed size of an unsized icon, as in a browser tab. */
17
+ const UNSIZED = 16;
18
+
19
+ /** How an icon's bytes start, for a server that names no image type. */
20
+ const MAGIC: readonly [start: readonly number[], mime: string][] = [
21
+ [[0x89, 0x50, 0x4e, 0x47], "image/png"],
22
+ [[0, 0, 1, 0], "image/x-icon"],
23
+ [[0x47, 0x49, 0x46, 0x38], "image/gif"],
24
+ [[0xff, 0xd8, 0xff], "image/jpeg"],
25
+ [[0x3c, 0x73, 0x76, 0x67], "image/svg+xml"],
26
+ ];
27
+
28
+ /** A fetched URL: where it landed after redirects, its type and its body. */
29
+ export type Page = {
30
+ url: string;
31
+ contentType: string | undefined;
32
+ body: Uint8Array;
33
+ };
34
+
35
+ /** Fetches a URL, or nothing if it does not answer. */
36
+ export type Fetch = (url: string) => Promise<Result<Option<Page>, SystemError>>;
37
+
38
+ /**
39
+ * The icon for `url` as a `data:` URL, or nothing if none is usable.
40
+ *
41
+ * Tries the page's icon links first, SVG then largest. Links are ignored if
42
+ * the page redirected off-site, for example to a sign-in page; a parent or
43
+ * subdomain such as `www.` counts as the same site. Then tries
44
+ * `/favicon.ico`.
45
+ */
46
+ export const favicon = async (
47
+ url: string,
48
+ fetch: Fetch,
49
+ ): Promise<Result<Option<string>, SystemError>> => {
50
+ const asked = new URL(url);
51
+ return (await fetch(url)).andThenAsync((page) =>
52
+ firstPicture(
53
+ [
54
+ ...page.match({
55
+ None: () => [],
56
+ Some: (found) =>
57
+ isHtml(found) && sameSite(asked, new URL(found.url))
58
+ ? iconLinks(found)
59
+ : [],
60
+ }),
61
+ new URL("/favicon.ico", asked).href,
62
+ ],
63
+ fetch,
64
+ ),
65
+ );
66
+ };
67
+
68
+ /** The first of `urls` that answers with a picture. */
69
+ const firstPicture = async (
70
+ urls: readonly string[],
71
+ fetch: Fetch,
72
+ ): Promise<Result<Option<string>, SystemError>> => {
73
+ for (const url of urls) {
74
+ const found = (await fetch(url)).map((page) =>
75
+ page.andThen((answer) => picture(answer)),
76
+ );
77
+ if (found.mapOr(true, (icon) => icon.isSome())) {
78
+ return found;
79
+ }
80
+ }
81
+ return Ok(None());
82
+ };
83
+
84
+ const isHtml = ({ contentType }: Page): boolean => {
85
+ const type = contentType?.toLowerCase() ?? "";
86
+ return (
87
+ type.startsWith("text/html") || type.startsWith("application/xhtml+xml")
88
+ );
89
+ };
90
+
91
+ /** Whether the hosts match or one is a subdomain of the other. */
92
+ const sameSite = (asked: URL, landed: URL): boolean =>
93
+ asked.hostname === landed.hostname ||
94
+ asked.hostname.endsWith(`.${landed.hostname}`) ||
95
+ landed.hostname.endsWith(`.${asked.hostname}`);
96
+
97
+ /** The icons `page` links, resolved against it, best first. */
98
+ const iconLinks = (page: Page): string[] =>
99
+ [
100
+ ...new DOMParser()
101
+ .parseFromString(new TextDecoder().decode(page.body), "text/html")
102
+ .querySelectorAll("link"),
103
+ ]
104
+ .flatMap((link) => {
105
+ const rels = (link.getAttribute("rel") ?? "").toLowerCase().split(/\s+/);
106
+ const touch = rels.includes("apple-touch-icon");
107
+ const href = link.getAttribute("href");
108
+ if (
109
+ (rels.includes("icon") || touch) &&
110
+ href !== null &&
111
+ URL.canParse(href, page.url)
112
+ ) {
113
+ const url = new URL(href, page.url);
114
+ const drawn =
115
+ link.getAttribute("type")?.toLowerCase() === "image/svg+xml" ||
116
+ url.pathname.toLowerCase().endsWith(".svg");
117
+ const size =
118
+ largestSize(link.getAttribute("sizes") ?? "") ??
119
+ (touch ? TOUCH_ICON_SIZE : UNSIZED);
120
+ return [{ drawn, size, url: url.href }];
121
+ } else {
122
+ return [];
123
+ }
124
+ })
125
+ // Stable, so page order holds among equal icons.
126
+ .toSorted((a, b) => Number(b.drawn) - Number(a.drawn) || b.size - a.size)
127
+ .map(({ url }) => url);
128
+
129
+ /** The largest size in a `sizes` attribute; `any` is largest. */
130
+ const largestSize = (sizes: string): number | undefined => {
131
+ const each = sizes
132
+ .toLowerCase()
133
+ .split(/\s+/)
134
+ .map((size) => {
135
+ const [width, height] = size.split("x").map(Number);
136
+ return size === "any"
137
+ ? Number.POSITIVE_INFINITY
138
+ : Math.max(width ?? Number.NaN, height ?? Number.NaN);
139
+ })
140
+ .filter((size) => !Number.isNaN(size));
141
+ return each.length === 0 ? undefined : Math.max(...each);
142
+ };
143
+
144
+ /**
145
+ * `page` as a `data:` URL if it is a small enough picture. Sniffs the body
146
+ * when the type is not `image/*`, since some servers send `.ico` without one.
147
+ */
148
+ const picture = ({ body, contentType }: Page): Option<string> => {
149
+ const said = contentType?.split(";")[0]?.trim();
150
+ const mime = said?.toLowerCase().startsWith("image/") ? said : sniffed(body);
151
+ return mime === undefined || body.length > LARGEST
152
+ ? None()
153
+ : Some(dataUrl(mime, body));
154
+ };
155
+
156
+ const sniffed = (body: Uint8Array): string | undefined => {
157
+ const start = body.findIndex(
158
+ (byte) => ![0x20, 0x09, 0x0a, 0x0d, 0x0c].includes(byte),
159
+ );
160
+ const trimmed = start === -1 ? new Uint8Array() : body.subarray(start);
161
+ return MAGIC.find(([magic]) =>
162
+ magic.every((byte, at) => trimmed[at] === byte),
163
+ )?.[1];
164
+ };
@@ -0,0 +1,117 @@
1
+ // Bookmark icons, looked up in the background and kept for the page's life. A
2
+ // site can take seconds to answer, so a search shows the icons found so far.
3
+
4
+ import type { Option, Result } from "@cprussin/option-result";
5
+ import { None, Some } from "@cprussin/option-result";
6
+ import type { SystemError } from "@domicile-desktop/sdk/system";
7
+
8
+ /** How long to wait before asking again a site that gave no icon. */
9
+ const RETRY_AFTER_MS = 60_000;
10
+
11
+ enum LookedKind {
12
+ InFlight,
13
+ Found,
14
+ /** The site gave no icon, or the lookup failed. */
15
+ Missed,
16
+ }
17
+
18
+ const Looked = {
19
+ Found: (icon: string) => ({ icon, kind: LookedKind.Found as const }),
20
+ InFlight: () => ({ kind: LookedKind.InFlight as const }),
21
+ Missed: (atMs: number) => ({ atMs, kind: LookedKind.Missed as const }),
22
+ };
23
+
24
+ type Looked = ReturnType<(typeof Looked)[keyof typeof Looked]>;
25
+
26
+ export type Favicons = {
27
+ /** The icon found for `url`, if one has been. */
28
+ icon: (url: string) => Option<string>;
29
+ /**
30
+ * Looks up, one at a time, each of `urls` that is new or whose miss is due
31
+ * a retry. Settles when they are done.
32
+ */
33
+ lookFor: (urls: readonly string[]) => Promise<void>;
34
+ };
35
+
36
+ /** How {@link favicons} keeps time and reports failures; for tests. */
37
+ export type FaviconsOptions = {
38
+ retryAfterMs?: number;
39
+ now?: () => number;
40
+ /** Told of each failed lookup, which is retried later. Logs by default. */
41
+ failed?: (url: string, error: SystemError) => void;
42
+ };
43
+
44
+ /**
45
+ * Icons `find` finds, by bookmark URL. A miss or a failure is asked again
46
+ * after a minute, since the network may come up after the desktop.
47
+ */
48
+ export const favicons = (
49
+ find: (url: string) => Promise<Result<Option<string>, SystemError>>,
50
+ {
51
+ failed = logged,
52
+ now = Date.now,
53
+ retryAfterMs = RETRY_AFTER_MS,
54
+ }: FaviconsOptions = {},
55
+ ): Favicons => {
56
+ const looked = new Map<string, Looked>();
57
+ return {
58
+ icon: (url) => {
59
+ const known = looked.get(url);
60
+ return known?.kind === LookedKind.Found ? Some(known.icon) : None();
61
+ },
62
+ lookFor: async (urls) => {
63
+ const wanted = [...new Set(urls)].filter((url) =>
64
+ due(looked.get(url), now(), retryAfterMs),
65
+ );
66
+ for (const url of wanted) {
67
+ looked.set(url, Looked.InFlight());
68
+ }
69
+ for (const url of wanted) {
70
+ looked.set(url, lookedOf(await find(url), url, now(), failed));
71
+ }
72
+ },
73
+ };
74
+ };
75
+
76
+ const due = (
77
+ known: Looked | undefined,
78
+ nowMs: number,
79
+ retryAfterMs: number,
80
+ ): boolean => {
81
+ if (known === undefined) {
82
+ return true;
83
+ } else {
84
+ switch (known.kind) {
85
+ case LookedKind.Missed: {
86
+ return nowMs - known.atMs >= retryAfterMs;
87
+ }
88
+ case LookedKind.InFlight:
89
+ case LookedKind.Found: {
90
+ return false;
91
+ }
92
+ }
93
+ }
94
+ };
95
+
96
+ const lookedOf = (
97
+ found: Result<Option<string>, SystemError>,
98
+ url: string,
99
+ nowMs: number,
100
+ failed: (url: string, error: SystemError) => void,
101
+ ): Looked =>
102
+ found.match({
103
+ Err: (error): Looked => {
104
+ failed(url, error);
105
+ return Looked.Missed(nowMs);
106
+ },
107
+ Ok: (icon) =>
108
+ icon.match({
109
+ None: (): Looked => Looked.Missed(nowMs),
110
+ Some: (each): Looked => Looked.Found(each),
111
+ }),
112
+ });
113
+
114
+ const logged = (url: string, error: SystemError): void => {
115
+ // biome-ignore lint/suspicious/noConsole: surfacing a lookup that is retried later
116
+ console.error(`could not look for ${url}'s icon`, error);
117
+ };
@@ -0,0 +1,21 @@
1
+ // A launcher's search over installed applications.
2
+
3
+ import type { DesktopEntry } from "./desktop-entry";
4
+ import { ranked } from "./ranked";
5
+
6
+ /**
7
+ * Up to `limit` applications matching `query`, best first.
8
+ *
9
+ * Every query word must appear, case-insensitively, in the name, generic name
10
+ * or keywords. Names that start with the query rank first, then sort by name.
11
+ */
12
+ export const findApps = (
13
+ entries: readonly DesktopEntry[],
14
+ query: string,
15
+ limit: number,
16
+ ): DesktopEntry[] =>
17
+ ranked(entries, query, limit, {
18
+ name: ({ name }) => name,
19
+ tieBreak: ({ id }) => id,
20
+ words: ({ words }) => words,
21
+ });
@@ -0,0 +1,112 @@
1
+ // Every application installed in the XDG data directories.
2
+
3
+ import type { Result } from "@cprussin/option-result";
4
+ import { None, Ok, Some } from "@cprussin/option-result";
5
+ import type {
6
+ DirEntry,
7
+ System,
8
+ SystemError,
9
+ } from "@domicile-desktop/sdk/system";
10
+ import { FileType } from "@domicile-desktop/sdk/system";
11
+
12
+ import type { DesktopEntry } from "./desktop-entry";
13
+ import { parseDesktopEntry } from "./desktop-entry";
14
+ import { allOk, orAbsent } from "./results";
15
+
16
+ /** A desktop file ID and the file it names. */
17
+ type EntryFile = { id: string; path: string };
18
+
19
+ /**
20
+ * Every application in `applications/` under `dataDirs`, unordered.
21
+ *
22
+ * `dataDirs` is highest priority first (see `./data-dirs`). An ID in an
23
+ * earlier directory hides the same ID in later ones, even when the earlier
24
+ * entry is `Hidden`: that is how a user removes a system entry. Missing
25
+ * directories and unreadable files are skipped.
26
+ */
27
+ export const installedApps = async (
28
+ system: System,
29
+ dataDirs: readonly string[],
30
+ ): Promise<Result<DesktopEntry[], SystemError>> => {
31
+ const listed = allOk(
32
+ await Promise.all(
33
+ dataDirs.map((dir) => entryFiles(system, `${dir}/applications`, "")),
34
+ ),
35
+ );
36
+ return (
37
+ await listed.andThenAsync(async (dirs) =>
38
+ allOk(
39
+ await Promise.all(
40
+ firstOfEach(dirs.flat()).map((file) => read(system, file)),
41
+ ),
42
+ ),
43
+ )
44
+ ).map((read) =>
45
+ read.flatMap((entry) =>
46
+ entry.match({ None: () => [], Some: (found) => [found] }),
47
+ ),
48
+ );
49
+ };
50
+
51
+ /** Each ID's first file, in priority order. */
52
+ const firstOfEach = (files: readonly EntryFile[]): EntryFile[] => {
53
+ const seen = new Set<string>();
54
+ return files.filter(({ id }) => {
55
+ const first = !seen.has(id);
56
+ seen.add(id);
57
+ return first;
58
+ });
59
+ };
60
+
61
+ /** `file` parsed, or nothing for an unreadable file or one not to offer. */
62
+ const read = async (system: System, { id, path }: EntryFile) =>
63
+ orAbsent(
64
+ (await system.readTextFile(path)).map((text) =>
65
+ parseDesktopEntry(id, text),
66
+ ),
67
+ None<DesktopEntry>(),
68
+ );
69
+
70
+ /**
71
+ * Every `.desktop` file under `dir`, recursively, with its ID: `prefix` plus
72
+ * its path under `dir`, `/` read as `-`.
73
+ */
74
+ const entryFiles = async (
75
+ system: System,
76
+ dir: string,
77
+ prefix: string,
78
+ ): Promise<Result<EntryFile[], SystemError>> =>
79
+ orAbsent(await system.readDir(dir), []).andThenAsync(async (entries) =>
80
+ allOk(
81
+ await Promise.all(
82
+ entries.map((entry) => filesAt(system, dir, prefix, entry)),
83
+ ),
84
+ ).map((found) => found.flat()),
85
+ );
86
+
87
+ /** The entry files `entry` in `dir` holds: itself, or those under it. */
88
+ const filesAt = async (
89
+ system: System,
90
+ dir: string,
91
+ prefix: string,
92
+ { fileType, name }: DirEntry,
93
+ ): Promise<Result<EntryFile[], SystemError>> => {
94
+ const path = `${dir}/${name}`;
95
+ const below = () => entryFiles(system, path, `${prefix}${name}-`);
96
+ if (fileType === FileType.Directory) {
97
+ return below();
98
+ } else if (name.endsWith(".desktop")) {
99
+ return Ok([{ id: `${prefix}${name}`, path }]);
100
+ } else if (fileType === FileType.Symlink) {
101
+ return orAbsent(
102
+ (await system.stat(path)).map(({ fileType: target }) =>
103
+ target === FileType.Directory ? Some(target) : None<FileType>(),
104
+ ),
105
+ None<FileType>(),
106
+ ).andThenAsync((linked) =>
107
+ linked.isSome() ? below() : Promise.resolve(Ok([])),
108
+ );
109
+ } else {
110
+ return Ok([]);
111
+ }
112
+ };
@@ -0,0 +1,81 @@
1
+ // The default applications `mimeapps.list` names for a content type. See
2
+ // https://specifications.freedesktop.org/mime-apps-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
+ import { allOk, orAbsent } from "./results";
9
+
10
+ /** The group defaults are read from. */
11
+ const GROUP = "[Default Applications]";
12
+
13
+ /**
14
+ * The desktop file IDs, `.desktop` included, set as `contentType`'s default,
15
+ * most preferred first. The caller takes the first it can offer.
16
+ *
17
+ * Reads every `mimeapps.list` the spec names, the current desktop's own
18
+ * (`domicile-mimeapps.list`) before each plain one. Missing lists are
19
+ * skipped.
20
+ */
21
+ export const defaultApps = async (
22
+ system: System,
23
+ contentType: string,
24
+ ): Promise<Result<string[], SystemError>> =>
25
+ (await desktopEnvironment(system)).andThenAsync(async (variables) =>
26
+ allOk(
27
+ await Promise.all(
28
+ listPaths(variables).map(async (path) =>
29
+ orAbsent(await system.readTextFile(path), "").map((text) =>
30
+ defaultsIn(text, contentType),
31
+ ),
32
+ ),
33
+ ),
34
+ ).map((found) => found.flat()),
35
+ );
36
+
37
+ /** Every list to read, in the spec's order. */
38
+ const listPaths = (variables: ReadonlyMap<string, string>): string[] => {
39
+ const desktops = (setVariable(variables, "XDG_CURRENT_DESKTOP") ?? "")
40
+ .split(":")
41
+ .filter((desktop) => desktop !== "")
42
+ .map((desktop) => desktop.toLowerCase());
43
+ const names = [
44
+ ...desktops.map((desktop) => `${desktop}-mimeapps.list`),
45
+ "mimeapps.list",
46
+ ];
47
+ const dataDirs = [
48
+ setVariable(variables, "XDG_DATA_HOME") ?? ".local/share",
49
+ ...(
50
+ setVariable(variables, "XDG_DATA_DIRS") ?? "/usr/local/share:/usr/share"
51
+ ).split(":"),
52
+ ].map((dir) => `${dir}/applications`);
53
+ return [
54
+ setVariable(variables, "XDG_CONFIG_HOME") ?? ".config",
55
+ ...(setVariable(variables, "XDG_CONFIG_DIRS") ?? "/etc/xdg").split(":"),
56
+ ...dataDirs,
57
+ ].flatMap((dir) => names.map((name) => `${dir}/${name}`));
58
+ };
59
+
60
+ /** The IDs list `text` sets for `contentType`. */
61
+ const defaultsIn = (text: string, contentType: string): string[] => {
62
+ let group = "";
63
+ const found: string[] = [];
64
+ for (const line of text.split("\n").map((each) => each.trim())) {
65
+ if (line.startsWith("[")) {
66
+ group = line;
67
+ } else if (group === GROUP && !line.startsWith("#")) {
68
+ const equals = line.indexOf("=");
69
+ if (equals !== -1 && line.slice(0, equals).trim() === contentType) {
70
+ found.push(
71
+ ...line
72
+ .slice(equals + 1)
73
+ .split(";")
74
+ .map((id) => id.trim())
75
+ .filter((id) => id !== ""),
76
+ );
77
+ }
78
+ }
79
+ }
80
+ return found;
81
+ };