dsh-clean-desktop-shell 0.1.0 → 0.1.2

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.
@@ -1,74 +1,201 @@
1
- /**
2
- * Version check and repo links.
3
- *
4
- * Checks the latest GitHub release of dsh-clean-desktop-shell against the
5
- * local app version (from app.getVersion()). Network failures are treated
6
- * as "no update" never block or error in the tray.
7
- */
8
- import { app, shell } from 'electron'
9
-
10
- const REPO_URL = 'https://github.com/Icather/dsh-clean-desktop-shell'
11
- const RELEASES_API = 'https://api.github.com/repos/Icather/dsh-clean-desktop-shell/releases/latest'
12
-
13
- /** Parse "v1.2.3" / "1.2.3" [1,2,3]; null when malformed. */
14
- function parseVersion(v) {
15
- if (!v) return null
16
- const m = String(v).replace(/^v/i, '').trim().match(/^(\d+)\.(\d+)\.(\d+)/)
17
- return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null
18
- }
19
-
20
- /** True when a is strictly newer than b. */
21
- function isNewer(a, b) {
22
- if (!a || !b) return false
23
- for (let i = 0; i < 3; i++) {
24
- if (a[i] !== b[i]) return a[i] > b[i]
25
- }
26
- return false
27
- }
28
-
29
- /**
30
- * Check for a newer release.
31
- * @returns {{ hasUpdate: boolean, latest?: string, current: string, url: string }}
32
- */
33
- export async function checkForUpdate() {
34
- const current = app.getVersion()
35
- let latest = null
36
- let tag = null
37
- let url = REPO_URL
38
-
39
- try {
40
- const controller = new AbortController()
41
- const timer = setTimeout(() => controller.abort(), 8000)
42
- const res = await fetch(RELEASES_API, {
43
- signal: controller.signal,
44
- headers: { Accept: 'application/vnd.github+json' },
45
- })
46
- clearTimeout(timer)
47
- if (res.ok) {
48
- const data = await res.json()
49
- tag = data.tag_name || null
50
- latest = parseVersion(tag)
51
- if (data.html_url) url = data.html_url
52
- }
53
- } catch {
54
- // Network error — no update known.
55
- }
56
-
57
- const currentV = parseVersion(current)
58
- return {
59
- hasUpdate: isNewer(latest, currentV),
60
- latest: tag,
61
- current,
62
- url,
63
- }
64
- }
65
-
66
- /** Open the repository homepage in the default browser. */
67
- export function openRepo() {
68
- shell.openExternal(REPO_URL)
69
- }
70
-
71
- /** Open an arbitrary URL in the default browser. */
72
- export function openUrl(url) {
73
- shell.openExternal(url)
74
- }
1
+ /**
2
+ * Update handling Windows auto-update, macOS manual download.
3
+ *
4
+ * Windows (packaged app): uses electron-updater to download the new
5
+ * installer from GitHub Releases in the background and install it on
6
+ * restart. Progress is shown in the small progress window.
7
+ *
8
+ * macOS / dev mode: falls back to a manual check that opens the GitHub
9
+ * Releases page (macOS auto-update needs a Developer ID signature which
10
+ * this project does not have yet).
11
+ */
12
+ import { app, shell, dialog } from 'electron'
13
+ import { showProgress, setProgress, closeProgress } from './progress.js'
14
+
15
+ const REPO_URL = 'https://github.com/Icather/dsh-clean-desktop-shell'
16
+ const RELEASES_API = 'https://api.github.com/repos/Icather/dsh-clean-desktop-shell/releases/latest'
17
+
18
+ let autoUpdater = null
19
+ let updaterPromise = null
20
+
21
+ /** Lazy-load electron-updater (CJS → ESM interop via dynamic import). */
22
+ function getAutoUpdater() {
23
+ if (!updaterPromise) {
24
+ updaterPromise = import('electron-updater').then((m) => m.autoUpdater)
25
+ }
26
+ return updaterPromise
27
+ }
28
+
29
+ /** Windows packaged apps can auto-update; everything else uses manual. */
30
+ export function isAutoUpdateSupported() {
31
+ return process.platform === 'win32' && app.isPackaged
32
+ }
33
+
34
+ /** Wire the auto-updater events once (no-op on mac / dev mode). */
35
+ export function setupAutoUpdater() {
36
+ if (!isAutoUpdateSupported()) return
37
+ getAutoUpdater()
38
+ .then((au) => {
39
+ if (autoUpdater) return
40
+ autoUpdater = au
41
+ autoUpdater.autoDownload = true
42
+ autoUpdater.autoInstallOnAppQuit = true
43
+
44
+ autoUpdater.on('update-available', () => {
45
+ showProgress({ title: '检查更新', message: '发现新版本,正在后台下载…' })
46
+ })
47
+ autoUpdater.on('download-progress', (p) => {
48
+ const pct = Math.round(p.percent)
49
+ setProgress({
50
+ title: '检查更新',
51
+ message: `正在下载更新:${pct}%`,
52
+ state: pct >= 100 ? 'ok' : 'busy',
53
+ })
54
+ })
55
+ autoUpdater.on('update-downloaded', () => {
56
+ setProgress({ title: '检查更新', message: '更新已下载', state: 'ok' })
57
+ setTimeout(() => {
58
+ closeProgress()
59
+ const choice = dialog.showMessageBoxSync({
60
+ type: 'info',
61
+ title: '更新已就绪',
62
+ message: '新版本已下载完成,重启应用即可安装。',
63
+ detail: '是否立即重启安装?',
64
+ buttons: ['立即重启', '稍后'],
65
+ defaultId: 0,
66
+ cancelId: 1,
67
+ })
68
+ if (choice === 0) autoUpdater.quitAndInstall()
69
+ }, 800)
70
+ })
71
+ autoUpdater.on('update-not-available', () => {
72
+ closeProgress()
73
+ dialog.showMessageBoxSync({
74
+ type: 'info',
75
+ title: '已是最新版本',
76
+ message: '当前已是最新版本。',
77
+ })
78
+ })
79
+ autoUpdater.on('error', (err) => {
80
+ closeProgress()
81
+ dialog.showMessageBoxSync({
82
+ type: 'warning',
83
+ title: '检查更新失败',
84
+ message: `自动更新失败:${err?.message || '未知错误'}`,
85
+ detail: '可前往 GitHub Releases 手动下载。',
86
+ })
87
+ })
88
+ })
89
+ .catch(() => {
90
+ // electron-updater failed to load — auto-update silently disabled.
91
+ })
92
+ }
93
+
94
+ /** Tray "check for update": auto flow on Windows, manual fallback elsewhere. */
95
+ export async function checkForUpdatesAuto() {
96
+ if (isAutoUpdateSupported()) {
97
+ try {
98
+ await getAutoUpdater()
99
+ await autoUpdater?.checkForUpdates()
100
+ } catch (err) {
101
+ closeProgress()
102
+ dialog.showMessageBoxSync({
103
+ type: 'warning',
104
+ title: '检查更新失败',
105
+ message: `自动更新失败:${err?.message || '未知错误'}`,
106
+ detail: '可前往 GitHub Releases 手动下载。',
107
+ })
108
+ }
109
+ return
110
+ }
111
+
112
+ // Manual path (macOS / dev mode): compare versions, offer GitHub page.
113
+ const r = await checkForUpdate()
114
+ if (r.hasUpdate) {
115
+ const choice = dialog.showMessageBoxSync({
116
+ type: 'info',
117
+ title: '发现新版本',
118
+ message: `当前版本 ${r.current},最新版本 ${r.latest}。`,
119
+ detail: 'macOS 自动更新需要代码签名,当前请前往 GitHub Releases 手动下载。',
120
+ buttons: ['前往下载', '取消'],
121
+ defaultId: 0,
122
+ cancelId: 1,
123
+ })
124
+ if (choice === 0) openUrl(r.url)
125
+ } else if (r.latest) {
126
+ dialog.showMessageBoxSync({
127
+ type: 'info',
128
+ title: '已是最新版本',
129
+ message: `当前版本 ${r.current} 已是最新(${r.latest})。`,
130
+ })
131
+ } else {
132
+ dialog.showMessageBoxSync({
133
+ type: 'warning',
134
+ title: '检查更新失败',
135
+ message: '无法连接 GitHub 检查更新,请检查网络后重试。',
136
+ })
137
+ }
138
+ }
139
+
140
+ /** Parse "v1.2.3" / "1.2.3" → [1,2,3]; null when malformed. */
141
+ function parseVersion(v) {
142
+ if (!v) return null
143
+ const m = String(v).replace(/^v/i, '').trim().match(/^(\d+)\.(\d+)\.(\d+)/)
144
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null
145
+ }
146
+
147
+ /** True when a is strictly newer than b. */
148
+ function isNewer(a, b) {
149
+ if (!a || !b) return false
150
+ for (let i = 0; i < 3; i++) {
151
+ if (a[i] !== b[i]) return a[i] > b[i]
152
+ }
153
+ return false
154
+ }
155
+
156
+ /**
157
+ * Manual version check against the latest GitHub release (macOS path).
158
+ * @returns {{ hasUpdate: boolean, latest?: string, current: string, url: string }}
159
+ */
160
+ export async function checkForUpdate() {
161
+ const current = app.getVersion()
162
+ let latest = null
163
+ let tag = null
164
+ let url = REPO_URL
165
+
166
+ try {
167
+ const controller = new AbortController()
168
+ const timer = setTimeout(() => controller.abort(), 8000)
169
+ const res = await fetch(RELEASES_API, {
170
+ signal: controller.signal,
171
+ headers: { Accept: 'application/vnd.github+json' },
172
+ })
173
+ clearTimeout(timer)
174
+ if (res.ok) {
175
+ const data = await res.json()
176
+ tag = data.tag_name || null
177
+ latest = parseVersion(tag)
178
+ if (data.html_url) url = data.html_url
179
+ }
180
+ } catch {
181
+ // Network error — no update known.
182
+ }
183
+
184
+ const currentV = parseVersion(current)
185
+ return {
186
+ hasUpdate: isNewer(latest, currentV),
187
+ latest: tag,
188
+ current,
189
+ url,
190
+ }
191
+ }
192
+
193
+ /** Open the repository homepage in the default browser. */
194
+ export function openRepo() {
195
+ shell.openExternal(REPO_URL)
196
+ }
197
+
198
+ /** Open an arbitrary URL in the default browser. */
199
+ export function openUrl(url) {
200
+ shell.openExternal(url)
201
+ }
@@ -4,16 +4,142 @@
4
4
  * Pure shell philosophy: the window is a normal native frame with
