strom-research 1.4.1 → 1.6.0

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.
Files changed (49) hide show
  1. package/assets/lang/cs.json +160 -1
  2. package/assets/lang/de.json +160 -1
  3. package/assets/method/narrate.md +2 -1
  4. package/assets/method/recording.md +3 -1
  5. package/dist/agents/files.js +8 -1
  6. package/dist/cli/human.js +18 -0
  7. package/dist/cli/main.js +9 -0
  8. package/dist/cli/menu-links.js +249 -0
  9. package/dist/cli/menu-parts.js +6 -3
  10. package/dist/cli/menu-research.js +185 -7
  11. package/dist/cli/menu-settings.js +17 -3
  12. package/dist/cli/menu-waiting.js +61 -47
  13. package/dist/cli/menu.js +3 -2
  14. package/dist/cli/ui.js +159 -1
  15. package/dist/cli/wizard.js +34 -1
  16. package/dist/commands/analysis.js +6 -2
  17. package/dist/commands/browse.js +5 -19
  18. package/dist/commands/checks.js +7 -1
  19. package/dist/commands/guide.js +15 -1
  20. package/dist/commands/index.js +2 -0
  21. package/dist/commands/intake.js +3 -0
  22. package/dist/commands/links.js +367 -0
  23. package/dist/commands/output.js +7 -1
  24. package/dist/commands/people.js +58 -1
  25. package/dist/commands/research.js +113 -24
  26. package/dist/commands/setup.js +13 -2
  27. package/dist/commands/start.js +6 -2
  28. package/dist/commands/story.js +17 -1
  29. package/dist/commands/sync.js +243 -0
  30. package/dist/core/actions.js +15 -0
  31. package/dist/core/config.js +5 -0
  32. package/dist/core/dialog.js +25 -0
  33. package/dist/core/excerpt.js +53 -5
  34. package/dist/core/frontier.js +10 -1
  35. package/dist/core/links.js +357 -0
  36. package/dist/core/live.js +156 -7
  37. package/dist/core/phrases.js +1 -0
  38. package/dist/core/review.js +43 -5
  39. package/dist/core/schema.js +1 -0
  40. package/dist/core/self.js +17 -0
  41. package/dist/core/session.js +6 -0
  42. package/dist/core/stromapp.js +38 -0
  43. package/dist/core/sync.js +876 -0
  44. package/dist/core/tree.js +1 -1
  45. package/dist/core/uninstall.js +3 -0
  46. package/dist/core/validate.js +4 -0
  47. package/dist/gedcom/export.js +66 -1
  48. package/dist/gedcom/validate.js +13 -4
  49. package/package.json +1 -1
@@ -7,7 +7,7 @@
7
7
  import crypto from "node:crypto";
8
8
  import fs from "node:fs";
9
9
  import path from "node:path";
10
- import { inputPath, sharperPart } from "./media.js";
10
+ import { clipText, inputPath, sharperPart } from "./media.js";
11
11
  import { kinship } from "./kin.js";
12
12
  import { Settings } from "./config.js";
13
13
  import { crop, resize, toGrey } from "../image/image.js";
@@ -21,6 +21,11 @@ export const LEVELS = [
21
21
  ];
22
22
  /** A margin round the clip, of its longer side: the edge of the entry stays in. */
23
23
  const MARGIN = 0.03;
24
+ /** A clip's mark: from where it is (its image and region) — a clip made again is another excerpt; "input": a document the user gave. */
25
+ export function clipMark(c) {
26
+ return `c${crypto.createHash("sha256").update(clipText(c)).digest("hex").slice(0, 10)}`;
27
+ }
28
+ export const INPUT_MARK = "input";
24
29
  /** A clip with the margin an excerpt keeps round it. */
