@cats-inc/cats-platform 0.1.1 → 0.1.7

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 (203) hide show
  1. package/.env.example +2 -2
  2. package/README.md +6 -0
  3. package/build/desktop/bootstrapPage.d.ts.map +1 -1
  4. package/build/desktop/bootstrapPage.js +81 -4
  5. package/build/desktop/bootstrapPage.js.map +1 -1
  6. package/build/desktop/config.d.ts +11 -1
  7. package/build/desktop/config.d.ts.map +1 -1
  8. package/build/desktop/config.js +5 -2
  9. package/build/desktop/config.js.map +1 -1
  10. package/build/desktop/contracts.d.ts +68 -11
  11. package/build/desktop/contracts.d.ts.map +1 -1
  12. package/build/desktop/contracts.js +52 -1
  13. package/build/desktop/contracts.js.map +1 -1
  14. package/build/desktop/hostState.d.ts +4 -3
  15. package/build/desktop/hostState.d.ts.map +1 -1
  16. package/build/desktop/hostState.js +22 -30
  17. package/build/desktop/hostState.js.map +1 -1
  18. package/build/desktop/hostVersion.d.ts +1 -0
  19. package/build/desktop/hostVersion.d.ts.map +1 -1
  20. package/build/desktop/hostVersion.js +29 -5
  21. package/build/desktop/hostVersion.js.map +1 -1
  22. package/build/desktop/ipcSecurity.d.ts +6 -0
  23. package/build/desktop/ipcSecurity.d.ts.map +1 -0
  24. package/build/desktop/ipcSecurity.js +10 -0
  25. package/build/desktop/ipcSecurity.js.map +1 -0
  26. package/build/desktop/main.js +272 -59
  27. package/build/desktop/main.js.map +1 -1
  28. package/build/desktop/packaging.d.ts.map +1 -1
  29. package/build/desktop/packaging.js +10 -4
  30. package/build/desktop/packaging.js.map +1 -1
  31. package/build/desktop/preload.cjs +39 -0
  32. package/build/desktop/preload.cjs.map +1 -1
  33. package/build/desktop/processSupervisor.d.ts +2 -0
  34. package/build/desktop/processSupervisor.d.ts.map +1 -1
  35. package/build/desktop/processSupervisor.js +28 -13
  36. package/build/desktop/processSupervisor.js.map +1 -1
  37. package/build/desktop/readiness.d.ts +2 -2
  38. package/build/desktop/readiness.d.ts.map +1 -1
  39. package/build/desktop/readiness.js +2 -2
  40. package/build/desktop/readiness.js.map +1 -1
  41. package/build/desktop/releaseDescriptor.d.ts +85 -0
  42. package/build/desktop/releaseDescriptor.d.ts.map +1 -0
  43. package/build/desktop/releaseDescriptor.js +246 -0
  44. package/build/desktop/releaseDescriptor.js.map +1 -0
  45. package/build/desktop/screenshotCapture.d.ts.map +1 -1
  46. package/build/desktop/screenshotCapture.js +3 -7
  47. package/build/desktop/screenshotCapture.js.map +1 -1
  48. package/build/desktop/security.d.ts +8 -0
  49. package/build/desktop/security.d.ts.map +1 -1
  50. package/build/desktop/security.js +54 -1
  51. package/build/desktop/security.js.map +1 -1
  52. package/build/desktop/tray.d.ts +1 -0
  53. package/build/desktop/tray.d.ts.map +1 -1
  54. package/build/desktop/tray.js +26 -0
  55. package/build/desktop/tray.js.map +1 -1
  56. package/build/desktop/trayMenu.d.ts +22 -1
  57. package/build/desktop/trayMenu.d.ts.map +1 -1
  58. package/build/desktop/trayMenu.js +61 -0
  59. package/build/desktop/trayMenu.js.map +1 -1
  60. package/build/desktop/updateInstallHandoff.d.ts +30 -0
  61. package/build/desktop/updateInstallHandoff.d.ts.map +1 -0
  62. package/build/desktop/updateInstallHandoff.js +39 -0
  63. package/build/desktop/updateInstallHandoff.js.map +1 -0
  64. package/build/desktop/updateIpc.d.ts +42 -0
  65. package/build/desktop/updateIpc.d.ts.map +1 -0
  66. package/build/desktop/updateIpc.js +71 -0
  67. package/build/desktop/updateIpc.js.map +1 -0
  68. package/build/desktop/updateManager.d.ts +76 -0
  69. package/build/desktop/updateManager.d.ts.map +1 -0
  70. package/build/desktop/updateManager.js +342 -0
  71. package/build/desktop/updateManager.js.map +1 -0
  72. package/build/desktop/updateNotifications.d.ts +64 -0
  73. package/build/desktop/updateNotifications.d.ts.map +1 -0
  74. package/build/desktop/updateNotifications.js +143 -0
  75. package/build/desktop/updateNotifications.js.map +1 -0
  76. package/build/desktop/updaterAdapter.d.ts +72 -0
  77. package/build/desktop/updaterAdapter.d.ts.map +1 -0
  78. package/build/desktop/updaterAdapter.js +198 -0
  79. package/build/desktop/updaterAdapter.js.map +1 -0
  80. package/build/desktop/voiceCapture.d.ts.map +1 -1
  81. package/build/desktop/voiceCapture.js +3 -7
  82. package/build/desktop/voiceCapture.js.map +1 -1
  83. package/build/renderer/assets/ActiveSessionPermissionChip-BRMLwkvf.js +1 -0
  84. package/build/renderer/assets/{App-BbuTWT6Y.js → App-BM2nzo6s.js} +1 -1
  85. package/build/renderer/assets/{App-n-RtGh42.js → App-CVQAwCMi.js} +1 -1
  86. package/build/renderer/assets/{App-B13AzNko.js → App-Dl5FLaR_.js} +1 -1
  87. package/build/renderer/assets/{WorkspaceAppRoutes-BktWrQK-.js → WorkspaceAppRoutes-C3DUzdbj.js} +1 -1
  88. package/build/renderer/assets/app-hP5h2jAt.js +43 -0
  89. package/build/renderer/assets/{client-DREGSZqQ.js → client-BdGHAUoK.js} +1 -1
  90. package/build/renderer/assets/{conversationSidebarRecentEntries-BNPz3X1V.js → conversationSidebarRecentEntries-B-mNvfiy.js} +1 -1
  91. package/build/renderer/assets/{screenshotOverlay-EF07cXvw.js → screenshotOverlay-DfBcolX-.js} +1 -1
  92. package/build/renderer/assets/{useDraftSessionChips-3z_Je4ju.js → useDraftSessionChips-DuXIoptW.js} +1 -1
  93. package/build/renderer/desktop/overlay/index.html +2 -2
  94. package/build/renderer/index.html +2 -2
  95. package/build/server/app/renderer/settings/settingsDesktopErrorLabels.d.ts +5 -0
  96. package/build/server/app/renderer/settings/settingsDesktopErrorLabels.d.ts.map +1 -0
  97. package/build/server/app/renderer/settings/settingsDesktopErrorLabels.js +16 -0
  98. package/build/server/app/renderer/settings/settingsDesktopErrorLabels.js.map +1 -0
  99. package/build/server/app/renderer/settings/settingsDesktopPreferences.d.ts +11 -0
  100. package/build/server/app/renderer/settings/settingsDesktopPreferences.d.ts.map +1 -0
  101. package/build/server/app/renderer/settings/settingsDesktopPreferences.js +30 -0
  102. package/build/server/app/renderer/settings/settingsDesktopPreferences.js.map +1 -0
  103. package/build/server/app/renderer/settings/settingsDesktopUpdateLabels.d.ts +31 -0
  104. package/build/server/app/renderer/settings/settingsDesktopUpdateLabels.d.ts.map +1 -0
  105. package/build/server/app/renderer/settings/settingsDesktopUpdateLabels.js +101 -0
  106. package/build/server/app/renderer/settings/settingsDesktopUpdateLabels.js.map +1 -0
  107. package/build/server/app/server/authGatePolicy.js +14 -2
  108. package/build/server/app/server/authGatePolicy.js.map +1 -1
  109. package/build/server/app/server/authRoutes.d.ts +2 -1
  110. package/build/server/app/server/authRoutes.d.ts.map +1 -1
  111. package/build/server/app/server/authRoutes.js +260 -4
  112. package/build/server/app/server/authRoutes.js.map +1 -1
  113. package/build/server/app/server/contracts.d.ts +3 -1
  114. package/build/server/app/server/contracts.d.ts.map +1 -1
  115. package/build/server/app/server/dependencies.d.ts.map +1 -1
  116. package/build/server/app/server/dependencies.js +4 -1
  117. package/build/server/app/server/dependencies.js.map +1 -1
  118. package/build/server/app/server/index.js +1 -1
  119. package/build/server/app/server/index.js.map +1 -1
  120. package/build/server/app/server/platformSetupRoutes.d.ts.map +1 -1
  121. package/build/server/app/server/platformSetupRoutes.js +42 -5
  122. package/build/server/app/server/platformSetupRoutes.js.map +1 -1
  123. package/build/server/app/server/requestRouter.d.ts.map +1 -1
  124. package/build/server/app/server/requestRouter.js +3 -0
  125. package/build/server/app/server/requestRouter.js.map +1 -1
  126. package/build/server/design/components/runtimeChips.d.ts +1 -1
  127. package/build/server/design/components/runtimeChips.d.ts.map +1 -1
  128. package/build/server/design/components/runtimeChips.js +4 -1
  129. package/build/server/design/components/runtimeChips.js.map +1 -1
  130. package/build/server/index.js +12 -1
  131. package/build/server/index.js.map +1 -1
  132. package/build/server/platform/auth/browserHandoff.d.ts +50 -0
  133. package/build/server/platform/auth/browserHandoff.d.ts.map +1 -0
  134. package/build/server/platform/auth/browserHandoff.js +94 -0
  135. package/build/server/platform/auth/browserHandoff.js.map +1 -0
  136. package/build/server/platform/auth/index.d.ts +1 -0
  137. package/build/server/platform/auth/index.d.ts.map +1 -1
  138. package/build/server/platform/auth/index.js +1 -0
  139. package/build/server/platform/auth/index.js.map +1 -1
  140. package/build/server/platform/auth/principal.js +2 -2
  141. package/build/server/platform/auth/principal.js.map +1 -1
  142. package/build/server/platform/auth/session.d.ts +3 -0
  143. package/build/server/platform/auth/session.d.ts.map +1 -1
  144. package/build/server/platform/auth/session.js +41 -0
  145. package/build/server/platform/auth/session.js.map +1 -1
  146. package/build/server/platform/auth/sessionSecretProvisioning.d.ts +23 -0
  147. package/build/server/platform/auth/sessionSecretProvisioning.d.ts.map +1 -0
  148. package/build/server/platform/auth/sessionSecretProvisioning.js +291 -0
  149. package/build/server/platform/auth/sessionSecretProvisioning.js.map +1 -0
  150. package/build/server/platform/auth/state.js +4 -0
  151. package/build/server/platform/auth/state.js.map +1 -1
  152. package/build/server/platform/auth/types.d.ts +1 -0
  153. package/build/server/platform/auth/types.d.ts.map +1 -1
  154. package/build/server/products/code/livePreview/realProcessAdapter.d.ts.map +1 -1
  155. package/build/server/products/code/livePreview/realProcessAdapter.js +7 -1
  156. package/build/server/products/code/livePreview/realProcessAdapter.js.map +1 -1
  157. package/build/server/products/code/state/appExport.d.ts.map +1 -1
  158. package/build/server/products/code/state/appExport.js +2 -0
  159. package/build/server/products/code/state/appExport.js.map +1 -1
  160. package/build/server/shared/browserHandoff.d.ts +11 -0
  161. package/build/server/shared/browserHandoff.d.ts.map +1 -0
  162. package/build/server/shared/browserHandoff.js +2 -0
  163. package/build/server/shared/browserHandoff.js.map +1 -0
  164. package/build/server/shared/desktopRecoveryBridge.d.ts +47 -0
  165. package/build/server/shared/desktopRecoveryBridge.d.ts.map +1 -1
  166. package/build/server/shared/desktopRecoveryBridge.js +20 -0
  167. package/build/server/shared/desktopRecoveryBridge.js.map +1 -1
  168. package/build/server/shared/i18n/catalogs/en.d.ts.map +1 -1
  169. package/build/server/shared/i18n/catalogs/en.js +38 -0
  170. package/build/server/shared/i18n/catalogs/en.js.map +1 -1
  171. package/build/server/shared/i18n/catalogs/zh-TW.d.ts.map +1 -1
  172. package/build/server/shared/i18n/catalogs/zh-TW.js +36 -0
  173. package/build/server/shared/i18n/catalogs/zh-TW.js.map +1 -1
  174. package/build/server/shared/i18n/index.d.ts +36 -0
  175. package/build/server/shared/i18n/index.d.ts.map +1 -1
  176. package/build/server/shared/i18n/messageKeys.d.ts +36 -0
  177. package/build/server/shared/i18n/messageKeys.d.ts.map +1 -1
  178. package/build/server/shared/i18n/messageKeys.js +36 -0
  179. package/build/server/shared/i18n/messageKeys.js.map +1 -1
  180. package/build/server/shared/loadProjectEnvFile.d.ts.map +1 -1
  181. package/build/server/shared/loadProjectEnvFile.js +18 -19
  182. package/build/server/shared/loadProjectEnvFile.js.map +1 -1
  183. package/build/server/shared/platformChildProcessEnv.d.ts +2 -0
  184. package/build/server/shared/platformChildProcessEnv.d.ts.map +1 -0
  185. package/build/server/shared/platformChildProcessEnv.js +22 -0
  186. package/build/server/shared/platformChildProcessEnv.js.map +1 -0
  187. package/package.json +19 -27
  188. package/scripts/build-desktop-installer.mjs +438 -37
  189. package/scripts/build-test-ui.mjs +13 -2
  190. package/scripts/generate-desktop-release-descriptor.mjs +258 -0
  191. package/scripts/validate-release-assets.mjs +422 -0
  192. package/scripts/validate-release-version.mjs +217 -0
  193. package/scripts/windows/Test-WindowsInstallerSmoke.ps1 +55 -1
  194. package/build/desktop/update.d.ts +0 -24
  195. package/build/desktop/update.d.ts.map +0 -1
  196. package/build/desktop/update.js +0 -173
  197. package/build/desktop/update.js.map +0 -1
  198. package/build/renderer/assets/ActiveSessionPermissionChip-BYr8cDpb.js +0 -1
  199. package/build/renderer/assets/app-DnVQMpW_.js +0 -43
  200. package/build/server/app/renderer/settings/settingsDesktopStartupErrorLabels.d.ts +0 -5
  201. package/build/server/app/renderer/settings/settingsDesktopStartupErrorLabels.d.ts.map +0 -1
  202. package/build/server/app/renderer/settings/settingsDesktopStartupErrorLabels.js +0 -11
  203. package/build/server/app/renderer/settings/settingsDesktopStartupErrorLabels.js.map +0 -1
