dsh-plugin-mobile-gateway 0.7.7 → 0.9.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.
@@ -0,0 +1,282 @@
1
+ import fs from 'node:fs'
2
+ import http from 'node:http'
3
+ import path from 'node:path'
4
+ import crypto from 'node:crypto'
5
+ import { spawn } from 'node:child_process'
6
+ import { ensureCloudflared } from './cloudflared-binary.mjs'
7
+
8
+ function hostnameOf(value) {
9
+ if (typeof value !== 'string') throw new TypeError('Cloudflare hostname is required')
10
+ const hostname = value.trim().toLowerCase()
11
+ if (hostname.length > 253 || !/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/.test(hostname) ||
12
+ !hostname.includes('.') || hostname.includes('..') ||
13
+ hostname.split('.').some((label) => label.length > 63 || label.startsWith('-') || label.endsWith('-'))) {
14
+ throw new TypeError('Cloudflare hostname must be a domain name, for example gateway.example.com')
15
+ }
16
+ return hostname
17
+ }
18
+
19
+ function tokenOf(value) {
20
+ if (typeof value !== 'string' || !value.trim() || value.length > 8192 || /\s/.test(value)) {
21
+ throw new TypeError('Cloudflare Tunnel Token must be a nonempty token without whitespace')
22
+ }
23
+ return value
24
+ }
25
+
26
+ function readState(file) {
27
+ try {
28
+ const value = JSON.parse(fs.readFileSync(file, 'utf8'))
29
+ if (value?.version !== 1 || typeof value.enabled !== 'boolean' ||
30
+ typeof value.hostname !== 'string' || typeof value.token !== 'string') {
31
+ throw new TypeError('invalid Cloudflare Tunnel settings')
32
+ }
33
+ if (value.hostname) hostnameOf(value.hostname)
34
+ if (value.token) tokenOf(value.token)
35
+ value.mode ??= 'named'
36
+ if (!['named', 'quick'].includes(value.mode)) throw new TypeError('invalid Cloudflare Tunnel mode')
37
+ if (value.enabled && value.mode === 'named' && (!value.hostname || !value.token)) {
38
+ throw new TypeError('enabled named tunnel has no hostname or token')
39
+ }
40
+ fs.chmodSync(file, 0o600)
41
+ return value
42
+ } catch (error) {
43
+ if (error.code === 'ENOENT') return { version: 1, enabled: false, mode: 'quick', hostname: '', token: '' }
44
+ throw new Error(`failed to load Cloudflare Tunnel settings ${file}: ${error.message}`, { cause: error })
45
+ }
46
+ }
47
+
48
+ function writeState(file, value) {
49
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
50
+ const temporary = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`
51
+ try {
52
+ fs.writeFileSync(temporary, `${JSON.stringify(value)}\n`, { mode: 0o600, flag: 'wx' })
53
+ fs.renameSync(temporary, file)
54
+ fs.chmodSync(file, 0o600)
55
+ } finally {
56
+ fs.rmSync(temporary, { force: true })
57
+ }
58
+ }
59
+
60
+ // A separate loopback listener exposes only the mobile WebSocket. In particular,
61
+ // Cloudflare can never reach the DSH WebUI or the /mgw management API through it.
62
+ export function createCloudflareTunnel({ file, port = 3082, wsPath, executable = 'cloudflared',
63
+ supported = true, isGatewayEnabled, onUpgrade, log = () => {}, spawnProcess = spawn,
64
+ prepareExecutable = ensureCloudflared }) {
65
+ let settings = supported ? readState(file) : { version: 1, enabled: false, mode: 'quick', hostname: '', token: '' }
66
+ let server = null
67
+ let child = null
68
+ let preparing = null
69
+ let retryTimer = null
70
+ let disposed = false
71
+ let phase = 'disabled'
72
+ let error = null
73
+ let quickUrl = null
74
+ let queue = Promise.resolve()
75
+ const sockets = new Set()
76
+
77
+ const shouldRun = () => supported && !disposed && settings.enabled && isGatewayEnabled()
78
+ const publicUrl = () => settings.mode === 'quick'
79
+ ? quickUrl
80
+ : settings.hostname ? `wss://${settings.hostname}${wsPath}` : null
81
+ const snapshot = () => ({
82
+ supported,
83
+ enabled: settings.enabled,
84
+ mode: settings.mode,
85
+ configured: !!(settings.hostname && settings.token),
86
+ hostname: settings.hostname || null,
87
+ publicUrl: settings.enabled ? publicUrl() : null,
88
+ port,
89
+ state: !settings.enabled ? 'disabled' : !isGatewayEnabled() ? 'waiting-for-gateway' : phase,
90
+ error,
91
+ })
92
+
93
+ const stop = async () => {
94
+ if (retryTimer) clearTimeout(retryTimer)
95
+ retryTimer = null
96
+ const previous = child
97
+ child = null
98
+ if (previous && !previous.killed) previous.kill()
99
+ quickUrl = null
100
+ for (const socket of sockets) socket.destroy()
101
+ sockets.clear()
102
+ const listener = server
103
+ server = null
104
+ phase = 'disabled'
105
+ error = null
106
+ if (listener) await new Promise((resolve) => listener.close(resolve))
107
+ }
108
+
109
+ const startChild = () => {
110
+ if (!shouldRun() || !server || child || preparing) return
111
+ phase = 'preparing'
112
+ error = null
113
+ preparing = prepareExecutable({ cacheDir: path.join(path.dirname(file), 'cloudflared-bin'), executable })
114
+ .then((binary) => {
115
+ preparing = null
116
+ if (!shouldRun() || !server || child) return
117
+ phase = 'connecting'
118
+ const quick = settings.mode === 'quick'
119
+ const args = quick
120
+ ? ['tunnel', '--no-autoupdate', '--loglevel', 'info', '--url', `http://127.0.0.1:${port}`]
121
+ : ['tunnel', '--no-autoupdate', '--loglevel', 'info', 'run']
122
+ const childEnv = { ...process.env }
123
+ delete childEnv.TUNNEL_TOKEN
124
+ if (!quick) childEnv.TUNNEL_TOKEN = settings.token
125
+ const running = spawnProcess(binary, args, {
126
+ env: childEnv,
127
+ stdio: ['ignore', 'pipe', 'pipe'],
128
+ windowsHide: true,
129
+ })
130
+ child = running
131
+ let diagnostics = ''
132
+ let outputTail = ''
133
+ let registered = false
134
+ const onOutput = (chunk) => {
135
+ if (child !== running) return
136
+ const lines = chunk.toString().slice(0, 4096)
137
+ outputTail = (outputTail + lines).slice(-4096)
138
+ if (quick) {
139
+ const match = /https:\/\/([a-z0-9-]+)\.trycloudflare\.com\b/i.exec(outputTail)
140
+ if (match) quickUrl = `wss://${match[1].toLowerCase()}.trycloudflare.com${wsPath}`
141
+ }
142
+ if (/Registered tunnel connection/i.test(outputTail)) {
143
+ registered = true
144
+ outputTail = ''
145
+ } else if (/ERR|error/i.test(lines)) {
146
+ diagnostics = (settings.token ? outputTail.replaceAll(settings.token, '[redacted]') : outputTail)
147
+ .replace(/eyJ[A-Za-z0-9._=-]+/g, '[redacted]').trim().slice(-500)
148
+ }
149
+ if (registered && (!quick || quickUrl)) {
150
+ phase = 'online'
151
+ error = null
152
+ }
153
+ }
154
+ running.stderr?.on('data', onOutput)
155
+ running.stdout?.on('data', onOutput)
156
+ const exited = (reason) => {
157
+ if (child !== running) return
158
+ child = null
159
+ quickUrl = null
160
+ phase = 'error'
161
+ error = reason || diagnostics || 'cloudflared exited unexpectedly'
162
+ log(`Cloudflare Tunnel stopped: ${error}`)
163
+ if (shouldRun()) retryTimer = setTimeout(() => { retryTimer = null; startChild() }, 10_000)
164
+ }
165
+ running.once('error', (cause) => exited(cause.code === 'ENOENT'
166
+ ? 'cloudflared is not installed or is not on PATH'
167
+ : `cloudflared failed: ${cause.message}`))
168
+ running.once('exit', (code, signal) => exited(diagnostics || `cloudflared exited (${signal || code})`))
169
+ })
170
+ .catch((cause) => {
171
+ preparing = null
172
+ if (!shouldRun()) return
173
+ phase = 'error'
174
+ error = `Cloudflare binary preparation failed: ${cause.message}`
175
+ log(error)
176
+ retryTimer = setTimeout(() => { retryTimer = null; startChild() }, 30_000)
177
+ })
178
+ }
179
+
180
+ const start = async () => {
181
+ if (server || !shouldRun()) return
182
+ phase = 'starting'
183
+ error = null
184
+ const listener = http.createServer((_req, res) => {
185
+ res.writeHead(404, { 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' })
186
+ res.end('Not found')
187
+ })
188
+ listener.on('connection', (socket) => {
189
+ sockets.add(socket)
190
+ socket.on('close', () => sockets.delete(socket))
191
+ })
192
+ listener.on('upgrade', (req, socket, head) => {
193
+ let pathname
194
+ try { pathname = new URL(req.url || '/', 'http://localhost').pathname } catch { pathname = '' }
195
+ if (pathname !== wsPath) {
196
+ socket.end('HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n')
197
+ return
198
+ }
199
+ onUpgrade(req, socket, head, { lan: true, port })
200
+ })
201
+ try {
202
+ await new Promise((resolve, reject) => {
203
+ listener.once('error', reject)
204
+ listener.listen(port, '127.0.0.1', () => {
205
+ listener.off('error', reject)
206
+ resolve()
207
+ })
208
+ })
209
+ if (!shouldRun()) {
210
+ listener.close()
211
+ return
212
+ }
213
+ server = listener
214
+ listener.on('error', (cause) => {
215
+ const message = `Cloudflare origin listener failed: ${cause.message}`
216
+ void stop().then(() => {
217
+ if (!shouldRun()) return
218
+ phase = 'error'
219
+ error = message
220
+ log(message)
221
+ retryTimer = setTimeout(() => { retryTimer = null; void reconcile() }, 10_000)
222
+ })
223
+ })
224
+ startChild()
225
+ } catch (cause) {
226
+ try { listener.close() } catch { /* The listener never opened. */ }
227
+ if (shouldRun()) {
228
+ phase = 'error'
229
+ error = `Cloudflare origin listener failed: ${cause.message}`
230
+ log(error)
231
+ retryTimer = setTimeout(() => { retryTimer = null; void reconcile() }, 10_000)
232
+ }
233
+ }
234
+ }
235
+
236
+ const reconcile = () => {
237
+ queue = queue.catch(() => {}).then(async () => {
238
+ if (shouldRun()) await start()
239
+ else await stop()
240
+ })
241
+ return queue
242
+ }
243
+
244
+ const configure = async ({ enabled, mode, hostname, token }) => {
245
+ if (!supported) throw new TypeError('Cloudflare Tunnel is available on PC only')
246
+ if (typeof enabled !== 'boolean') throw new TypeError('enabled must be a boolean')
247
+ const next = {
248
+ version: 1,
249
+ enabled,
250
+ mode: mode === undefined ? settings.mode : mode,
251
+ hostname: hostname === undefined ? settings.hostname : hostnameOf(hostname),
252
+ token: token === undefined || token === '' ? settings.token : tokenOf(token),
253
+ }
254
+ if (!['named', 'quick'].includes(next.mode)) throw new TypeError('mode must be named or quick')
255
+ if (enabled && next.mode === 'named' && (!next.hostname || !next.token)) {
256
+ throw new TypeError('hostname and Tunnel Token are required')
257
+ }
258
+ const changed = next.mode !== settings.mode || next.hostname !== settings.hostname || next.token !== settings.token
259
+ writeState(file, next)
260
+ settings = next
261
+ if (changed) await stop()
262
+ await reconcile()
263
+ return snapshot()
264
+ }
265
+
266
+ const restart = () => {
267
+ queue = queue.catch(() => {}).then(async () => {
268
+ if (!shouldRun()) {
269
+ const cause = new Error('请先开启移动网关和 Cloudflare Tunnel')
270
+ cause.status = 409
271
+ throw cause
272
+ }
273
+ await stop()
274
+ await start()
275
+ return snapshot()
276
+ })
277
+ return queue
278
+ }
279
+
280
+ const dispose = () => { disposed = true; void stop() }
281
+ return { snapshot, configure, reconcile, restart, dispose }
282
+ }
@@ -0,0 +1,114 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import crypto from 'node:crypto'
4
+ import { createReadStream } from 'node:fs'
5
+ import { execFile } from 'node:child_process'
6
+ import { promisify } from 'node:util'
7
+
8
+ const execFileAsync = promisify(execFile)
9
+ const CLOUDFLARED_VERSION = '2026.9.3'
10
+ const MAX_RELEASE_BYTES = 80 * 1024 * 1024
11
+ // GitHub's asset digests for the pinned Cloudflare release. The release API may
12
+ // return 403 even when the public release assets remain available.
13
+ const RELEASE_DIGESTS = Object.freeze({
14
+ 'cloudflared-darwin-amd64.tgz': 'd1155d0837487f261183b15c1eab6c4ebcad9dc49b94675f1524c3564cea3977',
15
+ 'cloudflared-darwin-arm64.tgz': '587c2cfb1c230fe36c7fa7727da78be459dae028cabe8c001291999350f07095',
16
+ 'cloudflared-windows-386.exe': '9b95ddc2eba67b86ed3dc4cc2a15881960563031b52ce564376af41fb91ad402',
17
+ 'cloudflared-windows-amd64.exe': 'f096265ec2fcbe9bb6e2d64268db167ced3fcbb83d894bdb9e2fcdb26f2ea7e2',
18
+ })
19
+ const BINARY_DIGESTS = Object.freeze({
20
+ 'cloudflared-darwin-amd64.tgz': 'ab588b3b4db9cdb4476c30a3db2a72635b1d8327d44741fee6799a0f37b0ec07',
21
+ 'cloudflared-darwin-arm64.tgz': '5472c1a01c84bc31b3021056a73b4e5774ddddefc572124ea8fdf6c340639f32',
22
+ 'cloudflared-windows-386.exe': RELEASE_DIGESTS['cloudflared-windows-386.exe'],
23
+ 'cloudflared-windows-amd64.exe': RELEASE_DIGESTS['cloudflared-windows-amd64.exe'],
24
+ })
25
+
26
+ export function cloudflaredAsset(platform = process.platform, arch = process.arch) {
27
+ if (platform === 'darwin' && (arch === 'arm64' || arch === 'x64')) {
28
+ return `cloudflared-darwin-${arch === 'x64' ? 'amd64' : 'arm64'}.tgz`
29
+ }
30
+ if (platform === 'win32' && arch === 'x64') return 'cloudflared-windows-amd64.exe'
31
+ if (platform === 'win32' && arch === 'ia32') return 'cloudflared-windows-386.exe'
32
+ throw new Error(`unsupported Cloudflare PC architecture: ${platform}/${arch}`)
33
+ }
34
+
35
+ async function readBounded(response, limit) {
36
+ if (!response.ok) throw new Error(`Cloudflare binary download returned HTTP ${response.status} from GitHub Releases`)
37
+ const length = Number(response.headers.get('content-length'))
38
+ if (length > limit) throw new Error('Cloudflare binary download exceeds the size limit')
39
+ const chunks = []
40
+ let size = 0
41
+ for await (const chunk of response.body) {
42
+ size += chunk.length
43
+ if (size > limit) throw new Error('Cloudflare binary download exceeds the size limit')
44
+ chunks.push(Buffer.from(chunk))
45
+ }
46
+ return Buffer.concat(chunks, size)
47
+ }
48
+
49
+ async function regularExecutable(file) {
50
+ try { return (await fs.lstat(file)).isFile() } catch { return false }
51
+ }
52
+
53
+ async function sha256File(file) {
54
+ const hash = crypto.createHash('sha256')
55
+ for await (const chunk of createReadStream(file)) hash.update(chunk)
56
+ return hash.digest('hex')
57
+ }
58
+
59
+ // The default executable stays in DSH's private cache. External binaries are
60
+ // used only when an explicit path is configured.
61
+ export async function ensureCloudflared({ cacheDir, executable = 'cloudflared',
62
+ platform = process.platform, arch = process.arch, fetchImpl = fetch,
63
+ releaseDigests = RELEASE_DIGESTS, binaryDigests = BINARY_DIGESTS } = {}) {
64
+ if (executable !== 'cloudflared') return executable
65
+ const assetName = cloudflaredAsset(platform, arch)
66
+ const destination = path.join(cacheDir, `${CLOUDFLARED_VERSION}-${platform}-${arch}${platform === 'win32' ? '.exe' : ''}`)
67
+ await fs.mkdir(cacheDir, { recursive: true, mode: 0o700 })
68
+ await fs.chmod(cacheDir, 0o700)
69
+ const expectedDigest = releaseDigests[assetName]
70
+ const expectedBinaryDigest = binaryDigests[assetName]
71
+ if (!/^[0-9a-f]{64}$/.test(expectedDigest || '') ||
72
+ !/^[0-9a-f]{64}$/.test(expectedBinaryDigest || '')) {
73
+ throw new Error('Cloudflare release has no verifiable binary for this PC')
74
+ }
75
+ if (await regularExecutable(destination)) {
76
+ if (await sha256File(destination) === expectedBinaryDigest) {
77
+ await fs.chmod(destination, 0o700)
78
+ return destination
79
+ }
80
+ await fs.rm(destination)
81
+ }
82
+
83
+ const expectedUrl = `https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/${assetName}`
84
+ const response = await fetchImpl(expectedUrl, {
85
+ headers: { 'User-Agent': 'dsh-mobile-gateway' },
86
+ signal: AbortSignal.timeout(120_000),
87
+ })
88
+ const archive = await readBounded(response, MAX_RELEASE_BYTES)
89
+ const actual = crypto.createHash('sha256').update(archive).digest('hex')
90
+ if (actual !== expectedDigest) {
91
+ throw new Error('Cloudflare binary checksum mismatch')
92
+ }
93
+
94
+ const stage = await fs.mkdtemp(path.join(cacheDir, '.cloudflared-'))
95
+ try {
96
+ let source
97
+ if (assetName.endsWith('.tgz')) {
98
+ const archiveFile = path.join(stage, assetName)
99
+ await fs.writeFile(archiveFile, archive, { mode: 0o600 })
100
+ await execFileAsync('tar', ['-xzf', archiveFile, '-C', stage], { timeout: 30_000 })
101
+ source = path.join(stage, 'cloudflared')
102
+ if (!await regularExecutable(source)) throw new Error('Cloudflare archive contains no cloudflared executable')
103
+ } else {
104
+ source = path.join(stage, 'cloudflared.exe')
105
+ await fs.writeFile(source, archive, { mode: 0o700 })
106
+ }
107
+ if (await sha256File(source) !== expectedBinaryDigest) throw new Error('Cloudflare executable checksum mismatch')
108
+ await fs.chmod(source, 0o700)
109
+ if (!await regularExecutable(destination)) await fs.rename(source, destination)
110
+ return destination
111
+ } finally {
112
+ await fs.rm(stage, { recursive: true, force: true })
113
+ }
114
+ }
@@ -13,13 +13,13 @@ function requireArray(value, endpoint) {
13
13
  return value
14
14
  }
15
15
 
16
- export const DSH_VERSION = '0.1.5-rc.2'
17
- export const SESSION_FORMAT_VERSION = 3
16
+ export const DSH_VERSION = '0.2.0-rc.2'
17
+ export const SESSION_FORMAT_VERSION = 4
18
18
 
19
19
  export function readHistoryRecords(records) {
20
20
  return requireArray(records, 'session history').map(record => {
21
21
  const value = requireRecord(record, 'session history record')
22
- if (value.type !== 'event') throw new Error('DSH 0.1.5-rc.2 history requires event records')
22
+ if (value.type !== 'event') throw new Error('DSH Session V4 history requires event records')
23
23
  const event = requireRecord(value.event, 'session history event')
24
24
  if (!Number.isSafeInteger(event.seq) || event.seq < 0) throw new Error('invalid history sequence')
25
25
  return event
@@ -31,7 +31,7 @@ export function readSessionSnapshot(frame, sessionId) {
31
31
  if (frame.type !== 'snapshot' || !Number.isSafeInteger(frame.cursor) || frame.cursor < -1
32
32
  || frame.header?.id !== sessionId) throw new Error('session/follow returned an invalid snapshot')
33
33
  if (frame.header.version !== SESSION_FORMAT_VERSION) {
34
- throw Object.assign(new Error('mobile-gateway requires DSH 0.1.5-rc.2 Session format 3'), {
34
+ throw Object.assign(new Error(`mobile-gateway requires Session format ${SESSION_FORMAT_VERSION}; received ${frame.header.version}`), {
35
35
  code: 'unsupported-session-format',
36
36
  })
37
37
  }
@@ -66,7 +66,7 @@ function requestArgs(request) {
66
66
  return { request }
67
67
  }
68
68
 
69
- // The 0.1.5-rc.2 SessionController names its reserved list argument
69
+ // The SessionController names its reserved list argument
70
70
  // `_request`. Typert descriptors preserve that source parameter name exactly,
71
71
  // so this endpoint cannot share the normal `{ request }` wrapper.
72
72
  function sessionListArgs(request) {
@@ -160,7 +160,7 @@ export function createDshHostAdapter(typertGateway) {
160
160
 
161
161
  const commands = {
162
162
  list: (sessionId, signal) => invoke('commands', 'list', { agentId: sessionId }, signal),
163
- // DSH 0.1.5-rc.2 accepts `submittedAttachments`. The parsed
163
+ // DSH accepts `submittedAttachments`. The parsed
164
164
  // wire images already carry the { type: 'image', ... } shape it expects, so
165
165
  // only the field name changes.
166
166
  execute: (sessionId, line, attachments, signal) => invoke(
@@ -247,12 +247,53 @@ export function createDshHostAdapter(typertGateway) {
247
247
  },
248
248
  settings: {
249
249
  describe: (_payload = {}, signal) => invoke('settings', 'describe', {}, signal),
250
+ mutate: (payload, signal) => invoke('settings', 'mutate', {
251
+ ns: payload.ns,
252
+ ops: payload.ops,
253
+ expectedRevision: payload.expectedRevision,
254
+ }, signal),
250
255
  update: (payload, signal) => invoke('settings', 'update', {
251
256
  ns: payload.ns,
252
257
  patch: payload.patch,
253
258
  ...(payload.expectedRevision === undefined ? {} : { expectedRevision: payload.expectedRevision }),
254
259
  }, signal),
255
260
  },
261
+ plugins: {
262
+ inventory: (_payload = {}, signal) => invoke('pluginInventory', 'list', {}, signal),
263
+ listBundles: (signal) => invoke('pluginManager', 'listBundles', {}, signal),
264
+ listPlugins: (signal) => invoke('pluginManager', 'listPlugins', {}, signal),
265
+ registries: (signal) => invoke('pluginManager', 'registries', {}, signal),
266
+ inspect: (spec, options, signal) => invoke('pluginManager', 'inspect', {
267
+ spec, ...(options === undefined ? {} : { options }),
268
+ }, signal),
269
+ installBundle: (spec, options) => invoke('pluginManager', 'installBundle', { spec, options }),
270
+ waitForInstall: (requestId) => invoke('pluginManager', 'waitForInstall', { requestId }),
271
+ cancelInstall: (requestId) => invoke('pluginManager', 'cancelInstall', { requestId }),
272
+ setBundleEnabled: (name, enabled) => invoke('pluginManager', 'setBundleEnabled', { name, enabled }),
273
+ setPluginEnabled: (id, enabled) => invoke('pluginManager', 'setPluginEnabled', { id, enabled }),
274
+ removeBundle: (name) => invoke('pluginManager', 'removeBundle', { name }),
275
+ listVersionExemptions: () => invoke('pluginManager', 'listVersionExemptions', {}),
276
+ setVersionExemption: (packageVersion, runtimeVersion, enabled, acceptRisk) => invoke(
277
+ 'pluginManager', 'setVersionExemption', {
278
+ packageVersion, runtimeVersion, enabled,
279
+ ...(acceptRisk === undefined ? {} : { acceptRisk }),
280
+ },
281
+ ),
282
+ },
283
+ permissionPresets: {
284
+ catalog: (_payload = {}, signal) => invoke('permissionPresets', 'catalog', {}, signal),
285
+ },
286
+ schedule: {
287
+ async catalog(_payload = {}, signal) {
288
+ return { items: requireArray(await invoke('schedule', 'catalog', {}, signal), 'schedule/catalog') }
289
+ },
290
+ async list(payload, signal) {
291
+ return { items: requireArray(await invoke('schedule', 'list', requestArgs(payload), signal), 'schedule/list') }
292
+ },
293
+ history: (payload, signal) => invoke('schedule', 'history', requestArgs(payload), signal),
294
+ update: (payload, signal) => invoke('schedule', 'update', requestArgs(payload), signal),
295
+ delete: (payload, signal) => invoke('schedule', 'delete', requestArgs(payload), signal),
296
+ },
256
297
  skills: {
257
298
  list: (payload, signal) => invoke('skills', 'list', requestArgs(payload), signal),
258
299
  },
@@ -0,0 +1,36 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { realpathSync } from 'node:fs'
3
+ import { fileURLToPath } from 'node:url'
4
+
5
+ export function webRestartAvailable(profile, runtime = process) {
6
+ if (profile?.name !== 'web' || !runtime.argv?.[1]) return false
7
+ try {
8
+ return /[/\\]@deepseek-ai[/\\]dsh[/\\]lib[/\\]bin\.js$/.test(realpathSync(runtime.argv[1]))
9
+ } catch {
10
+ return false
11
+ }
12
+ }
13
+
14
+ export async function prepareWebRestart(runtime = process, spawnChild = spawn) {
15
+ const argv = runtime.argv.slice(1)
16
+ // DSH Web opens a browser on every boot by default. This restart was
17
+ // initiated from an existing tab, so keep that tab and suppress the handoff.
18
+ const terminator = argv.indexOf('--')
19
+ const flagEnd = terminator < 0 ? argv.length : terminator
20
+ if (!argv.slice(0, flagEnd).includes('--no-open')) argv.splice(flagEnd, 0, '--no-open')
21
+ const payload = Buffer.from(JSON.stringify({
22
+ pid: runtime.pid,
23
+ execPath: runtime.execPath,
24
+ execArgv: runtime.execArgv,
25
+ argv,
26
+ cwd: runtime.cwd(),
27
+ })).toString('base64url')
28
+ const helper = spawnChild(runtime.execPath, [fileURLToPath(new URL('./web-restart.mjs', import.meta.url)), payload], {
29
+ cwd: runtime.cwd(), env: runtime.env, detached: true, stdio: 'ignore',
30
+ })
31
+ await new Promise((resolve, reject) => {
32
+ helper.once('spawn', resolve)
33
+ helper.once('error', reject)
34
+ })
35
+ helper.unref()
36
+ }