termdock 1.4.108 → 1.4.110

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/.desktop-dist/main.js +483 -24
  2. package/dist/client/assets/{ModelPreview-By3cPzJ9.js → ModelPreview-B40SLVdu.js} +1 -1
  3. package/dist/client/assets/{abnfDiagram-VRR7QNED-Dm9g0Pi3.js → abnfDiagram-VRR7QNED-D7p11Ssd.js} +1 -1
  4. package/dist/client/assets/{arc-XntYYR9t.js → arc-CsmLIp7i.js} +1 -1
  5. package/dist/client/assets/{architectureDiagram-ZJ3FMSHR-CW0kZlVv.js → architectureDiagram-ZJ3FMSHR-BjYttOye.js} +1 -1
  6. package/dist/client/assets/{blockDiagram-677ZJIJ3-DgMdh19W.js → blockDiagram-677ZJIJ3-BnWi8IVO.js} +1 -1
  7. package/dist/client/assets/{c4Diagram-LMCZKHZV-DJC4m2Y6.js → c4Diagram-LMCZKHZV-BFm2RcKX.js} +1 -1
  8. package/dist/client/assets/channel-ifcSKqN3.js +1 -0
  9. package/dist/client/assets/{chunk-2Q5K7J3B-CgE5oTjY.js → chunk-2Q5K7J3B-D_OKw60X.js} +1 -1
  10. package/dist/client/assets/{chunk-32BRIVSS-B50lSI4-.js → chunk-32BRIVSS-D6k1lZV9.js} +1 -1
  11. package/dist/client/assets/{chunk-5VM5RSS4-C984gGSO.js → chunk-5VM5RSS4-CEqvSDkW.js} +1 -1
  12. package/dist/client/assets/{chunk-EX3LRPZG-C1jPbFiq.js → chunk-EX3LRPZG-Ca2Gmbvd.js} +1 -1
  13. package/dist/client/assets/{chunk-JWPE2WC7-D4U9D5Kv.js → chunk-JWPE2WC7-CRRvRUQB.js} +1 -1
  14. package/dist/client/assets/{chunk-MOJQB5TN-C4rLPnf_.js → chunk-MOJQB5TN-CeZ5qCFm.js} +1 -1
  15. package/dist/client/assets/{chunk-RYQCIY6F-CnSX68rt.js → chunk-RYQCIY6F-Cx7sW5Rr.js} +1 -1
  16. package/dist/client/assets/{chunk-V7JOEXUC-LTTPDSzs.js → chunk-V7JOEXUC-B3_grzN8.js} +1 -1
  17. package/dist/client/assets/{chunk-VR4S4FIN-dCZ9yGOH.js → chunk-VR4S4FIN-g73M8USU.js} +1 -1
  18. package/dist/client/assets/{chunk-XXDRQBXY-DiUZr3na.js → chunk-XXDRQBXY-9i-NV13r.js} +1 -1
  19. package/dist/client/assets/classDiagram-OUVF2IWQ-CVu2gyed.js +1 -0
  20. package/dist/client/assets/classDiagram-v2-EOCWNBFH-CVu2gyed.js +1 -0
  21. package/dist/client/assets/{cose-bilkent-JH36ORCC-Dw73llr7.js → cose-bilkent-JH36ORCC-DGKauMxg.js} +1 -1
  22. package/dist/client/assets/{cynefin-VYW2F7L2-CsB9ZT7a.js → cynefin-VYW2F7L2-DKvEKvGd.js} +1 -1
  23. package/dist/client/assets/{cynefinDiagram-TSTJHNR4-NinMHYPB.js → cynefinDiagram-TSTJHNR4-CLXYMIpB.js} +1 -1
  24. package/dist/client/assets/{dagre-VKFMJZFB-CmFL5D4c.js → dagre-VKFMJZFB-DFAR5nAp.js} +1 -1
  25. package/dist/client/assets/{diagram-FQU43EPY-D_C52SB6.js → diagram-FQU43EPY-DeRBoyeq.js} +1 -1
  26. package/dist/client/assets/{diagram-G47NLZAW-BLlpvNO5.js → diagram-G47NLZAW-CDEAgMuP.js} +1 -1
  27. package/dist/client/assets/{diagram-NH7WQ7WH-BNT2NQnR.js → diagram-NH7WQ7WH-4VFR2ZsJ.js} +1 -1
  28. package/dist/client/assets/{diagram-OA4YK3LP-DlJ-T7tl.js → diagram-OA4YK3LP-DMKIgZlI.js} +1 -1
  29. package/dist/client/assets/{diagram-WEI45ONY-D7s_sZnL.js → diagram-WEI45ONY-zEjxP4io.js} +1 -1
  30. package/dist/client/assets/{ebnfDiagram-CCIWWBDH-PGksmUKG.js → ebnfDiagram-CCIWWBDH-BuErqR1l.js} +1 -1
  31. package/dist/client/assets/{erDiagram-Q63AITRT-CbfrT-bt.js → erDiagram-Q63AITRT-D4nbc2T3.js} +1 -1
  32. package/dist/client/assets/{flowDiagram-23GEKE2U-CqUGdXdQ.js → flowDiagram-23GEKE2U-B2MiQAI4.js} +1 -1
  33. package/dist/client/assets/{ganttDiagram-NO4QXBWP-DpH0ybM6.js → ganttDiagram-NO4QXBWP-hGTX0vsS.js} +1 -1
  34. package/dist/client/assets/{gitGraphDiagram-IHSO6WYX-Cmc3LsBg.js → gitGraphDiagram-IHSO6WYX-BHHSVtvH.js} +1 -1
  35. package/dist/client/assets/index-CFXqRKcJ.css +1 -0
  36. package/dist/client/assets/index-D3kro0zp.js +456 -0
  37. package/dist/client/assets/{infoDiagram-FWYZ7A6U-CHzl_SDV.js → infoDiagram-FWYZ7A6U-BZuNpsCG.js} +1 -1
  38. package/dist/client/assets/{ishikawaDiagram-FXEZZL3T-BUVwtCaN.js → ishikawaDiagram-FXEZZL3T-C0KwjZIv.js} +1 -1
  39. package/dist/client/assets/{journeyDiagram-5HDEW3XC-DrmO4THP.js → journeyDiagram-5HDEW3XC-mxy7-Irj.js} +1 -1
  40. package/dist/client/assets/{kanban-definition-HUTT4EX6-DTZoUd1n.js → kanban-definition-HUTT4EX6-DslZKv6b.js} +1 -1
  41. package/dist/client/assets/{linear-DgCNjduP.js → linear-BJXz3Iqq.js} +1 -1
  42. package/dist/client/assets/{mermaid.core-DLidsBlx.js → mermaid.core-Btv5hFOv.js} +4 -4
  43. package/dist/client/assets/{mindmap-definition-LN4V7U3C-srFG-Dae.js → mindmap-definition-LN4V7U3C-BSbqyDyq.js} +1 -1
  44. package/dist/client/assets/{pegDiagram-2B236MQR-Qary-lxo.js → pegDiagram-2B236MQR-L2cCu4W1.js} +1 -1
  45. package/dist/client/assets/{pieDiagram-ENE6RG2P-D924FFIQ.js → pieDiagram-ENE6RG2P-CXdawbl4.js} +1 -1
  46. package/dist/client/assets/{quadrantDiagram-ABIIQ3AL-YFBMJKUv.js → quadrantDiagram-ABIIQ3AL-CwKwBctE.js} +1 -1
  47. package/dist/client/assets/{railroadDiagram-RFXS5EU6-C__7fBPw.js → railroadDiagram-RFXS5EU6-B6MBOnSp.js} +1 -1
  48. package/dist/client/assets/{requirementDiagram-TGXJPOKE-BtAq5f69.js → requirementDiagram-TGXJPOKE-CR9lns0Z.js} +1 -1
  49. package/dist/client/assets/{sankeyDiagram-HTMAVEWB-BGULYk91.js → sankeyDiagram-HTMAVEWB-DLuhNvTd.js} +1 -1
  50. package/dist/client/assets/{sequenceDiagram-DBY2YBRQ-BBjCjIFm.js → sequenceDiagram-DBY2YBRQ-BorPhsA5.js} +1 -1
  51. package/dist/client/assets/{sizeCapture-X5ZJPWSS-DjP0UZy6.js → sizeCapture-X5ZJPWSS-DPXho3_K.js} +1 -1
  52. package/dist/client/assets/{stateDiagram-2N3HPSRC-BTqu5zVt.js → stateDiagram-2N3HPSRC-DSbrRqVk.js} +1 -1
  53. package/dist/client/assets/stateDiagram-v2-6OUMAXLB--bVRWeRL.js +1 -0
  54. package/dist/client/assets/{swimlanes-5IMT3BWC-ClySuTz2.js → swimlanes-5IMT3BWC-D4A4axIk.js} +2 -2
  55. package/dist/client/assets/swimlanesDiagram-G3AALYLV-CqWtgk0W.js +8 -0
  56. package/dist/client/assets/{timeline-definition-FHXFAJF6-DHURyh7H.js → timeline-definition-FHXFAJF6-D4ObRKT8.js} +1 -1
  57. package/dist/client/assets/{vennDiagram-L72KCM5P-BFdn5KML.js → vennDiagram-L72KCM5P-Bj_4-RtP.js} +1 -1
  58. package/dist/client/assets/{wardleyDiagram-EHGQE667-DKEHwCwO.js → wardleyDiagram-EHGQE667-BNvcAUoQ.js} +1 -1
  59. package/dist/client/assets/{xychartDiagram-FW5EYKEG-CI5HzOMq.js → xychartDiagram-FW5EYKEG-BfGV9sTq.js} +1 -1
  60. package/dist/client/index.html +2 -2
  61. package/dist/client/sw.js +1 -1
  62. package/dist/server/agent/automationStore.js +12 -7
  63. package/dist/server/agent/sessionSearchStore.js +135 -5
  64. package/dist/server/entry.js +24 -1
  65. package/dist/server/routes/terminal.js +6 -2
  66. package/dist/server/utils/runtimeMonitor.js +287 -0
  67. package/dist/server/utils/settings.js +9 -0
  68. package/package.json +1 -1
  69. package/runtime-manifest.json +2 -2
  70. package/dist/client/assets/channel-D1KecDCp.js +0 -1
  71. package/dist/client/assets/classDiagram-OUVF2IWQ-BYJ3p4nE.js +0 -1
  72. package/dist/client/assets/classDiagram-v2-EOCWNBFH-BYJ3p4nE.js +0 -1
  73. package/dist/client/assets/index-BwMQSNQq.js +0 -456
  74. package/dist/client/assets/index-RGd2Qu1X.css +0 -1
  75. package/dist/client/assets/stateDiagram-v2-6OUMAXLB-D5FsphfH.js +0 -1
  76. package/dist/client/assets/swimlanesDiagram-G3AALYLV-C1_CRJSE.js +0 -8