@@ -1,14 +1,16 @@
1
1
  import { dirname, join } from 'node:path';
2
- import { app, BrowserWindow, ipcMain, session, shell, systemPreferences } from 'electron';
2
+ import { app, BrowserWindow, ipcMain, Notification, session, shell, systemPreferences, } from 'electron';
3
3
  import { buildDesktopBootstrapPage } from './bootstrapPage.js';
4
4
  import { resolveDesktopBootstrapNavigation, shouldRevealDesktopBootstrapRecovery, shouldNavigateDesktopBootstrap, resolveDesktopWindowRevealNavigation, shouldAllowDesktopBootstrapWindowNavigation, } from './bootstrapNavigation.js';
5
5
  import { resolveCatsHomeDir, resolveDesktopHostConfig, resolveDesktopUserDataDir, } from './config.js';
6
+ import { DESKTOP_BROWSER_HANDOFF_OPEN_CHANNEL } from './contracts.js';
6
7
  import { appendHostEvent, appendRuntimeEvent, buildDesktopAggregationBundle, createBootstrapAttemptId, createDesktopBootstrapEvent, createEmptyDesktopDiagnosticsState, toDesktopBootstrapError, toDesktopBootstrapStatus, updateServiceLogs, } from './bootstrapDiagnostics.js';
