@threadcrumbs/threadnauts 0.0.0-stage → 1.1.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 (111) hide show
  1. package/LICENSE.md +34 -0
  2. package/NOTICES.md +683 -0
  3. package/README.md +162 -2
  4. package/bin/threadnauts.mjs +114 -0
  5. package/dist/assets/CREDITS.md +19 -0
  6. package/dist/assets/aldargon-B_VubjBf.svg +1 -0
  7. package/dist/assets/arrakesh-BW9Bb56U.svg +1 -0
  8. package/dist/assets/atoll-mfMgWjQE.svg +1 -0
  9. package/dist/assets/autumn-Cvk8t_-3.svg +1 -0
  10. package/dist/assets/bamboo-KEAHdFas.svg +1 -0
  11. package/dist/assets/basalt-g767Blh3.svg +1 -0
  12. package/dist/assets/baut.glb +0 -0
  13. package/dist/assets/beach-q2rexblq.svg +1 -0
  14. package/dist/assets/callisto-bug4x0L8.svg +1 -0
  15. package/dist/assets/cancri-BOebEtMP.svg +1 -0
  16. package/dist/assets/canyon-FF3hqLaT.svg +1 -0
  17. package/dist/assets/ceres-BeB8pWjM.svg +1 -0
  18. package/dist/assets/challenger-Bg9wWAtc.svg +1 -0
  19. package/dist/assets/crystal-bk_pmXWn.svg +1 -0
  20. package/dist/assets/dagmora-Co5gg-3x.svg +1 -0
  21. package/dist/assets/deadstar-x-dSincY.svg +1 -0
  22. package/dist/assets/desert-Bkozg-2y.svg +1 -0
  23. package/dist/assets/dragonnest-DExsIn2w.svg +1 -0
  24. package/dist/assets/draugr-ChEwvDI7.svg +1 -0
  25. package/dist/assets/enceladus-BlSNfFYi.svg +1 -0
  26. package/dist/assets/endral-DQ-u4-N6.svg +1 -0
  27. package/dist/assets/europa-Cgids_PV.svg +1 -0
  28. package/dist/assets/fjord-rGF4e-MG.svg +1 -0
  29. package/dist/assets/forest-BVWgbcSU.svg +1 -0
  30. package/dist/assets/forest.glb +0 -0
  31. package/dist/assets/frozenlake-B8282a2Z.svg +1 -0
  32. package/dist/assets/ganymede-Dg4iJbs2.svg +1 -0
  33. package/dist/assets/garden-TQmDzzU5.svg +1 -0
  34. package/dist/assets/glimmer-BIK9hzb3.svg +1 -0
  35. package/dist/assets/halley-B32BiHtn.svg +1 -0
  36. package/dist/assets/hd189733b-E4qM1zIs.svg +1 -0
  37. package/dist/assets/hollow-DKKw0GKy.svg +1 -0
  38. package/dist/assets/horth-DPuhph1g.svg +1 -0
  39. package/dist/assets/iapetus-CMxb57ov.svg +1 -0
  40. package/dist/assets/index-BSPNlXcc.js +2514 -0
  41. package/dist/assets/index-DoVfuOrP.css +1 -0
  42. package/dist/assets/io-C7TKKMjC.svg +1 -0
  43. package/dist/assets/j1407b-Dx2Qblb7.svg +1 -0
  44. package/dist/assets/jakhara-BLl7oBmq.svg +1 -0
  45. package/dist/assets/jupiter-8ezAafsg.svg +1 -0
  46. package/dist/assets/kaladorn-CkaAJJ7a.svg +1 -0
  47. package/dist/assets/kashiwa-_WrMlYhf.svg +1 -0
  48. package/dist/assets/kepler16b-CdRP9SpW.svg +1 -0
  49. package/dist/assets/kepler186f-Bosuwusi.svg +1 -0
  50. package/dist/assets/lanternnight-Bde4tTOv.svg +1 -0
  51. package/dist/assets/lighting/README.md +10 -0
  52. package/dist/assets/lighting/studio_small_09_1k.hdr +0 -0
  53. package/dist/assets/lt2230-CtZQ_E_7.svg +1 -0
  54. package/dist/assets/lt4260-BfDP2hby.svg +1 -0
  55. package/dist/assets/mars-MRN7bCwO.svg +1 -0
  56. package/dist/assets/mercury-Dxhy2buH.svg +1 -0
  57. package/dist/assets/middangard-TZnEvlji.svg +1 -0
  58. package/dist/assets/milners-Bbcl_qzx.svg +1 -0
  59. package/dist/assets/miranda-DeKynADL.svg +1 -0
  60. package/dist/assets/moon-trBMXXaP.svg +1 -0
  61. package/dist/assets/moors-h4HR2QJC.svg +1 -0
  62. package/dist/assets/muspel-CbnFgpIq.svg +1 -0
  63. package/dist/assets/nabora-C_M0gUvC.svg +1 -0
  64. package/dist/assets/nature.glb +0 -0
  65. package/dist/assets/north-CCQ_abLA.svg +1 -0
  66. package/dist/assets/ocean-xTY8Tv7N.svg +1 -0
  67. package/dist/assets/oz-ClTxqgDh.svg +1 -0
  68. package/dist/assets/pandora-vc20uhuY.svg +1 -0
  69. package/dist/assets/phobos-DKRMWpmg.svg +1 -0
  70. package/dist/assets/pluto-Bv-I3Adh.svg +1 -0
  71. package/dist/assets/proxima-CJWGm2yh.svg +1 -0
  72. package/dist/assets/qubitron-B9yy8CKB.svg +1 -0
  73. package/dist/assets/redspot-DbATwjpD.svg +1 -0
  74. package/dist/assets/ring-CWw9HQNl.svg +1 -0
  75. package/dist/assets/sakura-BaPBQo7U.svg +1 -0
  76. package/dist/assets/saturnrings-C0bWB6Nk.svg +1 -0
  77. package/dist/assets/savanna-3fK5T_wl.svg +1 -0
  78. package/dist/assets/shared-CgqOYNxw.js +4919 -0
  79. package/dist/assets/sky-B_jCrmWW.svg +1 -0
  80. package/dist/assets/spacebase.glb +0 -0
  81. package/dist/assets/sugarvale-CNIezmWw.svg +1 -0
  82. package/dist/assets/tatawin-B4kGS6XH.svg +1 -0
  83. package/dist/assets/terra-BpxwvhON.svg +1 -0
  84. package/dist/assets/titan-DRcbXkdn.svg +1 -0
  85. package/dist/assets/titanic-D6TQMoe4.svg +1 -0
  86. package/dist/assets/trappist-DegjcuQ8.svg +1 -0
  87. package/dist/assets/triton-DXbtxCV7.svg +1 -0
  88. package/dist/assets/tundra-D7lIxFPP.svg +1 -0
  89. package/dist/assets/undersea-rehq9Wmh.svg +1 -0
  90. package/dist/assets/valley-DVlve4ea.svg +1 -0
  91. package/dist/assets/venus-OIZfT1Sa.svg +1 -0
  92. package/dist/assets/vespera-CXGN-RGx.svg +1 -0
  93. package/dist/assets/worldtree-wqKc2Sb_.svg +1 -0
  94. package/dist/assets/yavora4-D1u-Riyb.svg +1 -0
  95. package/dist/audio/README.md +25 -0
  96. package/dist/favicon.svg +5 -0
  97. package/dist/index.html +25 -0
  98. package/dist/pro-manifest.json +9 -0
  99. package/package.json +63 -4
  100. package/server/api.mjs +540 -0
  101. package/server/http-server.mjs +127 -0
  102. package/server/lib/terminal.mjs +213 -0
  103. package/server/lib/user-data.mjs +11 -0
  104. package/server/lib/win-terminal.mjs +88 -0
  105. package/server/lib/windows.mjs +95 -0
  106. package/server/lib/xdg.mjs +44 -0
  107. package/server/license.mjs +69 -0
  108. package/server/pro-content.mjs +146 -0
  109. package/server/scan.mjs +187 -0
  110. package/server/serve.mjs +7 -0
  111. package/server/threadgauge.mjs +42 -0
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Put a command in a new terminal window.
3
+ *
4
+ * Only `server/api.mjs` uses this, and Windows is handed on to `win-terminal.mjs` at the door.
5
+ * It is split out because it is a page of terminal-emulator trivia with nothing to do with HTTP,
6
+ * and nothing in here knows about a particular harness — an adapter never imports it. An adapter
7
+ * hands the server an argv; the server decides whether a terminal is the right place for it.
8
+ */
9
+ import { spawn } from 'node:child_process'
10
+ import fsp from 'node:fs/promises'
11
+ import path from 'node:path'
12
+ import { findExecutable } from '@threadcrumbs/common/lib/fsutil'
13
+ import { openInTerminalWindows } from './win-terminal.mjs'
14
+
15
+ /**
16
+ * How each terminal wants "run this command in this directory". The command comes after the
17
+ * terminal's own end-of-options marker where it has one, so nothing in it is ever read as a
18
+ * flag; the working directory is also set on the spawn itself, which is all the xterm family
19
+ * needs and what the D-Bus terminals forward to their server anyway.
20
+ *
21
+ * Only terminals whose flags are documented are listed. `tilix` is deliberately absent: its `-e`
22
+ * takes one string that it re-splits itself, which would mean building a shell string.
23
+ */
24
+ const TERMINALS = {
25
+ 'gnome-terminal': (dir, cmd) => [`--working-directory=${dir}`, '--', ...cmd],
26
+ kgx: (dir, cmd) => [`--working-directory=${dir}`, '--', ...cmd],
27
+ ptyxis: (dir, cmd) => [`--working-directory=${dir}`, '--', ...cmd],
28
+ konsole: (dir, cmd) => ['--workdir', dir, '-e', ...cmd],
29
+ 'xfce4-terminal': (dir, cmd) => [`--working-directory=${dir}`, '-x', ...cmd],
30
+ 'mate-terminal': (dir, cmd) => [`--working-directory=${dir}`, '-x', ...cmd],
31
+ kitty: (dir, cmd) => [`--directory=${dir}`, ...cmd],
32
+ alacritty: (dir, cmd) => ['--working-directory', dir, '-e', ...cmd],
33
+ ghostty: (dir, cmd) => [`--working-directory=${dir}`, '-e', ...cmd],
34
+ wezterm: (dir, cmd) => ['start', '--cwd', dir, '--', ...cmd],
35
+ foot: (dir, cmd) => [`--working-directory=${dir}`, ...cmd],
36
+ terminator: (dir, cmd) => [`--working-directory=${dir}`, '-x', ...cmd],
37
+ xterm: (_dir, cmd) => ['-e', ...cmd],
38
+ uxterm: (_dir, cmd) => ['-e', ...cmd],
39
+ urxvt: (_dir, cmd) => ['-e', ...cmd],
40
+ rxvt: (_dir, cmd) => ['-e', ...cmd],
41
+ st: (_dir, cmd) => ['-e', ...cmd],
42
+ }
43
+
44
+ const GENERAL_ORDER = [
45
+ 'gnome-terminal', 'konsole', 'xfce4-terminal', 'mate-terminal', 'kitty', 'alacritty', 'ghostty',
46
+ 'wezterm', 'foot', 'terminator', 'ptyxis', 'kgx', 'xterm', 'uxterm', 'urxvt', 'rxvt', 'st',
47
+ ]
48
+
49
+ /**
50
+ * A terminal that ships with the desktop first. `XDG_CURRENT_DESKTOP` is a colon list, such as
51
+ * `ubuntu:GNOME`, and GNOME gets the three it has shipped over the years in the order they are
52
+ * most likely to be the one actually configured.
53
+ */
54
+ function desktopOrder() {
55
+ const parts = (process.env.XDG_CURRENT_DESKTOP || '').toLowerCase().split(':')
56
+ if (parts.includes('kde')) return ['konsole']
57
+ if (parts.includes('xfce')) return ['xfce4-terminal']
58
+ if (parts.includes('mate')) return ['mate-terminal']
59
+ if (parts.some((p) => ['gnome', 'ubuntu', 'unity', 'cinnamon', 'x-cinnamon'].includes(p))) {
60
+ return ['gnome-terminal', 'kgx', 'ptyxis']
61
+ }
62
+ return []
63
+ }
64
+
65
+ /**
66
+ * Is there anywhere for a window to appear? Deliberately loose: only the plainly headless case
67
+ * is refused here, so that it gets a message saying so, and anything less clear-cut is left to
68
+ * the launch itself, whose exit code is the real answer. Wayland clients find their socket
69
+ * without the variable being set, so the runtime dir is checked too.
70
+ */
71
+ async function hasDisplay() {
72
+ if (process.env.DISPLAY || process.env.WAYLAND_DISPLAY) return true
73
+ const run = process.env.XDG_RUNTIME_DIR
74
+ if (!run) return false
75
+ try {
76
+ return (await fsp.readdir(run)).some((name) => name.startsWith('wayland-'))
77
+ } catch {
78
+ return false
79
+ }
80
+ }
81
+
82
+ /**
83
+ * A terminal that refuses does so at once — well under 50 ms on a GNOME desktop — while a window
84
+ * takes longer than this to come up. A refusal slower than this is knowingly reported as a
85
+ * success: the alternative, treating every late non-zero exit as a failure, would open a second
86
+ * terminal whenever the command inside the first one ended quickly.
87
+ */
88
+ const REFUSAL_MS = 300
89
+ /** After this a foreground terminal (xterm, Debian's `--wait` wrapper) is plainly up. */
90
+ const GRACE_MS = 1500
91
+ /**
92
+ * And a ceiling on the whole walk. Each candidate can cost `GRACE_MS`, and on a machine with
93
+ * several terminals installed and a broken display, each one can fail only after that long, which
94
+ * would otherwise hold the request for the sum of them while the page shows a spinner. The walk
95
+ * stops at this budget and reports the failure instead.
96
+ */
97
+ const WALK_BUDGET_MS = 4000
98
+
99
+ /**
100
+ * Spawn a terminal and say whether it actually came up. The D-Bus terminals hand the window to a
101
+ * service and exit 0 at once; when they cannot — no display, a flag they reject — they exit
102
+ * non-zero just as fast, and that is the failure worth reporting instead of a toast that says
103
+ * "opened". A non-zero exit that arrives *later* is different: the window came up and the command
104
+ * inside it has ended, which is the user's to see and no reason to open a second terminal on top.
105
+ */
106
+ function trySpawn(cmd, args, cwd) {
107
+ return new Promise((resolve) => {
108
+ let child
109
+ try {
110
+ child = spawn(cmd, args, { cwd, stdio: 'ignore', detached: true })
111
+ } catch (err) {
112
+ resolve({ ok: false, error: err?.message || String(err) })
113
+ return
114
+ }
115
+ const startedAt = Date.now()
116
+ let timer = null
117
+ let settled = false
118
+ const done = (result) => {
119
+ if (settled) return
120
+ settled = true
121
+ clearTimeout(timer)
122
+ resolve(result)
123
+ }
124
+ child.on('error', (err) => done({ ok: false, error: err?.message || String(err) }))
125
+ child.on('exit', (code, signal) => {
126
+ if (code === 0 || Date.now() - startedAt >= REFUSAL_MS) return done({ ok: true })
127
+ const how = signal ? `signal ${signal}` : `code ${code}`
128
+ done({ ok: false, error: `${path.basename(cmd)} exited with ${how}` })
129
+ })
130
+ timer = setTimeout(() => {
131
+ child.unref()
132
+ done({ ok: true })
133
+ }, GRACE_MS)
134
+ })
135
+ }
136
+
137
+ /**
138
+ * Run `argv` in a new terminal window with `cwd` as its working directory.
139
+ *
140
+ * The caller has already resolved both: `argv[0]` is an absolute executable and `cwd` an
141
+ * existing directory. Which terminal is up to the machine — `THREADNAUTS_TERMINAL` if set, then
142
+ * `$TERMINAL`, then the desktop's own, then whatever is installed, then Debian's
143
+ * `x-terminal-emulator` alternative. That last is tried by name and given `-e`, the one form
144
+ * Debian policy guarantees, because on Ubuntu it is a wrapper script that knows no other flags —
145
+ * pass it `--working-directory` and it opens an empty window. Which is also why a real
146
+ * `gnome-terminal` is looked for first.
147
+ *
148
+ * A named terminal the table does not know is skipped rather than guessed at: `-e` means "the
149
+ * rest of the line" to xterm and "one string, which I will split" to tilix, and guessing wrong
150
+ * opens a window on the wrong command — worse than moving on to a terminal we do know.
151
+ *
152
+ * On macOS the desktop walk finds nothing, so only a named terminal works there — kitty,
153
+ * alacritty, ghostty and wezterm take the same flags on both. Terminal.app and iTerm are not
154
+ * driven: `open -a` cannot hand them an argv without a shell string in between.
155
+ */
156
+ export async function openInTerminal(argv, cwd) {
157
+ // Windows shares none of the trivia below — no PATH walk over sixteen emulators, no display
158
+ // to check — so it is a different file entirely, reached through the same door. The caller
159
+ // asks for a terminal and does not learn which platform it is on.
160
+ if (process.platform === 'win32') return openInTerminalWindows(argv, cwd)
161
+
162
+ const wellFormed = Array.isArray(argv) && argv.length > 0 && argv.every((a) => typeof a === 'string' && a)
163
+ if (!wellFormed || !path.isAbsolute(argv[0]) || typeof cwd !== 'string' || !path.isAbsolute(cwd)) {
164
+ return { ok: false, error: 'Invalid launch command' }
165
+ }
166
+ // A macOS session always has a window server; only Linux can be headless in a way worth naming.
167
+ if (process.platform === 'linux' && !(await hasDisplay())) {
168
+ return { ok: false, error: 'No graphical display to open a terminal on' }
169
+ }
170
+
171
+ const preferred = [
172
+ process.env.THREADNAUTS_TERMINAL || '',
173
+ process.env.TERMINAL || '',
174
+ ...desktopOrder(),
175
+ ...GENERAL_ORDER,
176
+ 'x-terminal-emulator',
177
+ ]
178
+ const names = [...new Set(preferred.filter(Boolean))]
179
+ const deadline = Date.now() + WALK_BUDGET_MS
180
+ const tried = new Set()
181
+ let lastError = ''
182
+ let ranOut = false
183
+
184
+ for (const name of names) {
185
+ if (Date.now() >= deadline) {
186
+ ranOut = true
187
+ break
188
+ }
189
+ const resolved = await findExecutable(name)
190
+ if (!resolved || tried.has(resolved)) continue
191
+ tried.add(resolved)
192
+
193
+ const base = path.basename(name)
194
+ let args
195
+ if (base === 'x-terminal-emulator') args = ['-e', ...argv]
196
+ else if (TERMINALS[base]) args = TERMINALS[base](cwd, argv)
197
+ else continue
198
+
199
+ const result = await trySpawn(resolved, args, cwd)
200
+ if (result.ok) return { ok: true }
201
+ lastError = result.error
202
+ }
203
+
204
+ if (ranOut) {
205
+ return { ok: false, error: 'Gave up waiting for a terminal to open — is the display responding?' }
206
+ }
207
+ return {
208
+ ok: false,
209
+ error: lastError
210
+ ? `Could not open a terminal (${lastError})`
211
+ : 'No terminal emulator found — set THREADNAUTS_TERMINAL or $TERMINAL, or install one (gnome-terminal, kitty, xterm)',
212
+ }
213
+ }
@@ -0,0 +1,11 @@
1
+ import os from 'node:os'
2
+ import path from 'node:path'
3
+
4
+ export function userDataDir({ platform = process.platform, env = process.env, home = os.homedir() } = {}) {
5
+ if (platform === 'win32') {
6
+ return path.win32.join(env.APPDATA || path.win32.join(home, 'AppData', 'Roaming'), 'Threadnauts')
7
+ }
8
+ if (platform === 'darwin') return path.posix.join(home, 'Library', 'Application Support', 'Threadnauts')
9
+ const xdg = env.XDG_DATA_HOME && path.posix.isAbsolute(env.XDG_DATA_HOME) ? env.XDG_DATA_HOME : ''
10
+ return path.posix.join(xdg || path.posix.join(home, '.local', 'share'), 'threadnauts')
11
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Windows plumbing: how you put a command in a terminal window here.
3
+ *
4
+ * The counterpart to `openInTerminal` in `terminal.mjs`, and deliberately a separate file for the
5
+ * same reason that one is: it is a page of platform trivia with nothing to do with HTTP, and
6
+ * nothing in it knows about a particular harness. An adapter hands the server an argv; the
7
+ * server decides whether a terminal is the right place for it.
8
+ *
9
+ * Two terminals, tried in order. Windows Terminal is the one worth having — it takes the
10
+ * working directory as a flag and the command as trailing arguments, so nothing in the argv
11
+ * is ever read as a flag of its own. Plain `cmd.exe` is the fallback that is on every machine.
12
+ */
13
+ import { spawn } from 'node:child_process'
14
+ import fsp from 'node:fs/promises'
15
+ import path from 'node:path'
16
+ import { findExecutable } from '@threadcrumbs/common/lib/fsutil'
17
+
18
+ /**
19
+ * `start` is a cmd builtin rather than a program, so it cannot be spawned directly — it has to
20
+ * be reached through `cmd /c`. `start` takes its first quoted argument as the new window's
21
+ * title, which is why the empty string is there: without it the first real argument disappears
22
+ * and the window opens on whatever the second one happened to be.
23
+ *
24
+ * `/k` keeps the window open after the command exits. That is the point of the feature — you
25
+ * are meant to keep typing in it — and it is also what makes a failure visible instead of a
26
+ * window that flashes and is gone before you read the error.
27
+ */
28
+ const TERMINALS = {
29
+ 'wt.exe': (cwd, argv) => ['-d', cwd, ...argv],
30
+ 'cmd.exe': (_cwd, argv) => ['/c', 'start', '', 'cmd.exe', '/k', ...argv],
31
+ }
32
+
33
+ const ORDER = ['wt.exe', 'cmd.exe']
34
+
35
+ function trySpawn(bin, args, cwd) {
36
+ return new Promise((resolve) => {
37
+ let child
38
+ try {
39
+ // Detached and unref'd: the terminal has to outlive this request, and it must not hold
40
+ // the dev server open when you stop it.
41
+ child = spawn(bin, args, { cwd, detached: true, stdio: 'ignore', windowsHide: false })
42
+ } catch (err) {
43
+ return resolve({ ok: false, error: String(err && err.message ? err.message : err) })
44
+ }
45
+ // A spawn that is going to fail does so immediately; one that survives the tick is running.
46
+ const settled = setTimeout(() => {
47
+ child.unref()
48
+ resolve({ ok: true })
49
+ }, 120)
50
+ child.once('error', (err) => {
51
+ clearTimeout(settled)
52
+ resolve({ ok: false, error: String(err && err.message ? err.message : err) })
53
+ })
54
+ })
55
+ }
56
+
57
+ /**
58
+ * Open `argv` in a terminal sitting in `cwd`.
59
+ *
60
+ * Both arguments are validated the same way `openInTerminal` validates them, and for the same
61
+ * reason: this function will hand what it is given to the OS, so a relative path or an empty
62
+ * string is refused here rather than resolved against whatever directory the server happens to
63
+ * be running in.
64
+ */
65
+ export async function openInTerminalWindows(argv, cwd) {
66
+ const wellFormed = Array.isArray(argv) && argv.length > 0 && argv.every((a) => typeof a === 'string' && a)
67
+ if (!wellFormed || !path.isAbsolute(argv[0]) || typeof cwd !== 'string' || !path.isAbsolute(cwd)) {
68
+ return { ok: false, error: 'Invalid launch command' }
69
+ }
70
+ const usable = await fsp.stat(cwd).then((st) => st.isDirectory(), () => false)
71
+ if (!usable) return { ok: false, error: 'That folder is not a directory any more' }
72
+
73
+ let lastError = ''
74
+ for (const name of ORDER) {
75
+ const resolved = await findExecutable(name)
76
+ if (!resolved) continue
77
+ const result = await trySpawn(resolved, TERMINALS[name](cwd, argv), cwd)
78
+ if (result.ok) return { ok: true }
79
+ lastError = result.error
80
+ }
81
+
82
+ return {
83
+ ok: false,
84
+ error: lastError
85
+ ? `Could not open a terminal (${lastError})`
86
+ : 'No terminal found — install Windows Terminal, or check that cmd.exe is on your PATH',
87
+ }
88
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Windows desktop plumbing: put the window that hosts a given process in front.
3
+ *
4
+ * The sibling of `xdg.mjs`, for the same reason — a page of OS trivia with nothing to do with
5
+ * HTTP, and nothing in here knows about a particular harness. An adapter hands the server a pid;
6
+ * the server decides whether fronting its window is the right way to answer "open this".
7
+ *
8
+ * The pid itself has no window — it is a CLI process. The window belongs to whatever terminal is
9
+ * hosting it, which is some ancestor: the shell's parent is Windows Terminal, VS Code, an IDE.
10
+ * So the walk goes *up* the parent chain until a process with a real main window appears, and
11
+ * fronts that. The Claude desktop app is deliberately stepped over: a process hosted by the app
12
+ * itself is the app's to present, and the deep link does that better than raw window fronting.
13
+ */
14
+ import { execFile } from 'node:child_process'
15
+ import { promisify } from 'node:util'
16
+
17
+ const execFileAsync = promisify(execFile)
18
+
19
+ /**
20
+ * Enumerating every process (`Get-CimInstance Win32_Process`) is the slow half, a second or two
21
+ * on a busy machine; the rest is instant. The budget covers that with room, because past it the
22
+ * caller falls back to the deep link — a worse answer than a fronted window, but never a hang.
23
+ */
24
+ const FOCUS_TIMEOUT_MS = 8000
25
+
26
+ /** How many parents to visit before concluding nothing on the chain owns a window. */
27
+ const MAX_HOPS = 16
28
+
29
+ /**
30
+ * `SetForegroundWindow` alone is refused when the caller is a background process — Windows
31
+ * reserves focus-stealing for whoever the user is interacting with. A transient Alt keypress
32
+ * (0xA4, down then up) is the documented-by-folklore exemption: a process that just sent input
33
+ * counts as interacting. Restore-first matters too: fronting a minimized window leaves it
34
+ * minimized, just "active" in the taskbar.
35
+ */
36
+ const script = (pid) => `
37
+ $ErrorActionPreference = 'SilentlyContinue'
38
+ $parent = @{}
39
+ foreach ($p in Get-CimInstance Win32_Process) { $parent[[int]$p.ProcessId] = [int]$p.ParentProcessId }
40
+ Add-Type -TypeDefinition @'
41
+ using System;
42
+ using System.Runtime.InteropServices;
43
+ public static class WindowFocus {
44
+ [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
45
+ [DllImport("user32.dll")] public static extern bool ShowWindowAsync(IntPtr h, int cmd);
46
+ [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h);
47
+ [DllImport("user32.dll")] public static extern void keybd_event(byte key, byte scan, uint flags, UIntPtr extra);
48
+ }
49
+ '@
50
+ $cur = ${pid}
51
+ for ($i = 0; $i -lt ${MAX_HOPS} -and $cur -gt 0; $i++) {
52
+ $proc = Get-Process -Id $cur
53
+ if ($proc -and $proc.MainWindowHandle -ne [IntPtr]::Zero) {
54
+ # The first *windowed* ancestor decides. The Claude app means the thread is the app's own —
55
+ # its deep link presents it better; explorer is the chain's root, and fronting a stray File
56
+ # Explorer window would be plain wrong. Either way: stop, let the caller fall back.
57
+ if ($proc.ProcessName -eq 'claude' -or $proc.ProcessName -eq 'explorer') { exit 1 }
58
+ $h = $proc.MainWindowHandle
59
+ if ([WindowFocus]::IsIconic($h)) { [WindowFocus]::ShowWindowAsync($h, 9) | Out-Null }
60
+ [WindowFocus]::keybd_event(0xA4, 0, 0, [UIntPtr]::Zero)
61
+ [WindowFocus]::keybd_event(0xA4, 0, 2, [UIntPtr]::Zero)
62
+ [WindowFocus]::SetForegroundWindow($h) | Out-Null
63
+ 'focused'
64
+ exit 0
65
+ }
66
+ if (-not $parent.ContainsKey($cur)) { break }
67
+ $next = $parent[$cur]
68
+ if ($next -eq $cur) { break }
69
+ $cur = $next
70
+ }
71
+ exit 1
72
+ `
73
+
74
+ /**
75
+ * Front the window hosting `pid`. True only when a window was actually found and told to come
76
+ * forward; false covers everything else — wrong platform, a chain with no window on it (a
77
+ * detached process, or one hosted by the Claude desktop app), or PowerShell being unavailable.
78
+ * The caller treats false as "use the URL instead", so failing quietly is the whole contract.
79
+ */
80
+ export async function focusWindowOfPid(pid) {
81
+ if (process.platform !== 'win32') return false
82
+ if (!Number.isInteger(pid) || pid <= 0) return false
83
+ try {
84
+ // -EncodedCommand sidesteps every quoting rule cmd and PowerShell disagree on.
85
+ const encoded = Buffer.from(script(pid), 'utf16le').toString('base64')
86
+ const { stdout } = await execFileAsync(
87
+ 'powershell.exe',
88
+ ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded],
89
+ { timeout: FOCUS_TIMEOUT_MS, windowsHide: true }
90
+ )
91
+ return stdout.includes('focused')
92
+ } catch {
93
+ return false
94
+ }
95
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Linux desktop plumbing: does anything on this machine answer a URL scheme?
3
+ *
4
+ * Only `server/api.mjs` uses this, and only on Linux. It is split out because it is a page of
5
+ * desktop-environment trivia with nothing to do with HTTP, and nothing in here knows about a
6
+ * particular harness — an adapter never imports it.
7
+ */
8
+ import { execFile } from 'node:child_process'
9
+ import { promisify } from 'node:util'
10
+
11
+ const execFileAsync = promisify(execFile)
12
+
13
+ /** The scheme of a URL, or '' if it does not parse. */
14
+ export function schemeOf(url) {
15
+ try {
16
+ return new URL(url).protocol.replace(/:$/, '')
17
+ } catch {
18
+ return ''
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Is a desktop app registered for this URL's scheme?
24
+ *
25
+ * `xdg-mime query default x-scheme-handler/<scheme>` is what `xdg-open` itself consults. It
26
+ * checks that a `mimeapps.list` entry still points at an installed .desktop file, which is the
27
+ * common stale case — an uninstalled app leaves its line behind — though its `mimeinfo.cache`
28
+ * fallback takes an entry on trust, exactly as xdg-open would.
29
+ *
30
+ * Anything that stops the query counts as "no handler": the alternative is handing xdg-open a
31
+ * URL nothing answers, which fails silently and reaches the page as "Opened".
32
+ */
33
+ export async function schemeHasHandler(url) {
34
+ const scheme = schemeOf(url)
35
+ if (!/^[a-z][a-z0-9+.-]*$/.test(scheme)) return false
36
+ try {
37
+ // Bounded: on KDE the query goes through the trader, which can sit on a D-Bus timeout.
38
+ const args = ['query', 'default', `x-scheme-handler/${scheme}`]
39
+ const { stdout } = await execFileAsync('xdg-mime', args, { timeout: 5000 })
40
+ return stdout.trim().length > 0
41
+ } catch {
42
+ return false
43
+ }
44
+ }
@@ -0,0 +1,69 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { paidThrough, renewalDue } from '@threadcrumbs/common/license'
5
+ import { cloudUrl, licenseStore, threadcrumbsDataDir, trustedKeys } from '@threadcrumbs/common/license/store'
6
+
7
+ const ENTITLEMENT = 'threadnauts.pro'
8
+ const CLOUD_TIMEOUT_MS = 15000
9
+
10
+ const runningFromCheckout = fs.existsSync(path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '.git'))
11
+
12
+ export function licensing({ store, cloudUrl, fetch = globalThis.fetch }) {
13
+ const describe = (license, at) => ({
14
+ proThrough: license ? paidThrough(license, ENTITLEMENT, at) : 0,
15
+ renewalDue: Boolean(license) && renewalDue(license, at),
16
+ })
17
+
18
+ async function status() {
19
+ const { license, at } = await store.current()
20
+ return describe(license, at)
21
+ }
22
+
23
+ async function ask(route, body) {
24
+ if (!cloudUrl) return { error: 'unavailable' }
25
+ let response
26
+ try {
27
+ response = await fetch(`${cloudUrl}${route}`, {
28
+ method: 'POST',
29
+ headers: { 'content-type': 'application/json' },
30
+ body: JSON.stringify(body),
31
+ signal: AbortSignal.timeout(CLOUD_TIMEOUT_MS),
32
+ })
33
+ } catch {
34
+ return { error: 'offline' }
35
+ }
36
+ const data = await response.json().catch(() => null)
37
+ if (response.ok && typeof data?.license === 'string') return { license: data.license }
38
+ return { error: typeof data?.error === 'string' ? data.error : 'unavailable' }
39
+ }
40
+
41
+ async function keep(answer) {
42
+ if (answer.error) return { ...(await status()), error: answer.error }
43
+ const saved = await store.save(answer.license)
44
+ if (!saved.ok) return { ...(await status()), error: 'license_rejected' }
45
+ return describe(saved.license, saved.at)
46
+ }
47
+
48
+ async function activate(key) {
49
+ const trimmed = typeof key === 'string' ? key.trim() : ''
50
+ if (!trimmed) return { ...(await status()), error: 'key_missing' }
51
+ return keep(await ask('/v1/license', { key: trimmed, device: await store.device() }))
52
+ }
53
+
54
+ async function renew() {
55
+ const { token, license, at } = await store.current()
56
+ if (!token || !renewalDue(license, at)) return describe(license, at)
57
+ return keep(await ask('/v1/license/renew', { license: token }))
58
+ }
59
+
60
+ return { status, activate, renew }
61
+ }
62
+
63
+ export function localLicensing({ env = process.env, development = runningFromCheckout, fetch } = {}) {
64
+ return licensing({
65
+ store: licenseStore({ dir: threadcrumbsDataDir({ env }), keys: trustedKeys({ env, development }) }),
66
+ cloudUrl: cloudUrl({ env, development }),
67
+ fetch,
68
+ })
69
+ }
@@ -0,0 +1,146 @@
1
+ import crypto from 'node:crypto'
2
+ import fs from 'node:fs'
3
+ import fsp from 'node:fs/promises'
4
+ import path from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+ import { LICENSE_GRACE, paidThrough } from '@threadcrumbs/common/license'
7
+ import { cloudUrl, licenseStore, threadcrumbsDataDir, trustedKeys } from '@threadcrumbs/common/license/store'
8
+
9
+ const ENTITLEMENT = 'threadnauts.pro'
10
+ const APP = 'threadnauts'
11
+ const DOWNLOAD_TIMEOUT_MS = 60000
12
+ const RENAME_TRIES = 10
13
+ const PAUSE_MS = 20
14
+
15
+ const here = path.dirname(fileURLToPath(import.meta.url))
16
+ const DIST = path.join(here, '..', 'dist')
17
+ const runningFromCheckout = fs.existsSync(path.join(here, '..', '.git'))
18
+
19
+ const sha256 = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex')
20
+ const pause = () => new Promise((resolve) => setTimeout(resolve, PAUSE_MS))
21
+
22
+ function readManifest(file) {
23
+ try {
24
+ const manifest = JSON.parse(fs.readFileSync(file, 'utf8'))
25
+ const files = Array.isArray(manifest?.files) ? manifest.files : []
26
+ return files.every((f) => typeof f?.path === 'string' && /^assets\/[A-Za-z0-9._-]+$/.test(f.path) && /^[0-9a-f]{64}$/.test(f.sha256)) ? files : []
27
+ } catch {
28
+ return null
29
+ }
30
+ }
31
+
32
+ export function proContent({ store, cloudUrl, cacheDir, manifestFile = path.join(DIST, 'pro-manifest.json'), localBuild = null, fetch = globalThis.fetch }) {
33
+ let downloading = null
34
+ let listed
35
+ const manifest = () => (listed === undefined ? (listed = readManifest(manifestFile)) : listed)
36
+ const cached = (file) => path.join(cacheDir, file.path)
37
+
38
+ async function verified(file) {
39
+ const bytes = await fsp.readFile(cached(file)).catch(() => null)
40
+ return bytes && sha256(bytes) === file.sha256 ? bytes : null
41
+ }
42
+
43
+ async function present(files) {
44
+ for (const file of files) {
45
+ if (!(await verified(file))) return false
46
+ }
47
+ return true
48
+ }
49
+
50
+ async function keep(file, bytes) {
51
+ if (sha256(bytes) !== file.sha256) return false
52
+ const target = cached(file)
53
+ const temp = `${target}.${process.pid}.${Date.now()}.tmp`
54
+ try {
55
+ await fsp.mkdir(path.dirname(target), { recursive: true })
56
+ await fsp.writeFile(temp, bytes)
57
+ for (let attempt = 1; ; attempt++) {
58
+ try {
59
+ await fsp.rename(temp, target)
60
+ return true
61
+ } catch (err) {
62
+ if (attempt >= RENAME_TRIES || !['EPERM', 'EACCES', 'EBUSY'].includes(err.code)) throw err
63
+ await pause()
64
+ }
65
+ }
66
+ } catch (err) {
67
+ await fsp.rm(temp, { force: true })
68
+ throw err
69
+ }
70
+ }
71
+
72
+ async function copyLocalBuild(files) {
73
+ if (!localBuild) return false
74
+ for (const file of files) {
75
+ if (await present([file])) continue
76
+ let bytes
77
+ try {
78
+ bytes = await fsp.readFile(path.join(localBuild, file.path))
79
+ } catch {
80
+ return false
81
+ }
82
+ if (!(await keep(file, bytes).catch(() => false))) return false
83
+ }
84
+ return true
85
+ }
86
+
87
+ async function download(files) {
88
+ if (await copyLocalBuild(files)) return { ready: true }
89
+ const { token, license, at } = await store.current()
90
+ if (!token || paidThrough(license, ENTITLEMENT, at) <= 0) return { ready: false, error: 'no_license' }
91
+ if (at > license.exp + LICENSE_GRACE) return { ready: false, error: 'license_expired' }
92
+ if (!cloudUrl) return { ready: false, error: 'unavailable' }
93
+ for (const file of files) {
94
+ if (await present([file])) continue
95
+ let bytes
96
+ try {
97
+ const response = await fetch(`${cloudUrl}/v1/content/${APP}/${file.path}`, {
98
+ headers: { authorization: `Bearer ${token}`, 'x-threadcrumbs-device': license.dev },
99
+ signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
100
+ })
101
+ if (!response.ok) {
102
+ const data = await response.json().catch(() => null)
103
+ return { ready: false, error: typeof data?.error === 'string' ? data.error : 'unavailable' }
104
+ }
105
+ bytes = Buffer.from(await response.arrayBuffer())
106
+ } catch {
107
+ return { ready: false, error: 'offline' }
108
+ }
109
+ const kept = await keep(file, bytes).catch(() => null)
110
+ if (kept === null) return { ready: false, error: 'not_saved' }
111
+ if (!kept) return { ready: false, error: 'corrupt' }
112
+ }
113
+ return { ready: true }
114
+ }
115
+
116
+ async function status() {
117
+ const files = manifest()
118
+ if (files === null) return { ready: true }
119
+ if (await present(files)) return { ready: true }
120
+ downloading ??= download(files)
121
+ .catch(() => ({ ready: false, error: 'unavailable' }))
122
+ .finally(() => {
123
+ downloading = null
124
+ })
125
+ return downloading
126
+ }
127
+
128
+ async function file(pathname) {
129
+ const wanted = manifest()?.find((f) => `/${f.path}` === pathname)
130
+ if (!wanted) return null
131
+ return (await verified(wanted)) ?? ((await status()).ready ? verified(wanted) : null)
132
+ }
133
+
134
+ return { status, file }
135
+ }
136
+
137
+ export function localProContent({ env = process.env, development = runningFromCheckout, fetch } = {}) {
138
+ const dir = threadcrumbsDataDir({ env })
139
+ return proContent({
140
+ store: licenseStore({ dir, keys: trustedKeys({ env, development }) }),
141
+ cloudUrl: cloudUrl({ env, development }),
142
+ cacheDir: path.join(dir, 'content', APP),
143
+ localBuild: development ? path.join(here, '..', 'pro-dist') : null,
144
+ fetch,
145
+ })
146
+ }