@domicile-desktop/system-apps 0.0.0-alpha-ddde2bfbbaad → 0.0.0-alpha-e1457b57e6b2

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 CHANGED
@@ -31,6 +31,7 @@ const editors = findApps(apps, "edit", 50);
31
31
  | `./find-apps`, `./bookmark` | The launcher's matching and ranking; bookmarks and their Zod schema. |
32
32
  | `./describe-apps` | Application IDs named and drawn by their entries, for a shell to say who asks. |
33
33
  | `./mime-apps` | A content type's default applications from every `mimeapps.list`, the desktop's own (`domicile-mimeapps.list`) first. |
34
+ | `./openers` | The applications that open a file, its default first, and the argv each opens it with. The type comes from shared-mime-info's `mime/globs2`, by name. |
34
35
  | `./omit` | Desktop file IDs left out, as globs. |
35
36
  | `./app-icons` | Icon names resolved in `hicolor` and `pixmaps` to `data:` URLs (PNG, SVG, 128 KiB at most). |
36
37
  | `./favicon`, `./curl`, `./favicons` | A bookmark's icon: picked from the page, fetched with `curl`, kept and retried. |
@@ -40,7 +41,8 @@ const editors = findApps(apps, "edit", 50);
40
41
  unreadable file or directory is skipped, as the specs say.
41
42
  - `./curl` needs `curl` on the desktop's `PATH`. Domicile's Nix package adds
42
43
  it.
43
- - Launching stays with the shell: pass an entry's `command` to `spawn`.
44
+ - Launching stays with the shell: pass an entry's `command`, or an
45
+ `openers` result's `command(app)`, to `spawn`.
44
46
 
45
47
  ## Test
46
48
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "dependencies": {
3
3
  "@cprussin/option-result": "^2.0.1",
4
- "@domicile-desktop/sdk": "0.0.0-alpha-ddde2bfbbaad",
4
+ "@domicile-desktop/sdk": "0.0.0-alpha-e1457b57e6b2",
5
5
  "zod": "^4.5.4"
6
6
  },
7
7
  "description": "Installed applications and bookmarks for a Domicile shell's launcher, read through @domicile-desktop/sdk/system.",
@@ -24,7 +24,8 @@
24
24
  "./find-apps": "./src/find-apps.ts",
25
25
  "./installed": "./src/installed.ts",
26
26
  "./mime-apps": "./src/mime-apps.ts",
27
- "./omit": "./src/omit.ts"
27
+ "./omit": "./src/omit.ts",
28
+ "./openers": "./src/openers.ts"
28
29
  },
29
30
  "files": [
30
31
  "src/**",
@@ -47,5 +48,5 @@
47
48
  "test:unit": "bun test"
48
49
  },
49
50
  "type": "module",
50
- "version": "0.0.0-alpha-ddde2bfbbaad"
51
+ "version": "0.0.0-alpha-e1457b57e6b2"
51
52
  }