7
- import { createDesktopBackgroundState, DesktopHostStateStore } from './hostState.js';
8
+ import { createDesktopBackgroundState, DesktopHostStateStore, readPersistedUpdateLastCheckedAt, } from './hostState.js';
8
9
  import { createDesktopPackagingPlan } from './packaging.js';
9
10
  import { ManagedServiceSupervisor } from './processSupervisor.js';
10
11
  import { buildDesktopBootstrapSnapshot, fetchJson, } from './readiness.js';
11
- import { isDesktopHostActionId, validateDesktopUrl } from './security.js';
12
+ import { isDesktopAppOriginUrl, isDesktopHostActionId, resolveDesktopBrowserHandoffUrl, validateDesktopUrl, } from './security.js';
13
+ import { assertMainWindowIpcSender } from './ipcSecurity.js';
12
14
  import { readPersistedSetupCompletionState, } from './persistedSetupState.js';
13
15
  import { buildDesktopSetupSnapshot, createEmptyDesktopSetupState, runDesktopSetupHelper, shouldAutoRunSetupAudit, } from './setupBridge.js';
14
16
  import { isDesktopBootstrapLoadingPhase, resolveDesktopBootstrapError, shouldAttemptDesktopLateReadyRecovery, } from './startupRecovery.js';
@@ -18,7 +20,13 @@ import { createDesktopTrayController, } from './tray.js';
18
20
  import { buildDesktopTrayMenuState as buildElectronTrayMenuState, buildDesktopTrayQuittingMenuState, } from './trayMenu.js';
