dsh-clean-desktop-shell 0.1.11 → 0.1.13

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/lib/client.js CHANGED
@@ -1,11 +1,21 @@
1
1
  /**
2
2
  * dsh-clean-desktop-shell — client half (web browser bundle).
3
3
  *
4
- * The shell is a standalone Electron window; it injects nothing into the
5
- * dsh web UI. This client module exists only to satisfy the client-modules
6
- * loader contract a bundle's client entry must register itself via
7
- * `window.__ModuleLoader__.load({ id, factory })`, otherwise dsh reports
8
- * "loaded without registering … via __ModuleLoader__.load".
4
+ * Bridges the page's DSH client-runtime connection lifecycle into the
5
+ * Electron shell so HTTP health alone can never hide a terminal disconnect
6
+ * (e.g. the backend restarted under the page: HTTP answers again while the
7
+ * page's WebSocket generation is dead).
8
+ *
9
+ * - ctx.connection.state ('connected' | 'connecting' | 'disconnected') is
10
+ * forwarded to the main process (shell:client-connection → preload
11
+ * shellAPI.connectionReport);
12
+ * - main-process reconnect requests (shell:client-reconnect →
13
+ * shellAPI.onReconnectRequest) call ctx.connection.reconnect() —
14
+ * recovery through the app's own reconnect loop, never a reload.
15
+ *
16
+ * The supported baseline provides ctx.connection.state / reconnect();
17
+ * missing APIs fail activation loudly instead of degrading. A plain browser
18
+ * (no shellAPI — no Electron shell) is the only silent case.
9
19
  */