@@ -1,4 +1,4 @@
1
- import { app, BrowserWindow, dialog, ipcMain, Menu, net, Notification, session, shell, } from 'electron';
1
+ import { app, BrowserWindow, dialog, ipcMain, Menu, net, nativeImage, Notification, screen, session, shell, Tray, } from 'electron';
2
2
  import { execFile, spawn } from 'node:child_process';
3
3
  import crypto from 'node:crypto';
4
4
  import dgram from 'node:dgram';
@@ -8,6 +8,7 @@ import os from 'node:os';
8
8
  import path from 'node:path';
9
9
  import { promisify } from 'node:util';
10
10
  import { fileURLToPath } from 'node:url';
11
+ import { desktopStatusTooltip, formatCompactDesktopStatus, mergeServiceActivity, nextServiceOrigin, normalizeServiceActivity, summarizeServiceActivity, } from './activityStatus.js';
11
12
  import { checkForDesktopUpdates, configureDesktopUpdater, ensureLatestRuntime, getDesktopUpdateState, installDownloadedDesktopUpdate, subscribeDesktopUpdateState, } from './updater.js';
12
13
  import { resolvePackagedRuntime, rollbackDownloadedRuntime, } from './runtime.js';
13
14
  import { isExternalLinkStagingUrl, isSafeExternalUrl } from './externalLinks.js';