@@ -0,0 +1,73 @@
1
+ // A file's content type, by name, from shared-mime-info's `mime/globs2`. See
2
+ // https://specifications.freedesktop.org/shared-mime-info-spec/latest/.
3
+
4
+ import type { Option, Result } from "@cprussin/option-result";
5
+ import { None, Ok, Some } from "@cprussin/option-result";
6
+ import type { System, SystemError } from "@domicile-desktop/sdk/system";
7
+ import { FileType } from "@domicile-desktop/sdk/system";
8
+
9
+ import { globOf } from "./glob";
10
+ import { allOk, orAbsent } from "./results";
11
+
12
+ /** The type shared-mime-info gives every directory. */
13
+ const DIRECTORY = "inode/directory";
14
+
15
+ /** One `globs2` line. */
16
+ type Glob = { weight: number; type: string; pattern: string; glob: RegExp };
17
+
18
+ /**
19
+ * `path`'s content type, or nothing when no glob matches its name.
20
+ *
21
+ * Matches the name only; contents are not sniffed. Reads `mime/globs2` under
22
+ * each of `dataDirs`, missing ones skipped. The heaviest match wins, then the
23
+ * longest pattern, then the earliest directory.
24
+ */
25
+ export const contentTypeOf = async (
26
+ system: System,
27
+ dataDirs: readonly string[],
28
+ path: string,
29
+ ): Promise<Result<Option<string>, SystemError>> =>
30
+ (await system.stat(path)).andThenAsync(async ({ fileType }) =>
31
+ fileType === FileType.Directory
32
+ ? Ok<Option<string>, SystemError>(Some(DIRECTORY))
33
+ : allOk(
34
+ await Promise.all(
35
+ dataDirs.map(async (dir) =>
36
+ orAbsent(await system.readTextFile(`${dir}/mime/globs2`), "").map(
37
+ globsIn,
38
+ ),
39
+ ),
40
+ ),
41
+ ).map((globs) => matching(globs.flat(), nameOf(path))),
42
+ );
43
+
44
+ /** The type of the best glob matching `name`. */
45
+ const matching = (globs: readonly Glob[], name: string): Option<string> => {
46
+ const best = globs
47
+ .filter(({ glob }) => glob.test(name))
48
+ .toSorted(
49
+ (a, b) => b.weight - a.weight || b.pattern.length - a.pattern.length,
50
+ )[0];
51
+ return best === undefined ? None() : Some(best.type);
52
+ };
53
+
54
+ /** The globs `text` lists: `weight:type:glob[:flags]`. */
55
+ const globsIn = (text: string): Glob[] =>
56
+ text
57
+ .split("\n")
58
+ .filter((line) => line !== "" && !line.startsWith("#"))
59
+ .map((line) => {
60
+ const [weight = "", type = "", pattern = "", flags = ""] =
61
+ line.split(":");
62
+ const glob = globOf(pattern);
63
+ return {
64
+ glob: flags.split(",").includes("cs")
65
+ ? glob
66
+ : new RegExp(glob.source, `${glob.flags}i`),
67
+ pattern,
68
+ type,
69
+ weight: Number(weight),
70
+ };
71
+ });
72
+
73
+ const nameOf = (path: string): string => path.slice(path.lastIndexOf("/") + 1);
@@ -19,6 +19,10 @@ export type DesktopEntry = {
19
19
  comment: string;
20
20
  /** The argv it runs. */
21
21
  command: readonly string[];
22
+ /** `Exec` as written, for `commandOf` to open a file with. */
23
+ exec: string;
24
+ /** `MimeType`: the content types it opens. */
25
+ mimeTypes: readonly string[];
22
26
  /** `Name`, `GenericName` and `Keywords`, in lower case, for `findApps`. */
23
27
  words: string;
24
28
  /** `Icon`: a theme name or an absolute path. See `./app-icons`. */
@@ -52,8 +56,12 @@ export const parseDesktopEntry = (
52
56
  : commandOf(exec).map((command) => ({
53
57
  command,
54
58
  comment: unescaped(keys.get("Comment") ?? ""),
59
+ exec,
55
60
  icon: optional(keys.get("Icon")),
56
61
  id,
62
+ mimeTypes: (keys.get("MimeType") ?? "")
63
+ .split(";")
64
+ .filter((type) => type !== ""),
57
65
  name: unescaped(name),
58
66
  preview: optional(keys.get("X-Domicile-Preview")),
59
67
  words: [name, keys.get("GenericName") ?? "", keys.get("Keywords") ?? ""]
package/src/exec.ts CHANGED
@@ -5,15 +5,20 @@ import { None, Some } from "@cprussin/option-result";
5
5
 
6
6
  import { unescaped } from "./escapes";
7
7
 
8
+ /** The field codes for files and URLs. A local file may stand for a URL. */
9
+ const FILE_CODES = new Set(["f", "F", "u", "U"]);
10
+
8
11
  /**
9
- * `exec` as argv, or nothing if it is malformed or empty.
12
+ * `exec` as argv opening `file`, or nothing if it is malformed or empty.
10
13
  *
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
+ * Unescapes strings, then quoting, in the spec's order. Puts `file` in place
15
+ * of each file or URL field code, or after the last argument when there is
16
+ * none. Drops every other field code, along with any argument that was only a
17
+ * field code.
14
18
  */
15
- export const commandOf = (exec: string): Option<string[]> => {
19
+ export const commandOf = (exec: string, file?: string): Option<string[]> => {
16
20
  const words: string[] = [];
21
+ let placed = false;
17
22
  let word = "";
18
23
  let quoted = false;
19
24
  // Whether the previous character was a `\` inside quotes, or a `%`.
@@ -24,7 +29,8 @@ export const commandOf = (exec: string): Option<string[]> => {
24
29
  word += char;
25
30
  escaping = false;
26
31
  } else if (fieldCode) {
27
- word += char === "%" ? "%" : "";
32
+ word += expanded(char, file);
33
+ placed ||= file !== undefined && FILE_CODES.has(char);
28
34
  fieldCode = false;
29
35
  } else if (char === '"') {
30
36
  quoted = !quoted;
@@ -40,5 +46,15 @@ export const commandOf = (exec: string): Option<string[]> => {
40
46
  }
41
47
  }
42
48
  words.push(...(word === "" ? [] : [word]));
49
+ words.push(...(file === undefined || placed ? [] : [file]));
43
50
  return quoted || words.length === 0 ? None() : Some(words);
44
51
  };
52
+
53
+ /** What field code `code` stands for when opening `file`. */
54
+ const expanded = (code: string, file: string | undefined): string => {
55
+ if (file !== undefined && FILE_CODES.has(code)) {
56
+ return file;
57
+ } else {
58
+ return code === "%" ? "%" : "";
59
+ }
60
+ };
package/src/glob.ts ADDED
@@ -0,0 +1,33 @@
1
+ // Shell-style globs, as the XDG specs use them.
2
+
3
+ /**
4
+ * `pattern` as an anchored regular expression: `*` matches any run, `?` one
5
+ * character and `[…]` one of a set. Throws on a pattern that is not a glob.
6
+ */
7
+ export const globOf = (pattern: string): RegExp => {
8
+ const parts = pattern.match(/\[[^\]]*\]|\*|\?|\[|[^*?[]+/g) ?? [];
9
+ return new RegExp(
10
+ `^${parts
11
+ .map((part) => {
12
+ switch (part) {
13
+ case "*": {
14
+ return ".*";
15
+ }
16
+ case "?": {
17
+ return ".";
18
+ }
19
+ case "[": {
20
+ throw new Error(
21
+ `\`${pattern}\` is not a glob: \`[\` is not closed`,
22
+ );
23
+ }
24
+ default: {
25
+ // A `[…]` set reads the same as a regular expression's.
26
+ return part.startsWith("[") ? part : RegExp.escape(part);
27
+ }
28
+ }
29
+ })
30
+ .join("")}$`,
31
+ "s",
32
+ );
33
+ };
package/src/omit.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  // Which desktop file IDs a launcher leaves out, as globs.
2
2
 
3
+ import { globOf } from "./glob";
4
+
3
5
  /**
4
6
  * Whether a desktop file ID is left out, per `patterns`.
5
7
  *
@@ -20,32 +22,3 @@ export const omitting = (
20
22
  return last !== undefined && !last.keeps;
21
23
  };
22
24
  };
23
-
24
- /** `pattern` as an anchored regular expression. */
25
- const globOf = (pattern: string): RegExp => {
26
- const parts = pattern.match(/\[[^\]]*\]|\*|\?|\[|[^*?[]+/g) ?? [];
27
- return new RegExp(
28
- `^${parts
29
- .map((part) => {
30
- switch (part) {
31
- case "*": {
32
- return ".*";
33
- }
34
- case "?": {
35
- return ".";
36
- }
37
- case "[": {
38
- throw new Error(
39
- `\`${pattern}\` is not a glob: \`[\` is not closed`,
40
- );
41
- }
42
- default: {
43
- // A `[…]` set reads the same as a regular expression's.
44
- return part.startsWith("[") ? part : RegExp.escape(part);
45
- }
46
- }
47
- })
48
- .join("")}$`,
49
- "s",
50
- );
51
- };
package/src/openers.ts ADDED
@@ -0,0 +1,99 @@
1
+ // The applications that open a file, its default first, and the argv each
2
+ // opens it with.
3
+
4
+ import type { Option, Result } from "@cprussin/option-result";
5
+ import { Ok } from "@cprussin/option-result";
6
+ import type { System, SystemError } from "@domicile-desktop/sdk/system";
7
+
8
+ import { contentTypeOf } from "./content-type";
9
+ import { dataDirs } from "./data-dirs";
10
+ import type { DesktopEntry } from "./desktop-entry";
11
+ import { desktopEnvironment, setVariable } from "./environment";
12
+ import { commandOf } from "./exec";
13
+ import { installedApps } from "./installed";
14
+ import { defaultApps } from "./mime-apps";
15
+
16
+ /** What can open a file. */
17
+ export type Openers = {
18
+ /** The file's content type, by name. Nothing when no glob matches. */
19
+ contentType: Option<string>;
20
+ /**
21
+ * The installed applications for the type: its defaults from
22
+ * `mimeapps.list` in their order (see `./mime-apps`), then the rest whose
23
+ * `MimeType` lists it, by name. The first is the one to open it with.
24
+ */
25
+ apps: DesktopEntry[];
26
+ /** The argv that opens the file with `app`. */
27
+ command: (app: DesktopEntry) => string[];
28
+ };
29
+
30
+ /**
31
+ * What can open `path`: relative to home, as every system call's path is, or
32
+ * absolute.
33
+ *
34
+ * Only `MimeType` and the defaults are read: aliases, subclasses and
35
+ * `mimeapps.list`'s added and removed associations are not. A launcher falls
36
+ * back to `xdg-open` when this offers nothing.
37
+ */
38
+ export const openers = async (
39
+ system: System,
40
+ path: string,
41
+ ): Promise<Result<Openers, SystemError>> =>
42
+ (await desktopEnvironment(system)).andThenAsync(async (variables) => {
43
+ const file = path.startsWith("/") ? path : `${homeOf(variables)}/${path}`;
44
+ return (await dataDirs(system)).andThenAsync(async (dirs) =>
45
+ (await contentTypeOf(system, dirs, path)).andThenAsync(
46
+ async (contentType) =>
47
+ (
48
+ await contentType.match({
49
+ None: () => Promise.resolve(Ok<DesktopEntry[], SystemError>([])),
50
+ Some: (type) => appsFor(system, dirs, type),
51
+ })
52
+ ).map((apps) => ({
53
+ apps,
54
+ command: (app: DesktopEntry) => commandFor(app, file),
55
+ contentType,
56
+ })),
57
+ ),
58
+ );
59
+ });
60
+
61
+ /** The installed applications for `contentType`, in `Openers.apps`'s order. */
62
+ const appsFor = async (
63
+ system: System,
64
+ dirs: readonly string[],
65
+ contentType: string,
66
+ ): Promise<Result<DesktopEntry[], SystemError>> =>
67
+ (await installedApps(system, dirs)).andThenAsync(async (installed) =>
68
+ (await defaultApps(system, contentType)).map((defaults) => {
69
+ const byId = new Map(installed.map((app) => [app.id, app]));
70
+ const first = [...new Set(defaults)].flatMap((id) => {
71
+ const app = byId.get(id);
72
+ return app === undefined ? [] : [app];
73
+ });
74
+ const rest = installed
75
+ .filter(
76
+ (app) => app.mimeTypes.includes(contentType) && !first.includes(app),
77
+ )
78
+ .toSorted((a, b) => a.name.localeCompare(b.name));
79
+ return [...first, ...rest];
80
+ }),
81
+ );
82
+
83
+ const homeOf = (variables: ReadonlyMap<string, string>): string => {
84
+ const home = setVariable(variables, "HOME");
85
+ if (home === undefined) {
86
+ throw new Error("the desktop has no HOME to open a file under");
87
+ } else {
88
+ return home;
89
+ }
90
+ };
91
+
92
+ /** `app`'s argv for `file`. It was read once already, so it reads again. */
93
+ const commandFor = (app: DesktopEntry, file: string): string[] =>
94
+ commandOf(app.exec, file).match({
95
+ None: () => {
96
+ throw new Error(`${app.id}'s Exec could not be read again`);
97
+ },
98
+ Some: (command) => command,
99
+ });