5
5
  * window-controls overlay (Win) / hiddenInset (mac), and nothing else.
6
6
  * No Mica, no vibrancy — keep it clean.
7
+ *
8
+ * Window reliability (Edge-style instant refresh):
9
+ * - the window shows immediately on launch (never waits for the backend);
10
+ * - while the backend is unreachable the page load fails and we swap in a
11
+ * local "backend offline" screen;
12
+ * - a background poll keeps probing the target; as soon as the backend
13
+ * answers, the real page is loaded automatically;
14
+ * - the moment the backend goes down (tray stop, external kill, crash) the
15
+ * window flips back to the offline screen instead of showing a stale page
16
+ * that suggests the app is still alive.
7
17
  */
8
- import { BrowserWindow } from 'electron'
18
+ import { BrowserWindow, ipcMain } from 'electron'
9
19
  import { fileURLToPath } from 'node:url'
10
- import { join } from 'node:path'
20
+ import { probe, onStatusChange, detect } from './service.js'
21
+ import { startBackendWithProgress, chooseBackendFolder } from './tray.js'
11
22
 
12
23
  export const WINDOWS_TITLEBAR_HEIGHT = 32
13
24
 
14
25
  const PRELOAD_PATH = fileURLToPath(new URL('./preload.js', import.meta.url))
