@threadcrumbs/threadnauts 0.0.0-stage → 1.0.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.
- package/LICENSE.md +10 -0
- package/NOTICES.md +683 -0
- package/README.md +163 -2
- package/bin/threadnauts.mjs +114 -0
- package/dist/assets/CREDITS.md +19 -0
- package/dist/assets/aldargon-B_VubjBf.svg +1 -0
- package/dist/assets/arrakesh-BW9Bb56U.svg +1 -0
- package/dist/assets/atoll-mfMgWjQE.svg +1 -0
- package/dist/assets/autumn-Cvk8t_-3.svg +1 -0
- package/dist/assets/bamboo-KEAHdFas.svg +1 -0
- package/dist/assets/basalt-g767Blh3.svg +1 -0
- package/dist/assets/baut.glb +0 -0
- package/dist/assets/beach-q2rexblq.svg +1 -0
- package/dist/assets/callisto-bug4x0L8.svg +1 -0
- package/dist/assets/cancri-BOebEtMP.svg +1 -0
- package/dist/assets/canyon-FF3hqLaT.svg +1 -0
- package/dist/assets/ceres-BeB8pWjM.svg +1 -0
- package/dist/assets/challenger-Bg9wWAtc.svg +1 -0
- package/dist/assets/crystal-bk_pmXWn.svg +1 -0
- package/dist/assets/dagmora-Co5gg-3x.svg +1 -0
- package/dist/assets/deadstar-x-dSincY.svg +1 -0
- package/dist/assets/desert-Bkozg-2y.svg +1 -0
- package/dist/assets/dragonnest-DExsIn2w.svg +1 -0
- package/dist/assets/draugr-ChEwvDI7.svg +1 -0
- package/dist/assets/enceladus-BlSNfFYi.svg +1 -0
- package/dist/assets/endral-DQ-u4-N6.svg +1 -0
- package/dist/assets/europa-Cgids_PV.svg +1 -0
- package/dist/assets/fjord-rGF4e-MG.svg +1 -0
- package/dist/assets/forest-BVWgbcSU.svg +1 -0
- package/dist/assets/forest.glb +0 -0
- package/dist/assets/frozenlake-B8282a2Z.svg +1 -0
- package/dist/assets/ganymede-Dg4iJbs2.svg +1 -0
- package/dist/assets/garden-TQmDzzU5.svg +1 -0
- package/dist/assets/glimmer-BIK9hzb3.svg +1 -0
- package/dist/assets/halley-B32BiHtn.svg +1 -0
- package/dist/assets/hd189733b-E4qM1zIs.svg +1 -0
- package/dist/assets/hollow-DKKw0GKy.svg +1 -0
- package/dist/assets/horth-DPuhph1g.svg +1 -0
- package/dist/assets/iapetus-CMxb57ov.svg +1 -0
- package/dist/assets/index-C-3KPMaV.css +1 -0
- package/dist/assets/index-xHGM7fhg.js +2381 -0
- package/dist/assets/io-C7TKKMjC.svg +1 -0
- package/dist/assets/j1407b-Dx2Qblb7.svg +1 -0
- package/dist/assets/jakhara-BLl7oBmq.svg +1 -0
- package/dist/assets/jupiter-8ezAafsg.svg +1 -0
- package/dist/assets/kaladorn-CkaAJJ7a.svg +1 -0
- package/dist/assets/kashiwa-_WrMlYhf.svg +1 -0
- package/dist/assets/kepler16b-CdRP9SpW.svg +1 -0
- package/dist/assets/kepler186f-Bosuwusi.svg +1 -0
- package/dist/assets/lanternnight-Bde4tTOv.svg +1 -0
- package/dist/assets/lighting/README.md +10 -0
- package/dist/assets/lighting/studio_small_09_1k.hdr +0 -0
- package/dist/assets/lt2230-CtZQ_E_7.svg +1 -0
- package/dist/assets/lt4260-BfDP2hby.svg +1 -0
- package/dist/assets/mars-MRN7bCwO.svg +1 -0
- package/dist/assets/mercury-Dxhy2buH.svg +1 -0
- package/dist/assets/middangard-TZnEvlji.svg +1 -0
- package/dist/assets/milners-Bbcl_qzx.svg +1 -0
- package/dist/assets/miranda-DeKynADL.svg +1 -0
- package/dist/assets/moon-trBMXXaP.svg +1 -0
- package/dist/assets/moors-h4HR2QJC.svg +1 -0
- package/dist/assets/muspel-CbnFgpIq.svg +1 -0
- package/dist/assets/nabora-C_M0gUvC.svg +1 -0
- package/dist/assets/nature.glb +0 -0
- package/dist/assets/north-CCQ_abLA.svg +1 -0
- package/dist/assets/ocean-xTY8Tv7N.svg +1 -0
- package/dist/assets/oz-ClTxqgDh.svg +1 -0
- package/dist/assets/pandora-vc20uhuY.svg +1 -0
- package/dist/assets/phobos-DKRMWpmg.svg +1 -0
- package/dist/assets/pluto-Bv-I3Adh.svg +1 -0
- package/dist/assets/proxima-CJWGm2yh.svg +1 -0
- package/dist/assets/qubitron-B9yy8CKB.svg +1 -0
- package/dist/assets/redspot-CV5z9nSH.svg +1 -0
- package/dist/assets/ring-CWw9HQNl.svg +1 -0
- package/dist/assets/sakura-BaPBQo7U.svg +1 -0
- package/dist/assets/saturnrings-C0bWB6Nk.svg +1 -0
- package/dist/assets/savanna-3fK5T_wl.svg +1 -0
- package/dist/assets/shared-B_T8JlmU.js +4919 -0
- package/dist/assets/sky-B_jCrmWW.svg +1 -0
- package/dist/assets/spacebase.glb +0 -0
- package/dist/assets/sugarvale-CNIezmWw.svg +1 -0
- package/dist/assets/tatawin-B4kGS6XH.svg +1 -0
- package/dist/assets/terra-BpxwvhON.svg +1 -0
- package/dist/assets/titan-DRcbXkdn.svg +1 -0
- package/dist/assets/titanic-D6TQMoe4.svg +1 -0
- package/dist/assets/trappist-DegjcuQ8.svg +1 -0
- package/dist/assets/triton-DXbtxCV7.svg +1 -0
- package/dist/assets/tundra-D7lIxFPP.svg +1 -0
- package/dist/assets/undersea-rehq9Wmh.svg +1 -0
- package/dist/assets/valley-DVlve4ea.svg +1 -0
- package/dist/assets/venus-OIZfT1Sa.svg +1 -0
- package/dist/assets/vespera-CXGN-RGx.svg +1 -0
- package/dist/assets/worldtree-wqKc2Sb_.svg +1 -0
- package/dist/assets/yavora4-D1u-Riyb.svg +1 -0
- package/dist/audio/README.md +25 -0
- package/dist/favicon.svg +5 -0
- package/dist/index.html +25 -0
- package/dist/pro-manifest.json +9 -0
- package/package.json +63 -4
- package/server/api.mjs +537 -0
- package/server/http-server.mjs +127 -0
- package/server/lib/terminal.mjs +213 -0
- package/server/lib/user-data.mjs +11 -0
- package/server/lib/win-terminal.mjs +88 -0
- package/server/lib/windows.mjs +95 -0
- package/server/lib/xdg.mjs +44 -0
- package/server/license.mjs +69 -0
- package/server/pro-content.mjs +146 -0
- package/server/scan.mjs +187 -0
- package/server/serve.mjs +7 -0
- 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
|
+
}
|