@@ -52,9 +53,17 @@ function triggerLocalNetworkPermission() {
52
53
  let mainWindow = null;
53
54
  const serviceWindows = new Map();
54
55
  const windowServiceOrigins = new WeakMap();
55
- const serviceActivity = new Map();
56
+ const restorableServiceWindows = new WeakSet();
57
+ const reportedServiceActivity = new Map();
58
+ const observedServiceActivity = new Map();
56
59
  let lastFocusedServiceWindow = null;
60
+ let menuBarStatus = null;
61
+ let menuBarStatusWidth = 0;
62
+ let floatingWidgetWindow = null;
63
+ let floatingPositionTimer = null;
57
64
  let isQuitting = false;
65
+ const FLOATING_WIDGET_WIDTHS = [64, 108, 152];
66
+ const FLOATING_WIDGET_HEIGHT = 40;
58
67
  function focusedWorkspaceWindow() {
59
68
  const focused = BrowserWindow.getFocusedWindow();
60
69
  if (focused && windowServiceOrigins.has(focused))
@@ -77,7 +86,7 @@ function serviceActivitySnapshot(targetWindow) {
77
86
  return [...serviceWindows.entries()].flatMap(([origin, window]) => {
78
87
  if (window.isDestroyed())
79
88
  return [];
80
- const activity = serviceActivity.get(origin) ?? { runningCount: 0, reviewCount: 0 };
89
+ const activity = mergeServiceActivity(reportedServiceActivity.get(origin), observedServiceActivity.get(origin));
81
90
  return [{
82
91
  origin,
83
92
  label: serviceLabel(origin),
@@ -93,6 +102,7 @@ function broadcastServiceActivity() {
93
102
  continue;
94
103
  window.webContents.send('desktop:service-activity-changed', serviceActivitySnapshot(window));
95
104
  }
105
+ refreshDesktopStatusSurfaces();
96
106
  }
97
107
  function serviceLabel(url) {
98
108
  const normalized = normalizeServiceUrl(url);
@@ -104,6 +114,311 @@ function serviceLabel(url) {
104
114
  ? '本机'
105
115
  : parsed.host;
106
116
  }
117
+ function allServiceActivity() {
118
+ const focused = BrowserWindow.getFocusedWindow();
119
+ return [...serviceWindows.entries()].flatMap(([origin, window]) => {
120
+ if (window.isDestroyed())
121
+ return [];
122
+ return [{
123
+ origin,
124
+ label: serviceLabel(origin),
125
+ current: window === lastFocusedServiceWindow,
126
+ focused: window === focused,
127
+ ...mergeServiceActivity(reportedServiceActivity.get(origin), observedServiceActivity.get(origin)),
128
+ }];
129
+ });
130
+ }
131
+ function desktopStatusSnapshot() {
132
+ const services = allServiceActivity();
133
+ const summary = summarizeServiceActivity(services);
134
+ return {
135
+ ...summary,
136
+ text: formatCompactDesktopStatus(summary),
137
+ tooltip: desktopStatusTooltip(summary),
138
+ services,
139
+ preferences: readDesktopConfig().desktopPreferences,
140
+ };
141
+ }
142
+ function nextServiceWindow(scope) {
143
+ const currentOrigin = lastFocusedServiceWindow && !lastFocusedServiceWindow.isDestroyed()
144
+ ? windowServiceOrigins.get(lastFocusedServiceWindow) ?? null
145
+ : null;
146
+ const origin = nextServiceOrigin(allServiceActivity(), currentOrigin, scope);
147
+ return origin ? serviceWindows.get(origin) ?? null : focusedWorkspaceWindow();
148
+ }
149
+ function focusNextService(scope = 'attention') {
150
+ const target = nextServiceWindow(scope);
151
+ if (target && !target.isDestroyed()) {
152
+ showAndFocusWindow(target);
153
+ return;
154
+ }
155
+ void showConnectionCenter();
156
+ }
157
+ function menuBarContextMenu() {
158
+ const status = desktopStatusSnapshot();
159
+ const serviceItems = status.services.length > 0
160
+ ? status.services
161
+ .sort((left, right) => right.reviewCount - left.reviewCount
162
+ || right.runningCount - left.runningCount
163
+ || left.label.localeCompare(right.label))
164
+ .map((service) => ({
165
+ label: `${service.label}${service.runningCount > 0 ? ` 运行 ${service.runningCount}` : ''}${service.reviewCount > 0 ? ` 待办 ${service.reviewCount}` : ''}`,
166
+ click: () => {
167
+ const target = serviceWindows.get(service.origin);
168
+ if (target && !target.isDestroyed())
169
+ showAndFocusWindow(target);
170
+ },
171
+ }))
172
+ : [{ label: '暂无已打开服务', enabled: false }];
173
+ return Menu.buildFromTemplate([
174
+ { label: status.tooltip, enabled: false },
175
+ { type: 'separator' },
176
+ ...serviceItems,
177
+ { type: 'separator' },
178
+ { label: '连接中心…', click: () => void showConnectionCenter() },
179
+ {
180
+ label: '显示悬浮状态',
181
+ type: 'checkbox',
182
+ checked: status.preferences.floatingWidgetEnabled,
183
+ click: (item) => updateDesktopPreferences({ floatingWidgetEnabled: item.checked }),
184
+ },
185
+ { type: 'separator' },
186
+ { role: 'quit' },
187
+ ]);
188
+ }
189
+ function menuBarStatusImage(status) {
190
+ const size = 18;
191
+ const scale = 2;
192
+ const bitmap = Buffer.alloc(size * scale * size * scale * 4);
193
+ const glyphs = {
194
+ '0': ['111', '101', '101', '101', '111'],
195
+ '1': ['010', '110', '010', '010', '111'],
196
+ '2': ['111', '001', '111', '100', '111'],
197
+ '3': ['111', '001', '111', '001', '111'],
198
+ '4': ['101', '101', '111', '001', '001'],
199
+ '5': ['111', '100', '111', '001', '111'],
200
+ '6': ['111', '100', '111', '101', '111'],
201
+ '7': ['111', '001', '010', '010', '010'],
202
+ '8': ['111', '101', '111', '101', '111'],
203
+ '9': ['111', '101', '111', '001', '111'],
204
+ '+': ['000', '010', '111', '010', '000'],
205
+ };
206
+ const drawPixel = (x, y, alpha = 255) => {
207
+ for (let offsetY = 0; offsetY < scale; offsetY += 1) {
208
+ for (let offsetX = 0; offsetX < scale; offsetX += 1) {
209
+ const pixelX = x * scale + offsetX;
210
+ const pixelY = y * scale + offsetY;
211
+ if (pixelX < 0 || pixelX >= size * scale || pixelY < 0 || pixelY >= size * scale)
212
+ continue;
213
+ bitmap[(pixelY * size * scale + pixelX) * 4 + 3] = alpha;
214
+ }
215
+ }
216
+ };
217
+ const drawGlyph = (value, x, y, alpha) => {
218
+ const glyph = glyphs[value > 9 ? '+' : String(value)] ?? glyphs['0'];
219
+ glyph.forEach((row, rowIndex) => {
220
+ [...row].forEach((cell, columnIndex) => {
221
+ if (cell === '1')
222
+ drawPixel(x + columnIndex, y + rowIndex, alpha);
223
+ });
224
+ });
225
+ };
226
+ // Three quiet, consistently aligned rows read much better at menu-bar size
227
+ // than squeezing two metrics beside each other. The ordering mirrors the
228
+ // floating widget: running, review, then connected services.
229
+ const rows = [
230
+ { value: status.runningCount, y: 0, icon: [[0, 0], [0, 1], [1, 1], [0, 2], [1, 2], [2, 2], [3, 2], [0, 3], [1, 3], [0, 4]] },
231
+ { value: status.reviewCount, y: 6, icon: [[2, 0], [1, 1], [3, 1], [0, 2], [4, 2], [1, 3], [3, 3], [2, 4]] },
232
+ { value: status.serviceCount, y: 12, icon: [[0, 0], [1, 0], [3, 0], [4, 0], [1, 1], [3, 1], [2, 2], [2, 3], [1, 4], [2, 4], [3, 4]] },
233
+ ];
234
+ rows.forEach(({ value, y, icon }) => {
235
+ const alpha = value > 0 ? 255 : 96;
236
+ icon.forEach(([offsetX, offsetY]) => drawPixel(1 + offsetX, y + offsetY, alpha));
237
+ drawGlyph(value, 11, y, alpha);
238
+ });
239
+ // Template images are intentionally monochrome; macOS supplies the correct
240
+ // menu-bar color for the active appearance and accessibility state.
241
+ const image = nativeImage.createFromBitmap(bitmap, {
242
+ width: size * scale,
243
+ height: size * scale,
244
+ scaleFactor: scale,
245
+ });
246
+ image.setTemplateImage(true);
247
+ return image;
248
+ }
249
+ function refreshMenuBarStatus(status) {
250
+ if (process.platform !== 'darwin' || !status.preferences.menuBarStatusEnabled) {
251
+ menuBarStatus?.destroy();
252
+ menuBarStatus = null;
253
+ menuBarStatusWidth = 0;
254
+ return;
255
+ }
256
+ const image = menuBarStatusImage(status);
257
+ const imageWidth = image.getSize().width;
258
+ if (!menuBarStatus || menuBarStatus.isDestroyed() || menuBarStatusWidth !== imageWidth) {
259
+ menuBarStatus?.destroy();
260
+ menuBarStatus = new Tray(image);
261
+ menuBarStatusWidth = imageWidth;
262
+ menuBarStatus.on('click', () => focusNextService('attention'));
263
+ menuBarStatus.on('right-click', () => menuBarStatus?.popUpContextMenu(menuBarContextMenu()));
264
+ }
265
+ else {
266
+ menuBarStatus.setImage(image);
267
+ }
268
+ menuBarStatus.setTitle('');
269
+ menuBarStatus.setToolTip(`${status.tooltip}\n点击循环切换需关注的服务`);
270
+ }
271
+ function constrainFloatingPosition(position, width = FLOATING_WIDGET_WIDTHS[2]) {
272
+ const height = FLOATING_WIDGET_HEIGHT;
273
+ const display = position
274
+ ? screen.getDisplayMatching({ x: position.x, y: position.y, width, height })
275
+ : screen.getPrimaryDisplay();
276
+ const area = display.workArea;
277
+ const preferred = position ?? {
278
+ x: area.x + area.width - width - 18,
279
+ y: area.y + 18,
280
+ };
281
+ return {
282
+ x: Math.min(area.x + area.width - width, Math.max(area.x, Math.round(preferred.x))),
283
+ y: Math.min(area.y + area.height - height, Math.max(area.y, Math.round(preferred.y))),
284
+ };
285
+ }
286
+ function createFloatingWidget(position) {
287
+ const bounds = constrainFloatingPosition(position);
288
+ const window = new BrowserWindow({
289
+ title: 'Termdock Status',
290
+ type: 'panel',
291
+ width: FLOATING_WIDGET_WIDTHS[2],
292
+ height: FLOATING_WIDGET_HEIGHT,
293
+ x: bounds.x,
294
+ y: bounds.y,
295
+ show: false,
296
+ frame: false,
297
+ transparent: true,
298
+ backgroundColor: 'rgba(0, 0, 0, 0)',
299
+ vibrancy: 'popover',
300
+ visualEffectState: 'active',
301
+ resizable: false,
302
+ maximizable: false,
303
+ minimizable: false,
304
+ fullscreenable: false,
305
+ focusable: false,
306
+ skipTaskbar: true,
307
+ alwaysOnTop: true,
308
+ acceptFirstMouse: true,
309
+ hasShadow: true,
310
+ webPreferences: {
311
+ preload: path.join(currentDir, 'preload.cjs'),
312
+ nodeIntegration: false,
313
+ contextIsolation: true,
314
+ sandbox: true,
315
+ backgroundThrottling: false,
316
+ },
317
+ });
318
+ window.setAlwaysOnTop(true, 'floating');
319
+ window.setVisibleOnAllWorkspaces(true, {
320
+ visibleOnFullScreen: true,
321
+ // Electron otherwise temporarily transforms the whole process into a UI
322
+ // element, which can hide the app menu and remove normal windows from App
323
+ // Expose while this panel exists.
324
+ skipTransformProcessType: true,
325
+ });
326
+ // Keep the auxiliary status panel out of App Expose / Mission Control so it
327
+ // cannot disrupt macOS's three-finger application-window gesture.
328
+ window.setHiddenInMissionControl(true);
329
+ let constrainingMove = false;
330
+ window.on('move', () => {
331
+ if (constrainingMove || window.isDestroyed())
332
+ return;
333
+ const [x, y] = window.getPosition();
334
+ const [width] = window.getSize();
335
+ const constrained = constrainFloatingPosition({ x, y }, width);
336
+ if (constrained.x === x && constrained.y === y)
337
+ return;
338
+ constrainingMove = true;
339
+ window.setPosition(constrained.x, constrained.y, false);
340
+ constrainingMove = false;
341
+ });
342
+ window.on('moved', () => {
343
+ if (floatingPositionTimer)
344
+ clearTimeout(floatingPositionTimer);
345
+ floatingPositionTimer = setTimeout(() => {
346
+ floatingPositionTimer = null;
347
+ if (window.isDestroyed())
348
+ return;
349
+ const [x, y] = window.getPosition();
350
+ const config = readDesktopConfig();
351
+ config.desktopPreferences.floatingWidgetPosition = { x, y };
352
+ writeDesktopConfig(config);
353
+ }, 250);
354
+ });
355
+ window.on('closed', () => {
356
+ if (floatingWidgetWindow === window)
357
+ floatingWidgetWindow = null;
358
+ });
359
+ const rendererPath = app.isPackaged
360
+ ? path.join(process.resourcesPath, 'renderer', 'floating.html')
361
+ : path.join(projectRoot, 'desktop', 'renderer', 'floating.html');
362
+ void window.loadFile(rendererPath).then(() => window.showInactive());
363
+ return window;
364
+ }
365
+ function resizeFloatingWidget(metricCount) {
366
+ const window = floatingWidgetWindow;
367
+ if (!window || window.isDestroyed())
368
+ return;
369
+ const safeCount = Math.min(3, Math.max(1, Math.round(metricCount)));
370
+ const width = FLOATING_WIDGET_WIDTHS[safeCount - 1];
371
+ const [currentWidth] = window.getSize();
372
+ if (currentWidth === width)
373
+ return;
374
+ const [x, y] = window.getPosition();
375
+ window.setSize(width, FLOATING_WIDGET_HEIGHT, false);
376
+ const constrained = constrainFloatingPosition({ x: x + currentWidth - width, y }, width);
377
+ window.setPosition(constrained.x, constrained.y, false);
378
+ }
379
+ function refreshFloatingWidget(status) {
380
+ if (process.platform !== 'darwin' || !status.preferences.floatingWidgetEnabled) {
381
+ if (floatingWidgetWindow && !floatingWidgetWindow.isDestroyed())
382
+ floatingWidgetWindow.destroy();
383
+ floatingWidgetWindow = null;
384
+ return;
385
+ }
386
+ if (!floatingWidgetWindow || floatingWidgetWindow.isDestroyed()) {
387
+ floatingWidgetWindow = createFloatingWidget(status.preferences.floatingWidgetPosition);
388
+ }
389
+ }
390
+ function keepFloatingWidgetOnScreen() {
391
+ if (!floatingWidgetWindow || floatingWidgetWindow.isDestroyed())
392
+ return;
393
+ const [x, y] = floatingWidgetWindow.getPosition();
394
+ const [width] = floatingWidgetWindow.getSize();
395
+ const constrained = constrainFloatingPosition({ x, y }, width);
396
+ if (constrained.x !== x || constrained.y !== y) {
397
+ floatingWidgetWindow.setPosition(constrained.x, constrained.y, false);
398
+ }
399
+ }
400
+ function refreshDesktopStatusSurfaces() {
401
+ if (!app.isReady())
402
+ return;
403
+ const status = desktopStatusSnapshot();
404
+ refreshMenuBarStatus(status);
405
+ refreshFloatingWidget(status);
406
+ for (const window of BrowserWindow.getAllWindows()) {
407
+ if (!window.isDestroyed())
408
+ window.webContents.send('desktop:status-changed', status);
409
+ }
410
+ }
411
+ function updateDesktopPreferences(patch) {
412
+ const config = readDesktopConfig();
413
+ if (typeof patch.menuBarStatusEnabled === 'boolean') {
414
+ config.desktopPreferences.menuBarStatusEnabled = patch.menuBarStatusEnabled;
415
+ }
416
+ if (typeof patch.floatingWidgetEnabled === 'boolean') {
417
+ config.desktopPreferences.floatingWidgetEnabled = patch.floatingWidgetEnabled;
418
+ }
419
+ writeDesktopConfig(config);
420
+ refreshDesktopStatusSurfaces();
421
+ }
107
422
  function showDesktopMessageBox(options) {
108
423
  const parent = BrowserWindow.getFocusedWindow() ?? mainWindow;
109
424
  return parent
@@ -156,7 +471,30 @@ function defaultConfig() {
156
471
  version: 1,
157
472
  connections: [],
158
473
  lastConnectionUrl: null,
474
+ openConnectionUrls: [],
159
475
  trustedCertificateAuthorities: [],
476
+ desktopPreferences: {
477
+ menuBarStatusEnabled: true,
478
+ floatingWidgetEnabled: false,
479
+ floatingWidgetPosition: null,
480
+ },
481
+ };
482
+ }
483
+ function normalizeDesktopPreferences(value) {
484
+ const input = value && typeof value === 'object'
485
+ ? value
486
+ : {};
487
+ const position = input.floatingWidgetPosition;
488
+ return {
489
+ // The compact, text-only status is the useful default on macOS. Existing
490
+ // desktop.json files therefore gain the feature without a migration step.
491
+ menuBarStatusEnabled: input.menuBarStatusEnabled !== false,
492
+ floatingWidgetEnabled: input.floatingWidgetEnabled === true,
493
+ floatingWidgetPosition: position
494
+ && Number.isFinite(position.x)
495
+ && Number.isFinite(position.y)
496
+ ? { x: Math.round(position.x), y: Math.round(position.y) }
497
+ : null,
160
498
  };
161
499
  }
162
500
  function normalizeServiceUrl(raw) {
@@ -272,13 +610,20 @@ function readDesktopConfig() {
272
610
  const parsed = JSON.parse(fs.readFileSync(desktopConfigPath, 'utf8'));
273
611
  if (parsed.version !== 1 || !Array.isArray(parsed.connections))
274
612
  return defaultConfig();
613
+ const lastConnectionUrl = typeof parsed.lastConnectionUrl === 'string' ? parsed.lastConnectionUrl : null;
275
614
  return {
276
615
  version: 1,
277
616
  connections: parsed.connections.filter((entry) => Boolean(entry)
278
617
  && typeof entry.id === 'string'
279
618
  && typeof entry.label === 'string'
280
619
  && typeof entry.url === 'string'),
281
- lastConnectionUrl: typeof parsed.lastConnectionUrl === 'string' ? parsed.lastConnectionUrl : null,
620
+ lastConnectionUrl,
621
+ // Older desktop.json files only remembered the most recent window. Use
622
+ // that value once as their restore list, while preserving an explicit
623
+ // empty list when the user intentionally closed every service window.
624
+ openConnectionUrls: Array.isArray(parsed.openConnectionUrls)
625
+ ? parsed.openConnectionUrls.filter((url) => typeof url === 'string')
626
+ : lastConnectionUrl ? [lastConnectionUrl] : [],
282
627
  trustedCertificateAuthorities: Array.isArray(parsed.trustedCertificateAuthorities)
283
628
  ? parsed.trustedCertificateAuthorities.filter((entry) => Boolean(entry)
284
629
  && typeof entry.origin === 'string'
@@ -286,6 +631,7 @@ function readDesktopConfig() {
286
631
  && typeof entry.subject === 'string'
287
632
  && typeof entry.trustedAt === 'number')
288
633
  : [],
634
+ desktopPreferences: normalizeDesktopPreferences(parsed.desktopPreferences),
289
635
  };
290
636
  }
291
637
  catch {
@@ -616,6 +962,7 @@ async function snapshot() {
616
962
  localService,
617
963
  connections: config.connections,
618
964
  lastConnectionUrl: config.lastConnectionUrl,
965
+ desktopPreferences: config.desktopPreferences,
619
966
  };
620
967
  }
621
968
  function desktopRuntimeEnv(runtime = runtimePaths()) {
@@ -810,7 +1157,7 @@ async function installCli() {
810
1157
  ], { timeout: 120_000, maxBuffer: 256 * 1024 });
811
1158
  return snapshot();
812
1159
  }
813
- async function connectWindow(rawUrl) {
1160
+ async function connectWindow(rawUrl, options = {}) {
814
1161
  let probe = await probeServiceWithLocalNetworkPermission(rawUrl);
815
1162
  if (!probe.ok)
816
1163
  return probe;
@@ -819,28 +1166,67 @@ async function connectWindow(rawUrl) {
819
1166
  const existingWindow = serviceWindows.get(key);
820
1167
  if (existingWindow && !existingWindow.isDestroyed()) {
821
1168
  const config = readDesktopConfig();
822
- config.lastConnectionUrl = probe.url;
1169
+ if (options.updateLastConnection !== false)
1170
+ config.lastConnectionUrl = probe.url;
1171
+ config.openConnectionUrls = [
1172
+ ...config.openConnectionUrls.filter((url) => {
1173
+ try {
1174
+ return new URL(url).origin !== key;
1175
+ }
1176
+ catch {
1177
+ return false;
1178
+ }
1179
+ }),
1180
+ probe.url,
1181
+ ];
823
1182
  const existing = config.connections.find((entry) => entry.url === probe.url);
824
1183
  if (existing)
825
1184
  existing.lastConnectedAt = Date.now();
826
1185
  writeDesktopConfig(config);
827
- mainWindow?.hide();
828
- showAndFocusWindow(existingWindow);
1186
+ if (options.focus !== false) {
1187
+ mainWindow?.hide();
1188
+ showAndFocusWindow(existingWindow);
1189
+ }
1190
+ else {
1191
+ existingWindow.showInactive();
1192
+ }
829
1193
  return probe;
830
1194
  }
831
1195
  const workspaceWindow = createDesktopWindow({ serviceOrigin: key, label: serviceLabel(probe.url) });
832
1196
  serviceWindows.set(key, workspaceWindow);
1197
+ broadcastServiceActivity();
833
1198
  while (!workspaceWindow.isDestroyed()) {
834
1199
  try {
835
1200
  await workspaceWindow.loadURL(probe.url);
836
1201
  const config = readDesktopConfig();
837
- config.lastConnectionUrl = probe.url;
1202
+ if (options.updateLastConnection !== false)
1203
+ config.lastConnectionUrl = probe.url;
1204
+ config.openConnectionUrls = [
1205
+ ...config.openConnectionUrls.filter((url) => {
1206
+ try {
1207
+ return new URL(url).origin !== key;
1208
+ }
1209
+ catch {
1210
+ return false;
1211
+ }
1212
+ }),
1213
+ probe.url,
1214
+ ];
838
1215
  const existing = config.connections.find((entry) => entry.url === probe.url);
839
1216
  if (existing)
840
1217
  existing.lastConnectedAt = Date.now();
841
1218
  writeDesktopConfig(config);
842
- mainWindow?.hide();
843
- showAndFocusWindow(workspaceWindow);
1219
+ restorableServiceWindows.add(workspaceWindow);
1220
+ if (options.focus !== false) {
1221
+ mainWindow?.hide();
1222
+ showAndFocusWindow(workspaceWindow);
1223
+ }
1224
+ else {
1225
+ // Restored workspaces must be real visible macOS windows so App Expose
1226
+ // can select them, but opening several at launch must not steal focus
1227
+ // from each other.
1228
+ workspaceWindow.showInactive();
1229
+ }
844
1230
  return probe;
845
1231
  }
846
1232
  catch (error) {
@@ -1030,7 +1416,8 @@ function createDesktopWindow(options) {
1030
1416
  const serviceOrigin = windowServiceOrigins.get(window);
1031
1417
  if (serviceOrigin && serviceWindows.get(serviceOrigin) === window) {
1032
1418
  serviceWindows.delete(serviceOrigin);
1033
- serviceActivity.delete(serviceOrigin);
1419
+ reportedServiceActivity.delete(serviceOrigin);
1420
+ observedServiceActivity.delete(serviceOrigin);
1034
1421
  broadcastServiceActivity();
1035
1422
  }
1036
1423
  });
@@ -1042,6 +1429,19 @@ function createDesktopWindow(options) {
1042
1429
  clearUnreadNotifications();
1043
1430
  });
1044
1431
  window.on('close', (event) => {
1432
+ const serviceOrigin = windowServiceOrigins.get(window);
1433
+ if (serviceOrigin && !isQuitting && restorableServiceWindows.has(window)) {
1434
+ const config = readDesktopConfig();
1435
+ config.openConnectionUrls = config.openConnectionUrls.filter((url) => {
1436
+ try {
1437
+ return new URL(url).origin !== serviceOrigin;
1438
+ }
1439
+ catch {
1440
+ return false;
1441
+ }
1442
+ });
1443
+ writeDesktopConfig(config);
1444
+ }
1045
1445
  if (window === mainWindow && process.platform === 'darwin' && !isQuitting) {
1046
1446
  event.preventDefault();
1047
1447
  window.hide();
@@ -1085,18 +1485,28 @@ function installIpcHandlers() {
1085
1485
  const origin = windowServiceOrigins.get(sourceWindow);
1086
1486
  if (!origin)
1087
1487
  return;
1088
- const normalizeCount = (value) => Math.min(999, Math.max(0, typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 0));
1089
- const next = {
1090
- runningCount: normalizeCount(activity?.runningCount),
1091
- reviewCount: normalizeCount(activity?.reviewCount),
1092
- };
1093
- const current = serviceActivity.get(origin);
1488
+ const next = normalizeServiceActivity(activity);
1489
+ const current = reportedServiceActivity.get(origin);
1094
1490
  if (current?.runningCount === next.runningCount && current.reviewCount === next.reviewCount) {
1095
1491
  // The sender may have just reloaded and still needs the complete roster.
1096
1492
  sourceWindow.webContents.send('desktop:service-activity-changed', serviceActivitySnapshot(sourceWindow));
1097
1493
  return;
1098
1494
  }
1099
- serviceActivity.set(origin, next);
1495
+ reportedServiceActivity.set(origin, next);
1496
+ broadcastServiceActivity();
1497
+ });
1498
+ ipcMain.on('desktop:observe-service-activity', (event, activity) => {
1499
+ const sourceWindow = BrowserWindow.fromWebContents(event.sender);
1500
+ if (!sourceWindow)
1501
+ return;
1502
+ const origin = windowServiceOrigins.get(sourceWindow);
1503
+ if (!origin)
1504
+ return;
1505
+ const next = normalizeServiceActivity(activity);
1506
+ const current = observedServiceActivity.get(origin);
1507
+ if (current?.runningCount === next.runningCount && current.reviewCount === next.reviewCount)
1508
+ return;
1509
+ observedServiceActivity.set(origin, next);
1100
1510
  broadcastServiceActivity();
1101
1511
  });
1102
1512
  ipcMain.handle('desktop:focus-service', (event, origin) => {
@@ -1111,6 +1521,36 @@ function installIpcHandlers() {
1111
1521
  return true;
1112
1522
  });
1113
1523
  ipcMain.handle('desktop:start-local', () => startLocalService());
1524
+ ipcMain.handle('desktop:update-preferences', async (_event, preferences) => {
1525
+ updateDesktopPreferences({
1526
+ ...(typeof preferences?.menuBarStatusEnabled === 'boolean'
1527
+ ? { menuBarStatusEnabled: preferences.menuBarStatusEnabled }
1528
+ : {}),
1529
+ ...(typeof preferences?.floatingWidgetEnabled === 'boolean'
1530
+ ? { floatingWidgetEnabled: preferences.floatingWidgetEnabled }
1531
+ : {}),
1532
+ });
1533
+ return snapshot();
1534
+ });
1535
+ ipcMain.handle('desktop:status-snapshot', () => desktopStatusSnapshot());
1536
+ ipcMain.handle('desktop:focus-next-service', (_event, scope) => {
1537
+ const normalized = scope === 'running'
1538
+ || scope === 'review'
1539
+ || scope === 'all'
1540
+ ? scope
1541
+ : 'attention';
1542
+ focusNextService(normalized);
1543
+ });
1544
+ ipcMain.handle('desktop:set-floating-metric-count', (event, count) => {
1545
+ const sourceWindow = BrowserWindow.fromWebContents(event.sender);
1546
+ if (sourceWindow !== floatingWidgetWindow || typeof count !== 'number' || !Number.isFinite(count))
1547
+ return;
1548
+ resizeFloatingWidget(count);
1549
+ });
1550
+ ipcMain.handle('desktop:disable-floating-widget', async () => {
1551
+ updateDesktopPreferences({ floatingWidgetEnabled: false });
1552
+ return snapshot();
1553
+ });
1114
1554
  ipcMain.handle('desktop:install-cli', () => installCli());
1115
1555
  ipcMain.handle('desktop:update-state', () => getDesktopUpdateState());
1116
1556
  ipcMain.handle('desktop:check-update', () => checkForDesktopUpdates());
@@ -1388,6 +1828,9 @@ app.whenReady().then(async () => {
1388
1828
  configureLocalServiceCertificateTrust();
1389
1829
  installIpcHandlers();
1390
1830
  installMenu();
1831
+ refreshDesktopStatusSurfaces();
1832
+ screen.on('display-removed', keepFloatingWidgetOnScreen);
1833
+ screen.on('display-metrics-changed', keepFloatingWidgetOnScreen);
1391
1834
  configureDesktopUpdater(showDesktopMessageBox);
1392
1835
  subscribeDesktopUpdateState((state) => {
1393
1836
  for (const window of BrowserWindow.getAllWindows()) {
@@ -1395,11 +1838,27 @@ app.whenReady().then(async () => {
1395
1838
  window.webContents.send('desktop:update-state-changed', state);
1396
1839
  }
1397
1840
  });
1398
- const lastConnectionUrl = readDesktopConfig().lastConnectionUrl;
1399
- if (lastConnectionUrl) {
1400
- const probe = await connectWindow(lastConnectionUrl);
1401
- if (!probe.ok)
1402
- await showConnectionCenter();
1841
+ const startupConfig = readDesktopConfig();
1842
+ const restoreUrls = startupConfig.openConnectionUrls;
1843
+ let restoredWindowCount = 0;
1844
+ for (const url of restoreUrls) {
1845
+ const probe = await connectWindow(url, { focus: false, updateLastConnection: false });
1846
+ if (probe.ok)
1847
+ restoredWindowCount += 1;
1848
+ }
1849
+ if (restoredWindowCount > 0) {
1850
+ let preferredOrigin = null;
1851
+ try {
1852
+ preferredOrigin = startupConfig.lastConnectionUrl
1853
+ ? new URL(startupConfig.lastConnectionUrl).origin
1854
+ : null;
1855
+ }
1856
+ catch {
1857
+ // A malformed legacy value must not prevent the other saved windows
1858
+ // from being restored.
1859
+ }
1860
+ const preferredWindow = preferredOrigin ? serviceWindows.get(preferredOrigin) : null;
1861
+ showAndFocusWindow(preferredWindow ?? [...serviceWindows.values()][0]);
1403
1862
  }
1404
1863
  else {
1405
1864
  await showConnectionCenter();