15
26
  // Black-whale app icon (matches the DSH web favicon).
16
27
  const ICON_PATH = fileURLToPath(new URL('../build/icon.png', import.meta.url))
28
+ // Local fallback page shown while the backend is down.
29
+ const ERROR_PAGE = fileURLToPath(new URL('./error.html', import.meta.url))
30
+
31
+ // How often we re-probe the backend while the window is in "offline" mode.
32
+ const RECONNECT_INTERVAL_MS = 2500
33
+ // How often we check the backend is still alive while the page is shown.
34
+ const WATCH_INTERVAL_MS = 4000
35
+ // ERR_ABORTED — navigation was cancelled, not a real failure. Ignore it.
36
+ const ERR_ABORTED = -3
37
+
38
+ // Per-window state, keyed by webContents id.
39
+ const reconnectTimers = new Map()
40
+ const watchTimers = new Map()
41
+ const windowTargets = new Map()
42
+ const statusUnsubs = new Map()
43
+
44
+ // Manual reload requests come from the tray button and from the offline
45
+ // screen's retry button (via preload -> ipcRenderer). Route them to the
46
+ // window that sent the message.
47
+ ipcMain.on('shell:reload', (event) => {
48
+ const win = BrowserWindow.fromWebContents(event.sender)
49
+ if (win && !win.isDestroyed()) {
50
+ reloadWindow(win, windowTargets.get(win.id))
51
+ }
52
+ })
53
+
54
+ // Offline-screen quick actions: start / detect backend, pick install
55
+ // folder. The resulting state changes propagate via onStatusChange
56
+ // (window flip + tray refresh), so no extra wiring is needed here.
57
+ ipcMain.on('shell:start-backend', () => startBackendWithProgress())
58
+ ipcMain.on('shell:detect-backend', () => detect())
59
+ ipcMain.on('shell:choose-backend-folder', () => chooseBackendFolder())
60
+
61
+ // ---------- offline mode ----------
62
+
63
+ function stopReconnect(win) {
64
+ const timer = reconnectTimers.get(win.id)
65
+ if (timer) {
66
+ clearInterval(timer)
67
+ reconnectTimers.delete(win.id)
68
+ }
69
+ }
70
+
71
+ function startReconnect(win, target) {
72
+ if (reconnectTimers.has(win.id)) return
73
+ const timer = setInterval(async () => {
74
+ if (win.isDestroyed()) {
75
+ stopReconnect(win)
76
+ return
77
+ }
78
+ const up = await probe(target)
79
+ if (up) {
80
+ stopReconnect(win)
81
+ win.webContents.loadURL(target).catch(() => startReconnect(win, target))
82
+ }
83
+ }, RECONNECT_INTERVAL_MS)
84
+ reconnectTimers.set(win.id, timer)
85
+ }
86
+
87
+ // ---------- online mode (backend liveness watch) ----------
88
+
89
+ function stopWatch(win) {
90
+ const timer = watchTimers.get(win.id)
91
+ if (timer) {
92
+ clearInterval(timer)
93
+ watchTimers.delete(win.id)
94
+ }
95
+ }
96
+
97
+ /** While the real page is shown, watch that the backend stays alive. */
98
+ function startWatch(win, target) {
99
+ if (watchTimers.has(win.id)) return
100
+ const timer = setInterval(async () => {
101
+ if (win.isDestroyed()) {
102
+ stopWatch(win)
103
+ return
104
+ }
105
+ const up = await probe(target, 1500)
106
+ if (!up) {
107
+ // Backend vanished — flip to the offline screen immediately so the
108
+ // stale page cannot fool the user into thinking the app is alive.
109
+ showOffline(win)
110
+ }
111
+ }, WATCH_INTERVAL_MS)
112
+ watchTimers.set(win.id, timer)
113
+ }
114
+
115
+ // ---------- state flips ----------
116
+
117
+ /** Switch to the offline screen and start re-probing. */
118
+ function showOffline(win) {
119
+ if (win.isDestroyed()) return
120
+ const target = windowTargets.get(win.id)
121
+ stopWatch(win)
122
+ win.loadFile(ERROR_PAGE).catch(() => {})
123
+ if (target) startReconnect(win, target)
124
+ }
125
+
126
+ /** Load the real backend page and start watching it. */
127
+ function showOnline(win) {
128
+ if (win.isDestroyed()) return
129
+ const target = windowTargets.get(win.id)
130
+ if (!target) return
131
+ stopReconnect(win)
132
+ win.webContents.loadURL(target).catch(() => startReconnect(win, target))
133
+ }
134
+
135
+ /** Load the real target URL in a window (used by tray + offline retry). */
136
+ export function reloadWindow(win, target) {
137
+ if (!win || win.isDestroyed()) return
138
+ stopReconnect(win)
139
+ win.webContents.loadURL(target).catch(() => startReconnect(win, target))
140
+ }
141
+
142
+ // ---------- window creation ----------
17
143
 