19
21
  import { applyDesktopHostPlatformShellUpdate, normalizePlatformShellSetupState, parseDesktopHostPlatformShellUpdate, } from './platformShellUpdate.js';
20
22
  import { enableDesktopMobilePairingEnv } from './mobilePairingEnv.js';
21
- import { checkForDesktopUpdates, createDefaultDesktopUpdateState } from './update.js';
23
+ import { DESKTOP_APP_USER_MODEL_ID, DESKTOP_HOST_VERSION } from './hostVersion.js';
24
+ import { loadDesktopReleaseDescriptor, resolveDesktopDistributionIdentity, } from './releaseDescriptor.js';
25
+ import { createDesktopUpdateCapability, createDesktopUpdateManager, createUnavailableDesktopUpdateSnapshot, } from './updateManager.js';
26
+ import { configureElectronUpdater, createElectronUpdaterAdapter, resolveAutoUpdaterExport, } from './updaterAdapter.js';
27
+ import { createDesktopUpdateIpcHandlers, createDesktopUpdateSnapshotBroadcast, } from './updateIpc.js';
28
+ import { withDesktopInstallHandoff } from './updateInstallHandoff.js';
29
+ import { DESKTOP_UPDATE_SETTINGS_PATH, resolveDesktopUpdateAnnouncement, } from './updateNotifications.js';
22
30
  import { applyDesktopWindowChrome, resolveDesktopWindowChromeOptions, } from './windowChrome.js';
23
31
  import { resolveDesktopWindowIconPath } from './windowIcon.js';
24
32
  import { readDesktopStartupPreferences, resolveDesktopStartupLaunchContext, syncDesktopStartupPreferences, updateDesktopStartupPreferences, DESKTOP_LAUNCH_AT_LOGIN_ARG, } from './desktopStartup.js';
@@ -64,6 +72,8 @@ let trayController = null;
64
72
  let stateStore = null;
65
73
  let backgroundState = null;
66
74
  let updateState = null;
75
+ let updateManager = null;
76
+ let detachUpdateBroadcast = null;
67
77
  let packagingState = null;
68
78
  let setupState = null;
69
79
  let diagnosticsState = null;
@@ -229,16 +239,29 @@ function resolveAllowedAppHosts(config) {
229
239
  return Array.from(new Set([config.appHost, appBaseHost]));
230
240
  }
