@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 +50 -0
- package/package.json +50 -0
- package/src/app-icons.ts +161 -0
- package/src/bookmark.ts +39 -0
- package/src/curl.ts +54 -0
- package/src/data-dirs.ts +28 -0
- package/src/data-url.ts +12 -0
- package/src/desktop-entry.ts +85 -0
- package/src/environment.ts +37 -0
- package/src/escapes.ts +16 -0
- package/src/exec.ts +44 -0
- package/src/fake-system.ts +176 -0
- package/src/favicon.ts +164 -0
- package/src/favicons.ts +117 -0
- package/src/find-apps.ts +21 -0
- package/src/installed.ts +112 -0
- package/src/mime-apps.ts +81 -0
- package/src/omit.ts +51 -0
- package/src/ranked.ts +60 -0
- package/src/results.ts +46 -0
|
@@ -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
|
+
};
|
package/src/favicons.ts
ADDED
|
@@ -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
|
+
};
|
package/src/find-apps.ts
ADDED
|
@@ -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
|
+
});
|
package/src/installed.ts
ADDED
|
@@ -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
|
+
};
|
package/src/mime-apps.ts
ADDED
|
@@ -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
|
+
};
|