25
30
  export function withMargin(r) {
26
31
  const pad = MARGIN * Math.max(r.w, r.h);
@@ -60,7 +65,7 @@ export function renderClip(tree, shared, clip, all = tree.list("media"), level =
60
65
  const jpeg = fs.readFileSync(cache);
61
66
  const size = imageSize(jpeg);
62
67
  if (size)
63
- return { media: m.id, from: src.id, jpeg, ...size, url };
68
+ return { media: m.id, from: src.id, jpeg, ...size, url, clip: clipMark(clip) };
64
69
  }
65
70
  if (cachedOnly)
66
71
  return LATER;
@@ -69,7 +74,7 @@ export function renderClip(tree, shared, clip, all = tree.list("media"), level =
69
74
  const out = finish(crop(img, px.x, px.y, Math.max(1, Math.min(px.w, img.width - px.x)), Math.max(1, Math.min(px.h, img.height - px.y))), level);
70
75
  fs.mkdirSync(path.dirname(cache), { recursive: true });
71
76
  fs.writeFileSync(cache, out.jpeg);
72
- return { media: m.id, from: src.id, ...out, url };
77
+ return { media: m.id, from: src.id, ...out, url, clip: clipMark(clip) };
73
78
  }
74
79
  /** The image of a document the user gave (an input), when it is one: JPEG or PNG on this computer. */
75
80
  function inputImage(tree, id) {
@@ -90,14 +95,14 @@ export function renderInput(tree, id, level = LEVELS[1], cachedOnly = false) {
90
95
  const jpeg = fs.readFileSync(cache);
91
96
  const size = imageSize(jpeg);
92
97
  if (size)
93
- return { media: found.input.id, from: found.input.id, jpeg, ...size };
98
+ return { media: found.input.id, from: found.input.id, jpeg, ...size, clip: INPUT_MARK };
94
99
  }
95
100
  if (cachedOnly)
96
101
  return LATER;
97
102
  const out = finish(decodeImage(fs.readFileSync(found.file)), level);
98
103
  fs.mkdirSync(path.dirname(cache), { recursive: true });
99
104
  fs.writeFileSync(cache, out.jpeg);
100
- return { media: found.input.id, from: found.input.id, ...out };
105
+ return { media: found.input.id, from: found.input.id, ...out, clip: INPUT_MARK };
101
106
  }
102
107
  /** The same excerpt at a lower level, made from the excerpt itself (quick: no scan is opened again). */
103
108
  function lower(e, level) {
@@ -217,3 +222,46 @@ export function planExcerpts(tree, shared, opts) {
217
222
  export function dataUrl(e) {
218
223
  return `data:image/jpeg;base64,${Buffer.from(e.jpeg).toString("base64")}`;
219
224
  }
225
+ /** The margin round an excerpt opened in full: the lines round the entry stay in view. */
226
+ const FULL_MARGIN = 0.1;
227
+ /**
228
+ * One excerpt of a source (by its mark) as sharp as the research has it — the region with more round it, from the
229
+ * sharpest image of that place, not made smaller (a strom-research://excerpt link from the Strom app, which keeps
230
+ * them small). A document the user gave: the file itself. Made once, in .strom/excerpts.
231
+ */
232
+ export function fullExcerpt(tree, shared, s, mark) {
233
+ if (mark === INPUT_MARK) {
234
+ const found = !s.clips?.length ? inputImage(tree, s.input) : undefined;
235
+ return found ? { file: found.file } : { missing: "clip" };
236
+ }
237
+ const clip = s.clips?.find((c) => clipMark(c) === mark);
238
+ if (!clip)
239
+ return { missing: "clip" };
240
+ const all = tree.list("media");
241
+ const m = all.find((x) => x.id === clip.media);
242
+ if (!m)
243
+ return { missing: "clip" };
244
+ const pad = FULL_MARGIN * Math.max(clip.region.w, clip.region.h);
245
+ const f = (n) => Math.min(1, Math.max(0, n));
246
+ const x = f(clip.region.x - pad);
247
+ const y = f(clip.region.y - pad);
248
+ const wanted = { x, y, w: f(clip.region.x + clip.region.w + pad) - x, h: f(clip.region.y + clip.region.h + pad) - y };
249
+ const sharper = sharperPart(all, m, wanted);
250
+ const src = sharper?.part ?? m;
251
+ const region = sharper?.crop ?? wanted;
252
+ const file = path.join(shared, src.file);
253
+ if (!fs.existsSync(file)) {
254
+ const url = m.url ?? src.url;
255
+ return url ? { url } : { missing: "scan" };
256
+ }
257
+ const key = crypto.createHash("sha256").update(JSON.stringify({ sha: src.sha, region, full: 1 })).digest("hex").slice(0, 16);
258
+ const cache = path.join(tree.root, ".strom", "excerpts", `full-${s.id}-${src.id}-${key}.jpg`);
259
+ if (fs.existsSync(cache))
260
+ return { file: cache };
261
+ const img = decodeImage(fs.readFileSync(file));
262
+ const px = { x: Math.round(region.x * img.width), y: Math.round(region.y * img.height), w: Math.round(region.w * img.width), h: Math.round(region.h * img.height) };
263
+ const cut = crop(img, px.x, px.y, Math.max(1, Math.min(px.w, img.width - px.x)), Math.max(1, Math.min(px.h, img.height - px.y)));
264
+ fs.mkdirSync(path.dirname(cache), { recursive: true });
265
+ fs.writeFileSync(cache, encodeImage(cut, "jpeg", 92));
266
+ return { file: cache };
267
+ }
@@ -102,7 +102,7 @@ export function taskRecordsets(tree, task) {
102
102
  * 25 years before their marriage, else about 28 before their eldest known child —
103
103
  * where that happened. An estimate widens the years a search has to cover.
104
104
  */
105
- function birthEstimate(tree, p, lang) {
105
+ export function birthEstimate(tree, p, lang) {
106
106
  const own = birthEvent(p);
107
107
  const ownYear = own?.date ? dateYears(own.date)[0] : undefined;
108
108
  if (ownYear && own?.place)
@@ -132,6 +132,15 @@ export function researchPeople(tree, research) {
132
132
  return ancestorGenerations(tree, research.focus, research.limits?.generations ?? 50);
133
133
  if (research.direction !== "person")
134
134
  return new Map();
135
+ // a review of several people; of the people without a record of their own: each until one proves them
136
+ if (research.review?.people) {
137
+ const unproven = !!research.review.unproven;
138
+ const proven = (p) => p.events.some((e) => !e.retracted && (e.status === "proven" || e.status === "probable"));
139
+ return new Map(research.review.people
140
+ .map((id) => tree.get(id))
141
+ .filter((p) => p?.type === "person" && !p.retracted && !(unproven && proven(p)))
142
+ .map((p) => [p.id, 1]));
143
+ }
135
144
  const scope = research.review?.scope ?? "person";
136
145
  if (scope === "line")
137
146
  return ancestorGenerations(tree, research.focus, research.limits?.generations ?? 50);
@@ -0,0 +1,357 @@
1
+ // Links from the Strom app: strom-research://send?tree=… and
2
+ // strom-research://excerpt?tree=…&source=…&clip=… (the app's spec
3
+ // docs/ZADANI_VYZKUM_ODKAZY_Z_APLIKACE.md in the Strom repository). A web page
4
+ // may not start a program on the computer — only through a scheme of links the
5
+ // program registered with the system, and the browser asks first. strom
6
+ // registers it for this user alone, no admin rights, on the user's yes (the
7
+ // wizard, strom doctor --fix, strom link on):
8
+ //
9
+ // macOS a small AppleScript applet ~/Applications/Strom Research.app —
10
+ // the system hands a link over as an Apple Event, never as an argument
11
+ // Windows HKCU\Software\Classes\strom-research, straight to strom's Node
12
+ // (never through cmd, which would read the link again: %, &, ")
13
+ // Linux a .desktop entry, the default for x-scheme-handler/strom-research
14
+ //
15
+ // Any page can open such a link, so what it carries is never trusted: strom
16
+ // checks each part's shape, writes nothing without the user's word in the
17
+ // terminal, and an unknown action, research or excerpt is only said.
18
+ // The app learns that links work from strom itself (the bridge's /status,
19
+ // the GEDCOM it serves): only while the scheme here leads to this strom.
20
+ import fs from "node:fs";
21
+ import path from "node:path";
22
+ import { spawnSync } from "node:child_process";
23
+ import { userHome } from "./paths.js";
24
+ import { stromLauncher } from "./self.js";
25
+ import { ICON } from "./shortcut.js";
26
+ export const LINK_SCHEME = "strom-research";
27
+ /**
28
+ * What the links strom understands do: send the edited tree back, open an excerpt in full, send the research
29
+ * as it is now into the app's window, open the research's menu, a conversation with the agent (of one person),
30
+ * a task (answered, put aside, given up, back in the queue), a person's review, a new direction from a person —
31
+ * and (the app's second wave) a new research with a tree of the app, strom's update, the agent's sessions and
32
+ * what they cost, a conflict decided (by the user or the agent), a story approved, a sync taken back, the setup.
33
+ */
34
+ export const LINK_ACTIONS = ["send", "excerpt", "app", "open", "chat", "task", "review", "research", "new", "update", "sessions", "conflict", "story", "sync-undo", "setup"];
35
+ /** A person's review: the person, with the family, with the ancestors (strom review --scope). */
36
+ export const LINK_SCOPES = ["person", "family", "line"];
37
+ /** A new direction from a person (strom research new --direction). */
38
+ export const LINK_DIRECTIONS = ["ancestors", "descendants"];
39
+ /** What a link does with a task: put it aside, give it up, back into the queue (none: answer it). */
40
+ export const LINK_TASK_DOS = ["park", "drop", "wake"];
41
+ /** What a link does with a conflict: the user decides it, or leaves it to the agent. */
42
+ export const LINK_CONFLICT_DOS = ["decide", "agent"];
43
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
44
+ const SOURCE = /^S\d{1,9}$/;
45
+ const PERSON = /^P\d{1,9}$/;
46
+ const TASK = /^T\d{1,9}$/;
47
+ const CONFLICT = /^X\d{1,9}$/;
48
+ const INPUT = /^I\d{1,9}$/;
49
+ /** The app's own mark of a tree it hands over (32 random bytes, base64url). */
50
+ export const APP_TOKEN = /^[A-Za-z0-9_-]{22,43}$/;
51
+ /** An excerpt's mark (_STROM_CLIP): letters, digits and "-", at most 32. */
52
+ export const CLIP_MARK = /^[A-Za-z0-9-]{1,32}$/;
53
+ /** Why a link was not taken: said to the user, never acted on. */
54
+ export class LinkError extends Error {
55
+ }
56
+ /** One of fixed values, the default when none is given; anything else is a LinkError. */
57
+ function oneOf(q, name, values, fallback) {
58
+ const v = q.get(name) ?? fallback;
59
+ if (v === undefined || !values.includes(v))
60
+ throw new LinkError(`unknown ${name} "${(v ?? "").slice(0, 32)}"`);
61
+ return v;
62
+ }
63
+ /** An ID of a kind (P…, T…): upper-cased, checked; none when not given and not needed. */
64
+ function idOf(q, name, shape, needed) {
65
+ if (!q.has(name) && !needed)
66
+ return undefined;
67
+ const v = (q.get(name) ?? "").toUpperCase();
68
+ if (!shape.test(v))
69
+ throw new LinkError(`no ${name} named`);
70
+ return v;
71
+ }
72
+ /** A link, checked part by part; anything else is a LinkError. No link at all (the applet opened by itself): the menu. */
73
+ export function parseLink(text) {
74
+ if (!text?.trim())
75
+ return { action: "menu" };
76
+ const raw = text.trim();
77
+ if (raw.length > 2048)
78
+ throw new LinkError("too long");
79
+ let url;
80
+ try {
81
+ url = new URL(raw);
82
+ }
83
+ catch {
84
+ throw new LinkError("not a link");
85
+ }
86
+ if (url.protocol !== `${LINK_SCHEME}:`)
87
+ throw new LinkError(`not a ${LINK_SCHEME} link`);
88
+ // strom-research://send?… — and the forms systems make of it: …//send/?…, strom-research:send?…
89
+ const action = (url.host || url.pathname).replace(/^\/+|\/+$/g, "").toLowerCase();
90
+ const q = url.searchParams;
91
+ // the one link without a research: a tree of the app becomes one
92
+ if (action === "new") {
93
+ const app = q.get("app") ?? "";
94
+ if (!APP_TOKEN.test(app))
95
+ throw new LinkError("no tree of the app named");
96
+ return { action, app };
97
+ }
98
+ const tree = q.get("tree") ?? "";
99
+ if (!UUID.test(tree))
100
+ throw new LinkError("no research named");
101
+ const id = tree.toLowerCase();
102
+ const person = idOf(q, "person", PERSON, false);
103
+ switch (action) {
104
+ case "send":
105
+ case "app":
106
+ case "open":
107
+ case "update":
108
+ case "sessions":
109
+ case "setup":
110
+ return { action, tree: id };
111
+ case "excerpt": {
112
+ const source = (q.get("source") ?? "").toUpperCase();
113
+ const clip = q.get("clip") ?? "";
114
+ if (!SOURCE.test(source) || !CLIP_MARK.test(clip))
115
+ throw new LinkError("no excerpt named");
116
+ return { action, tree: id, source, clip };
117
+ }
118
+ case "chat":
119
+ return { action, tree: id, ...(person ? { person } : {}) };
120
+ case "task": {
121
+ const task = idOf(q, "task", TASK, true);
122
+ return { action, tree: id, task, ...(q.has("do") ? { do: oneOf(q, "do", LINK_TASK_DOS) } : {}) };
123
+ }
124
+ case "review":
125
+ case "research":
126
+ case "story": {
127
+ if (!person)
128
+ throw new LinkError("no person named");
129
+ if (action === "review")
130
+ return { action, tree: id, person, scope: oneOf(q, "scope", LINK_SCOPES, "person") };
131
+ if (action === "research")
132
+ return { action, tree: id, person, direction: oneOf(q, "direction", LINK_DIRECTIONS, "ancestors") };
133
+ return { action, tree: id, person, do: oneOf(q, "do", ["final"]) };
134
+ }
135
+ case "conflict":
136
+ return { action, tree: id, id: idOf(q, "id", CONFLICT, true), do: oneOf(q, "do", LINK_CONFLICT_DOS, "decide") };
137
+ case "sync-undo":
138
+ return { action, tree: id, intake: idOf(q, "intake", INPUT, true) };
139
+ }
140
+ throw new LinkError(`unknown action "${action.slice(0, 32)}"`);
141
+ }
142
+ /** A link written again from what was checked in it: only its known parts, in one form (handed to a new terminal). */
143
+ export function linkText(link) {
144
+ const { action, ...parts } = link;
145
+ const q = new URLSearchParams(Object.entries(parts).filter((e) => typeof e[1] === "string"));
146
+ return `${LINK_SCHEME}://${action}?${q}`;
147
+ }
148
+ /**
149
+ * What the handler runs: this strom's Node and script, "link open", the link. A strom of its own settings folder
150
+ * (STROM_CONFIG_DIR) gives its links that folder too (macOS, Linux: through env) — the same researches answer them.
151
+ */
152
+ function handlerArgv(env = {}, platform = process.platform) {
153
+ const { command, args } = stromLauncher();
154
+ const own = env.STROM_CONFIG_DIR && platform !== "win32" ? ["/usr/bin/env", `STROM_CONFIG_DIR=${env.STROM_CONFIG_DIR}`] : [];
155
+ return [...own, command, ...args, "link", "open"];
156
+ }
157
+ /** macOS: the applet strom makes. */
158
+ export function macApp(env) {
159
+ return path.join(userHome(env), "Applications", "Strom Research.app");
160
+ }
161
+ /** Linux: the entry that takes the links (the shortcut's own is strom-research.desktop). */
162
+ export const LINUX_ENTRY = "strom-research-link.desktop";
163
+ function linuxEntry(env) {
164
+ return path.join(env.XDG_DATA_HOME ?? path.join(userHome(env), ".local", "share"), "applications", LINUX_ENTRY);
165
+ }
166
+ const WIN_KEY = `HKCU\\Software\\Classes\\${LINK_SCHEME}`;
167
+ /** AppleScript string literal. */
168
+ function appleString(s) {
169
+ return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
170
+ }
171
+ /** A POSIX shell word. */
172
+ function shellWord(s) {
173
+ return `'${s.replace(/'/g, `'\\''`)}'`;
174
+ }
175
+ /**
176
+ * The applet's script: a link comes as an Apple Event (open location); opened by itself, the menu. strom runs in
177
+ * the background, so the applet ends at once; what strom has to say it says in a window or a terminal of its own.
178
+ */
179
+ export function macScript(argv) {
180
+ const run = argv.map(shellWord).join(" ");
181
+ return [
182
+ "on open location theURL",
183
+ `\tdo shell script ${appleString(`${run} `)} & quoted form of theURL & ${appleString(" >/dev/null 2>&1 &")}`,
184
+ "end open location",
185
+ "on run",
186
+ `\tdo shell script ${appleString(`${run} >/dev/null 2>&1 &`)}`,
187
+ "end run",
188
+ "",
189
+ ].join("\n");
190
+ }
191
+ /** Windows: the command in the registry — strom's Node itself, the link one argument. */
192
+ export function windowsCommand(argv = handlerArgv({}, "win32")) {
193
+ return [...argv, "%1"].map((a) => `"${a}"`).join(" ");
194
+ }
195
+ /** A word of a .desktop Exec line, quoted as the spec says; "%" doubled. */
196
+ function desktopWord(s) {
197
+ const quoted = /[\s"'\\$`<>~|&;*?#()]/.test(s) ? `"${s.replace(/([\\"`$])/g, "\\$1")}"` : s;
198
+ // …and a .desktop value escapes its backslashes once more
199
+ return quoted.replace(/\\/g, "\\\\").replace(/%/g, "%%");
200
+ }
201
+ export function linuxDesktopEntry(argv) {
202
+ return [
203
+ "[Desktop Entry]",
204
+ "Type=Application",
205
+ "Name=Strom Research",
206
+ "NoDisplay=true",
207
+ `Exec=${argv.map(desktopWord).join(" ")} %u`,
208
+ `Icon=${ICON.png}`,
209
+ "Terminal=false",
210
+ `MimeType=x-scheme-handler/${LINK_SCHEME};`,
211
+ "",
212
+ ].join("\n");
213
+ }
214
+ const sys = (cmd, args) => {
215
+ const r = spawnSync(cmd, args, { encoding: "utf8", timeout: 30_000, windowsHide: true });
216
+ return { status: r.error ? null : r.status, stdout: r.stdout ?? "" };
217
+ };
218
+ const LSREGISTER = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister";
219
+ /** The app the system opens a strom-research link with (macOS), or "". */
220
+ function macHandlerPath(run) {
221
+ const js = `ObjC.import("AppKit");var u=$.NSWorkspace.sharedWorkspace.URLForApplicationToOpenURL($.NSURL.URLWithString("${LINK_SCHEME}://status"));u.isNil()?"":u.path.js`;
222
+ const r = run("osascript", ["-l", "JavaScript", "-e", js]);
223
+ return r.status === 0 ? r.stdout.trim() : "";
224
+ }
225
+ /** The mark inside strom's applet: what it runs (another strom's applet, or one of an older place, is not ours). */
226
+ function macMark(app) {
227
+ return path.join(app, "Contents", "Resources", "strom-link.json");
228
+ }
229
+ /** Does the scheme lead to this strom? No system is asked from a test (STROM_NO_INSTALL). */
230
+ export function linkHandlerState(env, platform = process.platform, run = sys) {
231
+ if (env.STROM_NO_INSTALL === "1" && run === sys)
232
+ return "none";
233
+ const argv = handlerArgv(env, platform);
234
+ if (platform === "darwin") {
235
+ const at = macHandlerPath(run);
236
+ if (!at)
237
+ return "none";
238
+ const app = macApp(env);
239
+ if (path.resolve(at) !== path.resolve(app))
240
+ return "other";
241
+ try {
242
+ return JSON.stringify(JSON.parse(fs.readFileSync(macMark(app), "utf8")).argv) === JSON.stringify(argv) ? "ours" : "other";
243
+ }
244
+ catch {
245
+ return "other";
246
+ }
247
+ }
248
+ if (platform === "win32") {
249
+ const r = run("reg.exe", ["query", `${WIN_KEY}\\shell\\open\\command`, "/ve"]);
250
+ if (r.status !== 0)
251
+ return "none";
252
+ const value = /REG_(?:EXPAND_)?SZ\s+(.*)$/m.exec(r.stdout)?.[1]?.trim();
253
+ return value === windowsCommand(argv) ? "ours" : value ? "other" : "none";
254
+ }
255
+ const r = run("xdg-mime", ["query", "default", `x-scheme-handler/${LINK_SCHEME}`]);
256
+ const entry = r.status === 0 ? r.stdout.trim() : "";
257
+ if (!entry)
258
+ return "none";
259
+ if (entry !== LINUX_ENTRY)
260
+ return "other";
261
+ try {
262
+ return fs.readFileSync(linuxEntry(env), "utf8") === linuxDesktopEntry(argv) ? "ours" : "other";
263
+ }
264
+ catch {
265
+ return "other";
266
+ }
267
+ }
268
+ /** The actions a link can do here: all of them while the scheme leads to this strom, else none. */
269
+ export function linkActions(state) {
270
+ return state === "ours" ? [...LINK_ACTIONS] : [];
271
+ }
272
+ /** Register the scheme for this user (or register it again: this strom moved, was updated); true when it leads to this strom now. */
273
+ export function registerLinks(env, platform = process.platform, run = sys) {
274
+ if (env.STROM_NO_INSTALL === "1" && run === sys)
275
+ return false;
276
+ const argv = handlerArgv(env, platform);
277
+ if (platform === "darwin") {
278
+ const app = macApp(env);
279
+ fs.mkdirSync(path.dirname(app), { recursive: true });
280
+ fs.rmSync(app, { recursive: true, force: true });
281
+ const script = path.join(path.dirname(app), ".strom-link.applescript");
282
+ fs.writeFileSync(script, macScript(argv));
283
+ const made = run("osacompile", ["-o", app, script]);
284
+ fs.rmSync(script, { force: true });
285
+ if (made.status !== 0)
286
+ return false;
287
+ const plist = path.join(app, "Contents", "Info.plist");
288
+ run("plutil", ["-replace", "CFBundleIdentifier", "-string", "info.stromapp.research.link", plist]);
289
+ run("plutil", ["-replace", "CFBundleName", "-string", "Strom Research", plist]);
290
+ run("plutil", ["-replace", "LSUIElement", "-bool", "true", plist]);
291
+ run("plutil", ["-replace", "CFBundleURLTypes", "-json", JSON.stringify([{ CFBundleURLName: "Strom Research", CFBundleURLSchemes: [LINK_SCHEME] }]), plist]);
292
+ // its icon: Strom Research's own
293
+ if (fs.existsSync(ICON.png))
294
+ run("sips", ["-s", "format", "icns", ICON.png, "--out", path.join(app, "Contents", "Resources", "applet.icns")]);
295
+ fs.writeFileSync(macMark(app), JSON.stringify({ argv }, null, 2));
296
+ // the applet's seal covers its Info.plist: sealed again, by itself (no one's certificate)
297
+ run("codesign", ["--force", "--sign", "-", app]);
298
+ run(LSREGISTER, ["-f", app]);
299
+ return linkHandlerState(env, platform, run) === "ours";
300
+ }
301
+ if (platform === "win32") {
302
+ const add = (key, args) => run("reg.exe", ["add", key, ...args, "/f"]).status === 0;
303
+ const ok = add(WIN_KEY, ["/ve", "/d", "URL:Strom Research"]) &&
304
+ add(WIN_KEY, ["/v", "URL Protocol", "/d", ""]) &&
305
+ add(`${WIN_KEY}\\DefaultIcon`, ["/ve", "/d", ICON.ico]) &&
306
+ add(`${WIN_KEY}\\shell\\open\\command`, ["/ve", "/d", windowsCommand(argv)]);
307
+ return ok && linkHandlerState(env, platform, run) === "ours";
308
+ }
309
+ const file = linuxEntry(env);
310
+ fs.mkdirSync(path.dirname(file), { recursive: true });
311
+ fs.writeFileSync(file, linuxDesktopEntry(argv));
312
+ run("xdg-mime", ["default", LINUX_ENTRY, `x-scheme-handler/${LINK_SCHEME}`]);
313
+ run("update-desktop-database", [path.dirname(file)]);
314
+ return linkHandlerState(env, platform, run) === "ours";
315
+ }
316
+ /** What strom put there for the links (for strom uninstall): the applet, the registry key or the entry. */
317
+ export function linkFiles(env, platform = process.platform, run = sys) {
318
+ if (platform === "darwin")
319
+ return fs.existsSync(macApp(env)) && fs.existsSync(macMark(macApp(env))) ? [macApp(env)] : [];
320
+ if (platform === "win32") {
321
+ if (env.STROM_NO_INSTALL === "1" && run === sys)
322
+ return [];
323
+ const r = run("reg.exe", ["query", `${WIN_KEY}\\shell\\open\\command`, "/ve"]);
324
+ return r.status === 0 && /link"? "?open/.test(r.stdout) ? [WIN_KEY] : [];
325
+ }
326
+ return fs.existsSync(linuxEntry(env)) ? [linuxEntry(env)] : [];
327
+ }
328
+ /** Take the scheme off again (strom link off, strom uninstall); true when nothing of strom's is left for it. */
329
+ export function unregisterLinks(env, platform = process.platform, run = sys) {
330
+ if (platform === "darwin") {
331
+ const app = macApp(env);
332
+ if (fs.existsSync(macMark(app))) {
333
+ run(LSREGISTER, ["-u", app]);
334
+ fs.rmSync(app, { recursive: true, force: true });
335
+ }
336
+ return !fs.existsSync(app) || !fs.existsSync(macMark(app));
337
+ }
338
+ if (platform === "win32") {
339
+ if (!linkFiles(env, platform, run).length)
340
+ return true;
341
+ return run("reg.exe", ["delete", WIN_KEY, "/f"]).status === 0;
342
+ }
343
+ const file = linuxEntry(env);
344
+ fs.rmSync(file, { force: true });
345
+ // the default the entry was given goes with it
346
+ const list = path.join(env.XDG_CONFIG_HOME ?? path.join(userHome(env), ".config"), "mimeapps.list");
347
+ try {
348
+ const text = fs.readFileSync(list, "utf8");
349
+ const kept = text.split("\n").filter((l) => !(l.startsWith(`x-scheme-handler/${LINK_SCHEME}=`) && l.includes(LINUX_ENTRY)));
350
+ if (kept.length !== text.split("\n").length)
351
+ fs.writeFileSync(list, kept.join("\n"));
352
+ }
353
+ catch {
354
+ // no list
355
+ }
356
+ return true;
357
+ }