231
241
  function shouldAllowInAppNavigation(rawUrl, config) {
232
- try {
233
- const currentAppUrl = new URL(config.appBaseUrl);
234
- const nextUrl = new URL(validateDesktopUrl(rawUrl, {
235
- allowedHosts: resolveAllowedAppHosts(config),
236
- }));
237
- return nextUrl.origin === currentAppUrl.origin;
238
- }
239
- catch {
240
- return false;
241
- }
242
+ return isDesktopAppOriginUrl(rawUrl, {
243
+ appBaseUrl: config.appBaseUrl,
244
+ allowedHosts: resolveAllowedAppHosts(config),
245
+ });
246
+ }
247
+ function configureDesktopWindowNavigation(window, config) {
248
+ window.webContents.setWindowOpenHandler(({ url }) => {
249
+ if (shouldAllowInAppNavigation(url, config)) {
250
+ return { action: 'allow' };
251
+ }
252
+ void openExternalDesktopUrl(url).catch(reportExternalUrlOpenFailure);
253
+ return { action: 'deny' };
254
+ });
255
+ window.webContents.on('will-navigate', (event, url) => {
256
+ if (shouldAllowInAppNavigation(url, config)) {
257
+ return;
258
+ }
259
+ event.preventDefault();
260
+ void openExternalDesktopUrl(url).catch(reportExternalUrlOpenFailure);
261
+ });
262
+ window.webContents.on('did-create-window', (childWindow) => {
263
+ configureDesktopWindowNavigation(childWindow, config);
264
+ });
242
265
  }
243
266
  async function ensureBootstrapPageVisible() {
244
267
  if (!mainWindow) {
@@ -582,6 +605,7 @@ function resolveDesktopTrayMenuState(snapshot) {
582
605
  actions: snapshot.actions,
583
606
  products: latestAppShellPayload?.products,
584
607
  locale: app.getLocale(),
608
+ updates: updateState,
585
609
  });
586
610
  }
587
611
  function isSystemTrayEnabled() {
@@ -707,6 +731,14 @@ function buildTrayControllerOptions() {
707
731
  void shutdownHost();
708
732
  },
709
733
  canInteract: () => !shuttingDown,
734
+ onCheckForUpdates: async () => {
735
+ if (shuttingDown) {
736
+ return;
737
+ }
738
+ // The menu closes on click, so this result has to reach the user through
739
+ // a notification or by opening Settings.
740
+ await refreshUpdateState('tray');
741
+ },
710
742
  };
711
743
  }
712
744
  async function syncTrayController() {
@@ -1197,24 +1229,151 @@ async function maybeRecoverFromLateReadyStateChange() {
1197
1229
  })();
1198
1230
  await lateReadyRecoveryPromise;
1199
1231
  }