10
20
  window.__ModuleLoader__.load({
11
21
  id: 'dsh-clean-desktop-shell',
@@ -13,7 +23,32 @@ window.__ModuleLoader__.load({
13
23
  var module = { exports: {} };
14
24
  var exports = module.exports;
15
25
  Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
16
- exports.apply = () => {};
26
+ exports.inject = ['connection'];
27
+ exports.apply = function (ctx) {
28
+ var api = window.shellAPI;
29
+ if (!api) return;
30
+ var connection = ctx.connection;
31
+ var latest = null;
32
+ var notify = function () {
33
+ var state = connection.state.getSnapshot();
34
+ if (state === latest) return;
35
+ latest = state;
36
+ if (state === 'connected' || state === 'connecting' || state === 'disconnected') {
37
+ api.connectionReport(state);
38
+ }
39
+ };
40
+ var unsubscribe = connection.state.subscribe(notify);
41
+ notify(); // report whatever the runtime already is — a lost initial disconnect must not be hidden
42
+ var unregisterRequest = api.onReconnectRequest(function () {
43
+ connection.reconnect();
44
+ });
45
+ ctx.effect(function () {
46
+ return function () {
47
+ unsubscribe();
48
+ unregisterRequest();
49
+ };
50
+ });
51
+ };
17
52
  return module.exports;
18
53
  },
19
54
  });
package/lib/common.js CHANGED
@@ -1,74 +1,74 @@
1
- /**
2
- * Shared constants and helpers for the host half modules.
3
- *
4
- * These compiled files sit at lib/<name>.js, so two dirname hops reach the
5
- * package root — the same layout as src/host/ before build, and the same
6
- * location electron/ and build/ live in the published package.
7
- */
8
- import { homedir } from 'node:os'
9
- import { spawn } from 'node:child_process'
10
- import { dirname, join } from 'node:path'
11
- import { fileURLToPath } from 'node:url'
12
-
13
- export const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
14
- export const MAIN_JS = join(PKG_ROOT, 'electron', 'main.js')
15
- export const isWin = process.platform === 'win32'
16
- export const isMac = process.platform === 'darwin'
17
- export const ARCH = process.arch === 'arm64' ? 'arm64' : 'x64'
18
- export const PLATFORM = isWin ? 'win32' : isMac ? 'darwin' : 'linux'
19
-
20
- /**
21
- * Electron binary path *relative to the extracted runtime dir*.
22
- *
23
- * The three upstream archives do not share a layout — only darwin ships an
24
- * app bundle, and it is the one case with no top-level executable:
25
- * win32 → electron.exe
26
- * linux → electron
27
- * darwin → Electron.app/Contents/MacOS/Electron
28
- *
29
- * Authoritative source: the `electron` package's own install.js, which writes
30
- * exactly this relative path into path.txt for `require('electron')`.
31
- */
32
- export const EXE_RELPATH = isWin
33
- ? 'electron.exe'
34
- : isMac
35
- ? join('Electron.app', 'Contents', 'MacOS', 'Electron')
36
- : 'electron'
37
-
38
- /** First path segment of EXE_RELPATH — what a successful extract must leave behind. */
39
- export const EXE_TOP = isWin ? 'electron.exe' : isMac ? 'Electron.app' : 'electron'
40
-
41
- /** DSH home, honouring DSH_HOME the same way dsh-home-paths does. */
42
- export function dshHome() {
43
- return process.env.DSH_HOME || join(homedir(), '.dsh')
44
- }
45
-
46
- /** Where the self-provisioned runtimes live (shared with icon.js). */
47
- export function runtimeRoot() {
48
- return join(dshHome(), 'desktop-shell-runtime')
49
- }
50
-
51
- /** Launch diagnostics land here — the only thing a headless user can send us. */
52
- export function launchLogPath() {
53
- return join(dshHome(), 'desktop-shell-launch.log')
54
- }
55
-
56
- /**
57
- * Download a file to disk via curl — shared by runtime provisioning and
58
- * icon patching. Chosen over native fetch because undici (Node's fetch)
59
- * ignores HTTP(S)_PROXY env vars unless a proxy dispatcher is wired in,
60
- * while curl honors them out of the box (users behind Clash/v2ray rely on
61
- * that). --max-time keeps a stalled proxy from hanging forever; --retry
62
- * rides out transient failures.
63
- */
64
- export function fetchFile(url, dest, timeoutSec = 600) {
65
- return new Promise((resolve) => {
66
- const child = spawn(
67
- 'curl',
68
- ['-L', '--fail', '--silent', '--show-error', '--retry', '2', '--max-time', String(timeoutSec), '-o', dest, url],
69
- { windowsHide: true, stdio: 'ignore' },
70
- )
71
- child.on('error', () => resolve(false))
72
- child.on('exit', (code) => resolve(code === 0))
73
- })
74
- }
1
+ /**
2
+ * Shared constants and helpers for the host half modules.
3
+ *
4
+ * These compiled files sit at lib/<name>.js, so two dirname hops reach the
5
+ * package root — the same layout as src/host/ before build, and the same
6
+ * location electron/ and build/ live in the published package.
7
+ */
8
+ import { homedir } from 'node:os'
9
+ import { spawn } from 'node:child_process'
10
+ import { dirname, join } from 'node:path'
11
+ import { fileURLToPath } from 'node:url'
12
+
13
+ export const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
14
+ export const MAIN_JS = join(PKG_ROOT, 'electron', 'main.js')
15
+ export const isWin = process.platform === 'win32'
16
+ export const isMac = process.platform === 'darwin'
17
+ export const ARCH = process.arch === 'arm64' ? 'arm64' : 'x64'
18
+ export const PLATFORM = isWin ? 'win32' : isMac ? 'darwin' : 'linux'
19
+
20
+ /**
21
+ * Electron binary path *relative to the extracted runtime dir*.
22
+ *
23
+ * The three upstream archives do not share a layout — only darwin ships an
24
+ * app bundle, and it is the one case with no top-level executable:
25
+ * win32 → electron.exe
26
+ * linux → electron
27
+ * darwin → Electron.app/Contents/MacOS/Electron
28
+ *
29
+ * Authoritative source: the `electron` package's own install.js, which writes
30
+ * exactly this relative path into path.txt for `require('electron')`.
31
+ */
32
+ export const EXE_RELPATH = isWin
33
+ ? 'electron.exe'
34
+ : isMac
35
+ ? join('Electron.app', 'Contents', 'MacOS', 'Electron')
36
+ : 'electron'
37
+
38
+ /** First path segment of EXE_RELPATH — what a successful extract must leave behind. */
39
+ export const EXE_TOP = isWin ? 'electron.exe' : isMac ? 'Electron.app' : 'electron'
40
+
41
+ /** DSH home, honouring DSH_HOME the same way dsh-home-paths does. */
42
+ export function dshHome() {
43
+ return process.env.DSH_HOME || join(homedir(), '.dsh')
44
+ }
45
+
46
+ /** Where the self-provisioned runtimes live (shared with icon.js). */
47
+ export function runtimeRoot() {
48
+ return join(dshHome(), 'desktop-shell-runtime')
49
+ }
50
+
51
+ /** Launch diagnostics land here — the only thing a headless user can send us. */
52
+ export function launchLogPath() {
53
+ return join(dshHome(), 'desktop-shell-launch.log')
54
+ }
55
+
56
+ /**
57
+ * Download a file to disk via curl — shared by runtime provisioning and
58
+ * icon patching. Chosen over native fetch because undici (Node's fetch)
59
+ * ignores HTTP(S)_PROXY env vars unless a proxy dispatcher is wired in,
60
+ * while curl honors them out of the box (users behind Clash/v2ray rely on
61
+ * that). --max-time keeps a stalled proxy from hanging forever; --retry
62
+ * rides out transient failures.
63
+ */
64
+ export function fetchFile(url, dest, timeoutSec = 600) {
65
+ return new Promise((resolve) => {
66
+ const child = spawn(
67
+ 'curl',
68
+ ['-L', '--fail', '--silent', '--show-error', '--retry', '2', '--max-time', String(timeoutSec), '-o', dest, url],
69
+ { windowsHide: true, stdio: 'ignore' },
70
+ )
71
+ child.on('error', () => resolve(false))
72
+ child.on('exit', (code) => resolve(code === 0))
73
+ })
74
+ }
package/lib/icon.js CHANGED
@@ -1,51 +1,85 @@
1
- /**
2
- * Patch the runtime electron.exe's icon resource so the Windows taskbar
3
- * shows our whale icon.
4
- *
5
- * A bare runtime exe ships Electron's default icon and — as documented —
6
- * no runtime API (BrowserWindow icon, setAppDetails, AUMID shortcuts) can
7
- * change the taskbar button: it reads the exe's icon resource. rcedit
8
- * (electron team's official tool) rewrites it in place.
9
- *
10
- * Best-effort: icon patching must never block the shell from launching.
11
- * Idempotent: a marker file next to the exe records success; a re-provisioned
12
- * (new version) exe has no marker and gets patched again.
13
- */
14
- import { spawn } from 'node:child_process'
15
- import { existsSync, writeFileSync } from 'node:fs'
16
- import { join } from 'node:path'
17
- import { PKG_ROOT, isWin, runtimeRoot, fetchFile } from './common.js'
18
-
19
- export async function patchExeIcon(ctx, exe) {
20
- if (!isWin) return
21
- const ico = join(PKG_ROOT, 'build', 'icon.ico')
22
- if (!existsSync(ico)) return
23
- const marker = `${exe}.whale-icon`
24
- if (existsSync(marker)) return
25
-
26
- // rcedit is a single self-contained exe, cached next to the runtimes.
27
- const rcedit = join(runtimeRoot(), 'rcedit-x64.exe')
28
- if (!existsSync(rcedit)) {
29
- const url = 'https://github.com/electron/rcedit/releases/download/v2.0.0/rcedit-x64.exe'
30
- ctx.logger.info('[clean-desktop-shell] downloading rcedit for icon patching')
31
- if (!(await fetchFile(url, rcedit))) {
32
- ctx.logger.warn('[clean-desktop-shell] rcedit download failed — taskbar icon stays default')
33
- return
34
- }
35
- }
36
-
37
- const child = spawn(rcedit, [exe, '--set-icon', ico], {
38
- windowsHide: true,
39
- stdio: 'ignore',
40
- })
41
- const ok = await new Promise((resolve) => {
42
- child.on('error', () => resolve(false))
43
- child.on('exit', (code) => resolve(code === 0))
44
- })
45
- if (ok) {
46
- writeFileSync(marker, String(Date.now()), 'utf8')
47
- ctx.logger.info('[clean-desktop-shell] taskbar icon patched (rcedit)')
48
- } else {
49
- ctx.logger.warn('[clean-desktop-shell] rcedit patch failed — taskbar icon stays default')
50
- }
51
- }
1
+ /**
2
+ * Patch the runtime electron.exe's icon resource so the Windows taskbar
3
+ * shows our whale icon.
4
+ *
5
+ * A bare runtime exe ships Electron's default icon and — as documented —
6
+ * no runtime API (BrowserWindow icon, setAppDetails, AUMID shortcuts) can
7
+ * change the taskbar button: it reads the exe's icon resource. rcedit
8
+ * (electron team's official tool) rewrites it in place.
9
+ *
10
+ * Best-effort: icon patching must never block the shell from launching.
11
+ * Idempotent: a marker file next to the exe records success; a re-provisioned
12
+ * (new version) exe has no marker and gets patched again.
13
+ */
14
+ import { spawn } from 'node:child_process'
15
+ import { existsSync, writeFileSync } from 'node:fs'
16
+ import { join } from 'node:path'
17
+ import { PKG_ROOT, isWin, runtimeRoot, fetchFile } from './common.js'
18
+
19
+ const RCEDIT_NAME = 'rcedit-x64.exe'
20
+ const RCEDIT_URL = 'https://github.com/electron/rcedit/releases/download/v2.0.0/rcedit-x64.exe'
21
+
22
+ /**
23
+ * rcedit is a ~1.3 MB self-contained exe, and its download sits on the launch
24
+ * path (Windows locks a running image, so the patch has to happen before the
25
+ * exe is spawned). fetchFile's default budget — 600 s — is sized for the
26
+ * ~100 MB Electron runtime, not for this: behind a stalled proxy it could keep
27
+ * the window off screen for ten minutes. Cap it well below that; a miss only
28
+ * costs the default icon.
29
+ */
30
+ const RCEDIT_TIMEOUT_SEC = 20
31
+
32
+ let rceditPromise = null
33
+
34
+ /**
35
+ * Fetch (once) the cached rcedit binary next to the runtimes. Memoised so the
36
+ * host half can start this alongside the Electron runtime download and have
37
+ * the icon step reuse the same fetch instead of serialising behind it.
38
+ *
39
+ * Never rejects: every failure mode just means "no rcedit, default icon".
40
+ */
41
+ export function ensureRcedit(ctx) {
42
+ if (!rceditPromise) {
43
+ rceditPromise = (async () => {
44
+ try {
45
+ const rcedit = join(runtimeRoot(), RCEDIT_NAME)
46
+ if (existsSync(rcedit)) return rcedit
47
+ ctx.logger.info('[clean-desktop-shell] downloading rcedit for icon patching')
48
+ if (await fetchFile(RCEDIT_URL, rcedit, RCEDIT_TIMEOUT_SEC)) return rcedit
49
+ ctx.logger.warn('[clean-desktop-shell] rcedit download failed — taskbar icon stays default')
50
+ } catch (err) {
51
+ ctx.logger.warn(`[clean-desktop-shell] rcedit unavailable (${err?.message ?? err}) — taskbar icon stays default`)
52
+ }
53
+ // Not memoised as a failure: a later launch deserves a fresh attempt.
54
+ rceditPromise = null
55
+ return null
56
+ })()
57
+ }
58
+ return rceditPromise
59
+ }
60
+
61
+ export async function patchExeIcon(ctx, exe) {
62
+ if (!isWin) return
63
+ const ico = join(PKG_ROOT, 'build', 'icon.ico')
64
+ if (!existsSync(ico)) return
65
+ const marker = `${exe}.whale-icon`
66
+ if (existsSync(marker)) return
67
+
68
+ const rcedit = await ensureRcedit(ctx)
69
+ if (!rcedit) return
70
+
71
+ const child = spawn(rcedit, [exe, '--set-icon', ico], {
72
+ windowsHide: true,
73
+ stdio: 'ignore',
74
+ })
75
+ const ok = await new Promise((resolve) => {
76
+ child.on('error', () => resolve(false))
77
+ child.on('exit', (code) => resolve(code === 0))
78
+ })
79
+ if (ok) {
80
+ writeFileSync(marker, String(Date.now()), 'utf8')
81
+ ctx.logger.info('[clean-desktop-shell] taskbar icon patched (rcedit)')
82
+ } else {
83
+ ctx.logger.warn('[clean-desktop-shell] rcedit patch failed — taskbar icon stays default')
84
+ }
85
+ }
package/lib/index.js CHANGED
@@ -12,7 +12,7 @@ import { spawn } from 'node:child_process'
12
12
  import { appendFileSync } from 'node:fs'
13
13
  import { PKG_ROOT, MAIN_JS, dshHome, runtimeRoot, launchLogPath } from './common.js'
14
14
  import { ensureRuntime } from './runtime.js'
15
- import { patchExeIcon } from './icon.js'
15
+ import { ensureRcedit, patchExeIcon } from './icon.js'
16
16
 
17
17
  // cordis registers the plugin by this name — bundles without an explicit
18
18
  // `name` export are silently skipped by the dsh loader.
@@ -21,25 +21,98 @@ export const name = 'dsh-clean-desktop-shell'
21
21
  // Disable auto-launch with DSH_SHELL_AUTO_LAUNCH=0.
22
22
  const AUTO_LAUNCH = process.env.DSH_SHELL_AUTO_LAUNCH !== '0'
23
23
 
24
+ /**
25
+ * Hard budget for the taskbar-icon step. It runs before the spawn (Windows
26
+ * locks a running image), so it is on the launch path — but the icon is
27
+ * cosmetic and must never decide when the window appears. Without a budget a
28
+ * stalled rcedit fetch held the first launch for up to the fetchFile default
29
+ * of 600 s; with it, the worst case is 25 s and the usual case is ~0 (the
30
+ * fetch has already finished behind the Electron runtime download).
31
+ */
32
+ const ICON_PATCH_DEADLINE_MS = 25000
33
+
24
34
  let launched = false
25
35
 
36
+ /**
37
+ * Let a best-effort step run, but never wait longer than `ms` for it. Whatever
38
+ * it was doing keeps running in the background.
39
+ */
40
+ function withDeadline(ctx, promise, ms) {
41
+ let timer
42
+ const deadline = new Promise((resolve) => {
43
+ timer = setTimeout(() => {
44
+ ctx.logger.warn(`[clean-desktop-shell] icon step exceeded ${ms} ms — launching without it`)
45
+ resolve()
46
+ }, ms)
47
+ // Do not keep the host process alive just to fire a warning.
48
+ timer.unref?.()
49
+ })
50
+ const settled = promise.then(() => {}, () => {})
51
+ return Promise.race([settled, deadline]).finally(() => clearTimeout(timer))
52
+ }
53
+
26
54
  export function apply(ctx) {
27
55
  ctx.logger.info('[clean-desktop-shell] mounted (host half)')
28
56
  if (!AUTO_LAUNCH) return
29
- // The dsh bundle loader calls apply() during early boot, but the cordis
30
- // 'ready' event never fires for bundle plugins (dshmarket / agent-teams
31
- // use ctx.inject or run inline instead). Launch directly: the shell is a
32
- // separate process with its own offline screen + auto-reconnect, so an
33
- // early launch is safe it shows the offline page until 3080 answers.
34
- ;(async () => {
35
- try {
36
- const exe = await ensureRuntime(ctx)
37
- await patchExeIcon(ctx, exe).catch(() => {})
38
- launchShell(exe, ctx)
39
- } catch (err) {
57
+
58
+ // Runtime preparation is independent of authentication start it now, but
59
+ // defer the Electron spawn until the launch URL is minted below.
60
+ let runtimeExe = null
61
+ // Kick the (tiny) rcedit fetch off alongside the (large) Electron runtime so
62
+ // the two never serialise; patchExeIcon reuses this same memoised promise.
63
+ void ensureRcedit(ctx)
64
+ const runtimeReady = ensureRuntime(ctx)
65
+ .then(async (exe) => {
66
+ runtimeExe = exe
67
+ await withDeadline(ctx, patchExeIcon(ctx, exe), ICON_PATCH_DEADLINE_MS)
68
+ })
69
+ .catch((err) => {
40
70
  reportLaunchFailure(ctx, err)
71
+ })
72
+
73
+ // DSH 0.1.2 BrowserAuth: the shell must not start on bare "/" (401). It
74
+ // needs this process's launch URL (?token=…), which the Connection service
75
+ // alone can mint. The service is only reachable from a deferred inject
76
+ // scope — reading ctx.connection in apply() throws "cannot get property
77
+ // without inject" — and the URL is a readiness signal: mint it only after
78
+ // the Loader tree settles, because the port answers 4xx before settlement.
79
+ // Mirror @deepseek-ai/dsh-web-app's announce pattern.
80
+ ctx.inject(['connection'], (connectionCtx) => {
81
+ const launch = () => {
82
+ if (launched) return
83
+ // Only injected services are directly reachable in this scope; the
84
+ // webServer sibling is read through get() (see web-app: it may declare
85
+ // webServer in its row inject, this host does not).
86
+ const webServer = connectionCtx.get('webServer')
87
+ if (!webServer) return
88
+ try {
89
+ const webUrl = `http://127.0.0.1:${String(webServer.port)}`
90
+ // dsh 0.1.2+ mints this process's launch URL through BrowserAuth.
91
+ // Older versions have no BrowserAuth at all — they serve the web root
92
+ // unauthenticated — so a missing mint must NOT stop the launch: the
93
+ // shell just starts with no bootstrap URL, which is exactly the
94
+ // pre-0.1.2 behaviour.
95
+ const mint = connectionCtx.connection.authenticatedUrl
96
+ const launchUrl = typeof mint === 'function' ? mint.call(connectionCtx.connection, webUrl) : null
97
+ void runtimeReady.then(() => {
98
+ if (runtimeExe) launchShell(runtimeExe, connectionCtx, launchUrl)
99
+ })
100
+ } catch (err) {
101
+ reportLaunchFailure(connectionCtx, err)
102
+ }
103
+ }
104
+ // This row's own activation can precede a sibling failure. The tree owns
105
+ // readiness: await the Loader, then re-check that the services still
106
+ // exist — an early shutdown must not spawn a shell for a dead server.
107
+ const settled = connectionCtx.get('loader')?.await()
108
+ if (settled === undefined) launch()
109
+ else {
110
+ void settled.then(() => {
111
+ if (connectionCtx.get('webServer') !== undefined
112
+ && connectionCtx.get('connection') !== undefined) launch()
113
+ }, () => {})
41
114
  }
42
- })()
115
+ })
43
116
  }
44
117
 
45
118
  /**
@@ -81,11 +154,13 @@ function reportLaunchFailure(ctx, err) {
81
154
  }
82
155
  }
83
156
 
84
- function launchShell(exe, ctx) {
157
+ function launchShell(exe, ctx, launchUrl) {
85
158
  if (launched) return
159
+ const env = { ...process.env, ELECTRON_RUN_AS_NODE: undefined }
160
+ if (launchUrl) env.DSH_WEB_LAUNCH_URL = launchUrl
86
161
  const child = spawn(exe, [MAIN_JS], {
87
162
  cwd: PKG_ROOT,
88
- env: { ...process.env, ELECTRON_RUN_AS_NODE: undefined },
163
+ env,
89
164
  stdio: 'ignore',
90
165
  windowsHide: false,
91
166
  })