18
144
  export function createMainWindow({ target }) {
19
145
  const platform = process.platform
@@ -25,7 +151,9 @@ export function createMainWindow({ target }) {
25
151
  height: 800,
26
152
  minWidth: 760,
27
153
  minHeight: 520,
28
- show: false,
154
+ // Show immediately — the backend may be starting, the window must not
155
+ // wait for `ready-to-show` (which lags when the page fails to load).
156
+ show: true,
29
157
  title: 'DeepSeek Harness',
30
158
  backgroundColor: '#10131A',
31
159
  icon: isWin ? ICON_PATH : undefined,
@@ -61,9 +189,55 @@ export function createMainWindow({ target }) {
61
189
  // Linux / other: keep the native frame.
62
190
 
63
191
  const win = new BrowserWindow(options)
64
- win.loadURL(target)
192
+ windowTargets.set(win.id, target)
193
+ win.loadURL(target).catch(() => startReconnect(win, target))
65
194
 
66
- win.once('ready-to-show', () => win.show())
195
+ // Instant flip when the backend state machine changes (tray stop/start).
196
+ const unsub = onStatusChange((st) => {
197
+ if (win.isDestroyed()) return
198
+ const current = win.webContents.getURL()
199
+ const isOffline = current.includes('error.html')
200
+ if ((st.status === 'stopped' || st.status === 'error') && !isOffline) {
201
+ // Backend went down while a real page is showing — go dark at once.
202
+ showOffline(win)
203
+ } else if (st.status === 'running' && isOffline) {
204
+ // Backend came up while we are on the offline screen — load it.
205
+ showOnline(win)
206
+ }
207
+ })
208
+ statusUnsubs.set(win.id, unsub)
209
+
210
+ win.webContents.on('did-fail-load', (_e, code, _desc, url, isMainFrame) => {
211
+ if (!isMainFrame || code === ERR_ABORTED) return
212
+ // Offline screen already showing — just keep re-probing, do not
213
+ // reload the offline page again (avoids a reload loop if it fails).
214
+ if (win.webContents.getURL().includes('error.html')) {
215
+ startReconnect(win, target)
216
+ return
217
+ }
218
+ // Backend unreachable: show the offline screen and start re-probing.
219
+ showOffline(win)
220
+ })
221
+
222
+ win.webContents.on('did-finish-load', () => {
223
+ const current = win.webContents.getURL()
224
+ if (current.startsWith(target)) {
225
+ // Real backend page reached — stop re-probing and watch it.
226
+ stopReconnect(win)
227
+ startWatch(win, target)
228
+ }
229
+ })
230
+
231
+ win.on('closed', () => {
232
+ stopReconnect(win)
233
+ stopWatch(win)
234
+ const unsub = statusUnsubs.get(win.id)
235
+ if (unsub) {
236
+ unsub()
237
+ statusUnsubs.delete(win.id)
238
+ }
239
+ windowTargets.delete(win.id)
240
+ })
67
241
 
68
242
  return win
69
243
  }
package/lib/client.js CHANGED
@@ -1,10 +1,10 @@
1
- /**
2
- * dsh-clean-desktop-shell — client half (web browser bundle).
3
- *
4
- * Registers a settings row so the shell's target URL / material options
5
- * are editable from Settings → General. This is a minimal placeholder:
6
- * real UI wiring lands with the Electron shell work.
7
- */
8
- export function apply() {
9
- // placeholder: settings-row wiring arrives with the shell implementation
10
- }
1
+ /**
2
+ * dsh-clean-desktop-shell — client half (web browser bundle).
3
+ *
4
+ * Registers a settings row so the shell's target URL / material options
5
+ * are editable from Settings → General. This is a minimal placeholder:
6
+ * real UI wiring lands with the Electron shell work.
7
+ */
8
+ export function apply() {
9
+ // placeholder: settings-row wiring arrives with the shell implementation
10
+ }
package/lib/index.js CHANGED
@@ -1,13 +1,13 @@
1
- /**
2
- * dsh-clean-desktop-shell — host half (plugin loader entry).
3
- *
4
- * The desktop shell is an Electron client; the host half only registers
5
- * the plugin so it mounts cleanly into a dsh profile and exposes its
6
- * settings surface. Window material (Mica / vibrancy) lives in the
7
- * Electron main process (see src/main/).
8
- */
9
- export function apply(ctx) {
10
- ctx.on('ready', () => {
11
- ctx.logger.info('[clean-desktop-shell] mounted (host half)')
12
- })
13
- }
1
+ /**
2
+ * dsh-clean-desktop-shell — host half (plugin loader entry).
3
+ *
4
+ * The desktop shell is an Electron client; the host half only registers
5
+ * the plugin so it mounts cleanly into a dsh profile and exposes its
6
+ * settings surface. Window material (Mica / vibrancy) lives in the
7
+ * Electron main process (see src/main/).
8
+ */
9
+ export function apply(ctx) {
10
+ ctx.on('ready', () => {
11
+ ctx.logger.info('[clean-desktop-shell] mounted (host half)')
12
+ })
13
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-clean-desktop-shell",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Clean desktop shell for DeepSeek Harness (DSH) as a DSH plugin — reuses your web profile, tray/single-instance/auto-launch, zero visual changes. DSH 插件形态的纯净桌面壳:复用现有 web profile,托盘管理后端,零视觉改造。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -91,14 +91,22 @@
91
91
  "category": "public.app-category.developer-tools"
92
92
  },
93
93
  "nsis": {
94
- "oneClick": false,
95
- "allowToChangeInstallationDirectory": true,
94
+ "oneClick": true,
95
+ "perMachine": false,
96
96
  "createDesktopShortcut": true,
97
97
  "createStartMenuShortcut": true
98
+ },
99
+ "publish": {
100
+ "provider": "github",
101
+ "owner": "Icather",
102
+ "repo": "dsh-clean-desktop-shell"
98
103
  }
99
104
  },
100
105
  "license": "MIT",
101
106
  "allowScripts": {
102
107
  "electron@33.4.11": true
108
+ },
109
+ "dependencies": {
110
+ "electron-updater": "^6.8.9"
103
111
  }
104
112
  }