1200
- async function refreshUpdateState() {
1201
- if (!hostConfig) {
1202
- throw new Error('Desktop host is not initialized.');
1232
+ /**
1233
+ * Builds the update manager for this launch.
1234
+ *
1235
+ * Capability is derived from the embedded release descriptor plus the running
1236
+ * package, never from the environment. When the build is not an official
1237
+ * release the adapter is omitted entirely, so no provider is ever configured.
1238
+ */
1239
+ async function createUpdateManagerForLaunch(config, initialLastCheckedAt = null) {
1240
+ const descriptor = await loadDesktopReleaseDescriptor();
1241
+ const identity = resolveDesktopDistributionIdentity({
1242
+ isPackaged: config.packaged,
1243
+ currentVersion: DESKTOP_HOST_VERSION,
1244
+ nodePlatform: process.platform,
1245
+ descriptor,
1246
+ });
1247
+ const capability = createDesktopUpdateCapability({
1248
+ identity,
1249
+ nodePlatform: process.platform,
1250
+ });
1251
+ let adapter = null;
1252
+ if (capability.canCheck && identity.repository !== null) {
1253
+ // Self-update is a feature, not a startup requirement. If the provider
1254
+ // cannot be constructed the app must still open with update capability
1255
+ // switched off, rather than failing to launch at all.
1256
+ try {
1257
+ const autoUpdater = resolveAutoUpdaterExport(await import('electron-updater'));
1258
+ configureElectronUpdater(autoUpdater, {
1259
+ repository: identity.repository,
1260
+ channel: capability.channel,
1261
+ distribution: capability.distribution,
1262
+ });
1263
+ adapter = withDesktopInstallHandoff(createElectronUpdaterAdapter(autoUpdater), {
1264
+ drainManagedServices: async () => {
1265
+ // The tray says "Quitting" for the same reason shutdown does: from
1266
+ // here the app is on its way out, and a menu offering to open Cats
1267
+ // would be a lie while the sidecars are stopping. This also locks the
1268
+ // menu against repeated interaction without touching `shuttingDown`,
1269
+ // which gates far more behaviour and is never designed to be reset.
1270
+ trayController?.updateMenu(buildDesktopTrayQuittingMenuState(app.getLocale()));
1271
+ await drainManagedServices();
1272
+ // electron-updater quits the app itself. Without this the before-quit
1273
+ // handler would cancel that quit and run a second drain, delaying the
1274
+ // exit the installer is waiting on. The drain above already satisfied
1275
+ // the contract that flag stands for.
1276
+ exitingAfterShutdown = true;
1277
+ },
1278
+ restartManagedServices: async () => {
1279
+ // The handoff failed but the process survived, so the exit is no
1280
+ // longer prepared, the sidecars have to come back, and the tray has to
1281
+ // stop claiming we are quitting. startAll() intentionally reuses the
1282
+ // supervisor-lifetime environment snapshot populated during the first
1283
+ // bootstrap, including the Desktop-provisioned platform auth secret.
1284
+ exitingAfterShutdown = false;
1285
+ await supervisor?.startAll();
1286
+ await syncTrayController();
1287
+ },
1288
+ logger: (message) => {
1289
+ process.stdout.write(`${message}\n`);
1290
+ },
1291
+ });
1292
+ }
1293
+ catch (error) {
1294
+ process.stderr.write('[desktop-update] updater unavailable, continuing without self-update: '
1295
+ + `${error instanceof Error ? error.message : String(error)}\n`);
1296
+ adapter = null;
1297
+ }
1203
1298
  }
1204
- updateState = {
1205
- ...(updateState ?? createDefaultDesktopUpdateState(hostConfig.update)),
1206
- status: hostConfig.update.manifestUrl ? 'checking' : 'disabled',
1207
- summary: hostConfig.update.manifestUrl
1208
- ? 'Checking for desktop updates.'
1209
- : 'Update checks are disabled until a manifest URL is configured.',
1210
- };
1211
- if (latestSnapshot) {
1212
- publishSnapshot(latestSnapshot);
1299
+ return createDesktopUpdateManager({
1300
+ capability,
1301
+ adapter,
1302
+ initialLastCheckedAt,
1303
+ logger: (message) => {
1304
+ process.stdout.write(`${message}\n`);
1305
+ },
1306
+ });
1307
+ }
1308
+ async function openDesktopUpdateSettings() {
1309
+ if (shuttingDown || !hostConfig) {
1310
+ return;
1213
1311
  }
1214
- updateState = await checkForDesktopUpdates(hostConfig.update);
1215
- if (latestSnapshot) {
1216
- publishSnapshot(latestSnapshot);
1312
+ await showMainWindow(`${hostConfig.appBaseUrl}${DESKTOP_UPDATE_SETTINGS_PATH}`);
1313
+ }
1314
+ /**
1315
+ * Shows a native notification, reporting whether the user will actually see it.
1316
+ *
1317
+ * A false return sends the caller to the navigation fallback. Notifying is not
1318
+ * worth failing a check over, so a throwing platform is treated the same as one
1319
+ * without notification support.
1320
+ */
1321
+ function showDesktopUpdateNotification(notification) {
1322
+ try {
1323
+ const native = new Notification({
1324
+ title: notification.title,
1325
+ body: notification.body,
1326
+ });
1327
+ native.on('click', () => {
1328
+ void openDesktopUpdateSettings();
1329
+ });
1330
+ native.show();
1331
+ return true;
1332
+ }
1333
+ catch (error) {
1334
+ process.stderr.write('[desktop-update] native notification unavailable: '
1335
+ + `${error instanceof Error ? error.message : String(error)}\n`);
1336
+ return false;
1337
+ }
1338
+ }
1339
+ function areNativeNotificationsSupported() {
1340
+ try {
1341
+ return Notification.isSupported();
1342
+ }
1343
+ catch {
1344
+ return false;
1345
+ }
1346
+ }
1347
+ async function announceDesktopUpdateResult(origin, snapshot) {
1348
+ if (!snapshot || shuttingDown) {
1349
+ return;
1350
+ }
1351
+ const announcement = resolveDesktopUpdateAnnouncement({
1352
+ origin,
1353
+ snapshot,
1354
+ locale: app.getLocale(),
1355
+ notificationsSupported: areNativeNotificationsSupported(),
1356
+ });
1357
+ if (announcement.notification && showDesktopUpdateNotification(announcement.notification)) {
1358
+ return;
1359
+ }
1360
+ if (announcement.fallbackNavigatePath) {
1361
+ await openDesktopUpdateSettings();
1217
1362
  }
1363
+ }
1364
+ /**
1365
+ * Runs a check and reports its result to whoever asked for it.
1366
+ *
1367
+ * The origin is required rather than defaulted: it decides whether the result
1368
+ * is announced at all, and a new caller silently inheriting another surface's
1369
+ * policy is the failure this signature exists to prevent.
1370
+ */
1371
+ async function refreshUpdateState(origin) {
1372
+ if (!updateManager) {
1373
+ return null;
1374
+ }
1375
+ updateState = await updateManager.checkForUpdates();
1376
+ await announceDesktopUpdateResult(origin, updateState);
1218
1377
  return updateState;
1219
1378
  }
1220
1379
  async function bootstrapDesktopHost(restartServices = false) {
@@ -1448,17 +1607,7 @@ async function createMainWindow(config, options) {
1448
1607
  sandbox: true,
1449
1608
  },
1450
1609
  });
1451
- window.webContents.setWindowOpenHandler(({ url }) => {
1452
- void openExternalDesktopUrl(url).catch(reportExternalUrlOpenFailure);
1453
- return { action: 'deny' };
1454
- });
1455
- window.webContents.on('will-navigate', (event, url) => {
1456
- if (shouldAllowInAppNavigation(url, config)) {
1457
- return;
1458
- }
1459
- event.preventDefault();
1460
- void openExternalDesktopUrl(url).catch(reportExternalUrlOpenFailure);
1461
- });
1610
+ configureDesktopWindowNavigation(window, config);
1462
1611
  applyDesktopWindowChrome(window);
1463
1612
  const showBootstrapWindow = () => {
1464
1613
  if (!options.showWindowOnStartup || window.isDestroyed() || window.isVisible()) {
@@ -1488,6 +1637,33 @@ function waitForShutdownDeadline(ms) {
1488
1637
  timer.unref?.();
1489
1638
  });
1490
1639
  }
1640
+ /**
1641
+ * Stops managed services, forcing them down if the graceful path overruns.
1642
+ *
1643
+ * Shared by host shutdown and the update installer handoff, because both need
1644
+ * the sidecars actually gone — the installer for the same reason the exit path
1645
+ * does, plus the Windows upgrade running the old uninstaller over files a
1646
+ * surviving sidecar may still hold.
1647
+ */
1648
+ async function drainManagedServices() {
1649
+ const drain = supervisor?.stopAll() ?? Promise.resolve();
1650
+ const shutdownWatchdogMs = hostConfig
1651
+ ? resolveShutdownWatchdogMs(hostConfig, supervisor?.getManagedServiceCount() ?? 0)
1652
+ : SHUTDOWN_WATCHDOG_BUFFER_MS;
1653
+ const result = await Promise.race([
1654
+ drain.then(() => 'drained'),
1655
+ waitForShutdownDeadline(shutdownWatchdogMs),
1656
+ ]);
1657
+ if (result !== 'timed_out') {
1658
+ return { timedOut: false };
1659
+ }
1660
+ process.stderr.write(`Desktop host shutdown watchdog tripped after ${shutdownWatchdogMs}ms; forcing managed services down.\n`);
1661
+ await supervisor?.forceStopAll();
1662
+ void drain.catch((error) => {
1663
+ process.stderr.write(`Desktop host graceful shutdown failed after watchdog: ${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
1664
+ });
1665
+ return { timedOut: true };
1666
+ }
1491
1667
  async function shutdownHost() {
1492
1668
  if (shutdownPromise) {
1493
1669
  return shutdownPromise;
@@ -1502,22 +1678,10 @@ async function shutdownHost() {
1502
1678
  voiceCaptureController?.dispose();
1503
1679
  voiceCaptureController = null;
1504
1680
  activeTrayController?.updateMenu(buildDesktopTrayQuittingMenuState(app.getLocale()));
1505
- const drain = supervisor?.stopAll() ?? Promise.resolve();
1506
- const shutdownWatchdogMs = hostConfig
1507
- ? resolveShutdownWatchdogMs(hostConfig, supervisor?.getManagedServiceCount() ?? 0)
1508
- : SHUTDOWN_WATCHDOG_BUFFER_MS;
1509
- const result = await Promise.race([
1510
- drain.then(() => 'drained'),
1511
- waitForShutdownDeadline(shutdownWatchdogMs),
1512
- ]);
1513
- if (result === 'timed_out') {
1681
+ const { timedOut } = await drainManagedServices();
1682
+ if (timedOut) {
1514
1683
  shutdownExitCode = 1;
1515
- process.stderr.write(`Desktop host shutdown watchdog tripped after ${shutdownWatchdogMs}ms; forcing managed services down.\n`);
1516
1684
  forcedStopAttempted = true;
1517
- await supervisor?.forceStopAll();
1518
- void drain.catch((error) => {
1519
- process.stderr.write(`Desktop host graceful shutdown failed after watchdog: ${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
1520
- });
1521
1685
  }
1522
1686
  }
1523
1687
  catch (error) {
@@ -1551,6 +1715,14 @@ async function main() {
1551
1715
  return;
1552
1716
  }
1553
1717
  app.setPath('userData', resolveDesktopUserDataDir(app.getPath('appData')));
1718
+ // Windows routes a notification through the Start Menu shortcut whose
1719
+ // Application User Model ID matches the running process. The installer writes
1720
+ // that shortcut with the electron-builder appId, so claiming it here is what
1721
+ // makes update notifications visible at all. Unpackaged runs have no such
1722
+ // shortcut, so the claim would buy nothing.
1723
+ if (process.platform === 'win32' && app.isPackaged && DESKTOP_APP_USER_MODEL_ID) {
1724
+ app.setAppUserModelId(DESKTOP_APP_USER_MODEL_ID);
1725
+ }
1554
1726
  await app.whenReady();
1555
1727
  const nodeProcess = process;
1556
1728
  hostConfig = resolveDesktopHostConfig({
@@ -1580,12 +1752,14 @@ async function main() {
1580
1752
  stateStore = new DesktopHostStateStore(hostConfig.paths.hostStatePath);
1581
1753
  {
1582
1754
  const defaultBackground = applyEffectiveBackgroundPreferences(createDesktopBackgroundState(hostConfig));
1583
- const defaultUpdates = createDefaultDesktopUpdateState(hostConfig.update);
1755
+ // Built once with no restored history so the state file can be read; the
1756
+ // manager is rebuilt below with the restored last-check time.
1757
+ const provisionalUpdates = createUnavailableDesktopUpdateSnapshot(DESKTOP_HOST_VERSION);
1584
1758
  const defaultPackaging = buildHostPackagingPlan(hostConfig);
1585
1759
  const defaultSetup = createEmptyDesktopSetupState();
1586
1760
  const restoredState = await stateStore.load(hostConfig, {
1587
1761
  background: defaultBackground,
1588
- updates: defaultUpdates,
1762
+ updates: provisionalUpdates,
1589
1763
  packaging: defaultPackaging,
1590
1764
  setup: defaultSetup,
1591
1765
  });
@@ -1598,7 +1772,21 @@ async function main() {
1598
1772
  lastHiddenAt: new Date().toISOString(),
1599
1773
  };
1600
1774
  }
1601
- updateState = restoredState?.updates ?? defaultUpdates;
1775
+ // One owner: the manager holds update state and the host snapshot mirrors
1776
+ // it. Only the last-check time crosses a restart, because every status is
1777
+ // provider-dependent and cannot be acted on without checking again.
1778
+ updateManager = await createUpdateManagerForLaunch(hostConfig, readPersistedUpdateLastCheckedAt(restoredState?.updates));
1779
+ updateState = updateManager.getSnapshot();
1780
+ detachUpdateBroadcast = createDesktopUpdateSnapshotBroadcast({
1781
+ manager: updateManager,
1782
+ resolveMainWindowWebContents: () => mainWindow?.webContents ?? null,
1783
+ });
1784
+ updateManager.subscribe((snapshot) => {
1785
+ updateState = snapshot;
1786
+ if (latestSnapshot) {
1787
+ publishSnapshot(latestSnapshot);
1788
+ }
1789
+ });
1602
1790
  packagingState = defaultPackaging;
1603
1791
  setupState = normalizePlatformShellSetupState(restoredState?.setup ?? defaultSetup, Boolean(latestPersistedSetupState.setupCompleteAt
1604
1792
  || latestPersistedSetupState.productSetupCompleted));
@@ -1626,6 +1814,18 @@ async function main() {
1626
1814
  ipcMain.handle('cats-host:get-snapshot', async () => {
1627
1815
  return latestSnapshot ?? buildSnapshot(null);
1628
1816
  });
1817
+ if (updateManager) {
1818
+ const updateHandlers = createDesktopUpdateIpcHandlers({
1819
+ manager: updateManager,
1820
+ resolveMainWindowWebContents: () => mainWindow?.webContents ?? null,
1821
+ logger: (message) => {
1822
+ process.stdout.write(`${message}\n`);
1823
+ },
1824
+ });
1825
+ for (const [channel, handler] of Object.entries(updateHandlers)) {
1826
+ ipcMain.handle(channel, async (event) => handler(event));
1827
+ }
1828
+ }
1629
1829
  ipcMain.handle('cats-host:get-setup-snapshot', async () => {
1630
1830
  return await getSetupSnapshot();
1631
1831
  });
@@ -1663,6 +1863,17 @@ async function main() {
1663
1863
  ipcMain.handle('cats-host:resume-setup', async () => {
1664
1864
  return await resumeSetupAction();
1665
1865
  });
1866
+ ipcMain.handle(DESKTOP_BROWSER_HANDOFF_OPEN_CHANNEL, async (event, launchPath) => {
1867
+ assertMainWindowIpcSender(event, mainWindow, 'Browser handoff is only available to the main Cats window.');
1868
+ if (!hostConfig || typeof launchPath !== 'string') {
1869
+ throw new Error('Desktop browser handoff is not initialized.');
1870
+ }
1871
+ const url = resolveDesktopBrowserHandoffUrl(launchPath, {
1872
+ appBaseUrl: hostConfig.appBaseUrl,
1873
+ allowedHosts: resolveAllowedAppHosts(hostConfig),
1874
+ });
1875
+ await openExternalDesktopUrl(url);
1876
+ });
1666
1877
  ipcMain.handle(DESKTOP_SCREENSHOT_IPC_CHANNEL, async (event, payload) => {
1667
1878
  assertMainWindowScreenshotIpcSender(event, mainWindow);
1668
1879
  return await captureScreenshotRegion({
@@ -1808,8 +2019,10 @@ async function main() {
1808
2019
  void showMainWindow();
1809
2020
  });
1810
2021
  await bootstrapDesktopHost(false);
1811
- if (hostConfig.update.checkOnStartup && hostConfig.update.manifestUrl) {
1812
- void refreshUpdateState();
2022
+ // SPEC-111 section 6: at most one silent check per launch, and only when the
2023
+ // build actually has update capability.
2024
+ if (hostConfig.update.checkOnStartup && updateManager?.getSnapshot().capability.canCheck) {
2025
+ void refreshUpdateState('startup');
1813
2026
  }
1814
2027
  }
1815
2028
  void main().catch((error) => {