package/scripts/build.mjs CHANGED
@@ -1,12 +1,12 @@
1
- #!/usr/bin/env node
2
- // Minimal zero-dependency build: copies src → lib.
3
- // Real bundling (tsdown/vite) lands with the Electron shell work.
4
- import { cpSync, mkdirSync } from 'node:fs'
5
- import { dirname, join } from 'node:path'
6
- import { fileURLToPath } from 'node:url'
7
-
8
- const root = dirname(dirname(fileURLToPath(import.meta.url)))
9
- mkdirSync(join(root, 'lib'), { recursive: true })
10
- cpSync(join(root, 'src', 'host', 'index.js'), join(root, 'lib', 'index.js'))
11
- cpSync(join(root, 'src', 'client', 'client.js'), join(root, 'lib', 'client.js'))
12
- console.log('[dsh-clean-desktop-shell] built lib/ from src/')
1
+ #!/usr/bin/env node
2
+ // Minimal zero-dependency build: copies src → lib.
3
+ // Real bundling (tsdown/vite) lands with the Electron shell work.
4
+ import { cpSync, mkdirSync } from 'node:fs'
5
+ import { dirname, join } from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+
8
+ const root = dirname(dirname(fileURLToPath(import.meta.url)))
9
+ mkdirSync(join(root, 'lib'), { recursive: true })
10
+ cpSync(join(root, 'src', 'host', 'index.js'), join(root, 'lib', 'index.js'))
11
+ cpSync(join(root, 'src', 'client', 'client.js'), join(root, 'lib', 'client.js'))
12
+ console.log('[dsh-clean-desktop-shell] built lib/ from src/')