From d9175a607733269a00d1192401748d4d1d7084dc Mon Sep 17 00:00:00 2001 From: Ander Date: Sat, 20 Jun 2026 15:49:35 -0700 Subject: [PATCH] desktop/windows: apps detail page, conversation fixes, Rewind + auto-update UX Brings the Windows app up to the current build: - Apps: per-app detail page (About/Prompt/Preview/Capabilities/Integration/Reviews), popularity-ranked marketplace, faster cached loading. - Conversations: render pending/local rows locally instead of 404ing; fix pending deletion. - Rewind: load full stored history, wheel-pan the activity bar without yanking, stick to the live edge on open. - Auto-update: opt-in native "Download?" dialog, native Task-Dialog progress (a .NET win-update-helper), silent in-place install, app version in Advanced settings, app icon (win.icon). - Keeps productName "Omi" / omi.exe; no publish block (releases handled by CI). --- desktop/windows/.gitignore | 5 + desktop/windows/dev-app-update.yml | 8 + desktop/windows/electron-builder.yml | 30 +- desktop/windows/package.json | 10 +- desktop/windows/pnpm-lock.yaml | 447 +++--------------- .../windows/scripts/build-update-helper.ps1 | 19 + .../windows/scripts/ensure-update-helper.mjs | 32 ++ desktop/windows/src/main/index.ts | 145 ++++-- .../windows/src/main/insight/toastWindow.ts | 13 +- desktop/windows/src/main/ipc/automation.ts | 15 +- desktop/windows/src/main/ipc/db.ts | 11 +- desktop/windows/src/main/ipc/omiListen.ts | 42 +- desktop/windows/src/main/overlay/bounds.ts | 7 +- desktop/windows/src/main/overlay/ipc.ts | 5 +- desktop/windows/src/main/overlay/window.ts | 54 ++- .../windows/src/main/render/renderServer.ts | 106 +++++ .../src/main/render/renderServerLogic.test.ts | 40 ++ .../src/main/render/renderServerLogic.ts | 48 ++ desktop/windows/src/main/rendererServer.ts | 108 ----- .../windows/src/main/rewind/captureService.ts | 105 ++-- .../src/main/rewind/currentScreen.test.ts | 53 +++ .../windows/src/main/rewind/currentScreen.ts | 13 + .../src/main/rewind/latestRunner.test.ts | 80 ++++ .../windows/src/main/rewind/latestRunner.ts | 45 ++ .../windows/src/main/rewind/rewindSettings.ts | 7 +- desktop/windows/src/main/update/autoUpdate.ts | 189 ++++++++ .../windows/src/main/update/progressDialog.ts | 67 +++ .../src/main/update/resolveHelperPath.ts | 24 + .../src/main/update/updateLogic.test.ts | 22 + .../windows/src/main/update/updateLogic.ts | 25 + .../main/update/win-update-helper/Program.cs | 77 +++ .../win-update-helper.csproj | 16 + desktop/windows/src/preload/index.ts | 26 +- desktop/windows/src/renderer/src/App.tsx | 3 + .../src/renderer/src/components/Markdown.tsx | 22 +- .../src/components/graph/BrainGraph.tsx | 69 ++- .../src/components/graph/nodeColor.test.ts | 34 +- .../src/components/graph/nodeColor.ts | 46 +- .../src/components/layout/MainViews.tsx | 6 + .../src/components/overlay/OverlayApp.tsx | 28 +- .../components/rewind/RewindCaptureHost.tsx | 23 +- .../components/rewind/RewindTimelineBar.tsx | 30 +- .../src/components/settings/LevelSlider.tsx | 113 +++++ .../components/settings/tabs/AdvancedTab.tsx | 20 +- .../components/settings/tabs/GeneralTab.tsx | 151 +++++- .../components/settings/tabs/RewindTab.tsx | 79 +++- .../src/renderer/src/hooks/useMemoryGraph.ts | 25 +- .../src/renderer/src/hooks/usePushToTalk.ts | 15 +- .../src/renderer/src/hooks/useRecorder.ts | 20 + .../src/renderer/src/hooks/useRewind.ts | 5 +- .../src/renderer/src/lib/appDetail.test.ts | 114 +++++ .../windows/src/renderer/src/lib/appDetail.ts | 152 ++++++ .../src/renderer/src/lib/appMemories.test.ts | 9 +- .../renderer/src/lib/calendarExtract.test.ts | 8 +- .../src/renderer/src/lib/chatApps.test.ts | 67 +++ .../windows/src/renderer/src/lib/chatApps.ts | 122 +++++ .../src/lib/conversationTypes.test.ts | 18 + .../src/renderer/src/lib/conversationTypes.ts | 16 + .../src/renderer/src/lib/gmailExtract.test.ts | 8 +- .../src/renderer/src/lib/goals.test.ts | 12 +- .../src/renderer/src/lib/graphCap.test.ts | 93 ++++ .../windows/src/renderer/src/lib/graphCap.ts | 67 +++ .../renderer/src/lib/insightActivity.test.ts | 28 ++ .../src/renderer/src/lib/insightActivity.ts | 29 +- .../src/renderer/src/lib/insightEngine.ts | 71 ++- .../renderer/src/lib/insightPrompt.test.ts | 11 + .../src/renderer/src/lib/insightPrompt.ts | 32 +- .../src/renderer/src/lib/listenClose.test.ts | 108 +++++ .../src/renderer/src/lib/listenClose.ts | 72 +++ .../src/renderer/src/lib/micDevices.test.ts | 74 +++ .../src/renderer/src/lib/micDevices.ts | 46 ++ .../src/renderer/src/lib/omiListenClient.ts | 83 ++-- .../src/renderer/src/lib/pageCache.test.ts | 32 +- .../windows/src/renderer/src/lib/pageCache.ts | 20 + .../src/renderer/src/lib/preferences.ts | 14 + .../src/renderer/src/lib/speakerLabel.test.ts | 119 +++++ .../src/renderer/src/lib/speakerLabel.ts | 131 +++++ .../renderer/src/lib/transcriptionClient.ts | 109 ++--- .../windows/src/renderer/src/lib/uiScale.ts | 32 ++ .../renderer/src/lib/useGraphSimulation.ts | 35 +- desktop/windows/src/renderer/src/main.tsx | 12 + .../src/renderer/src/pages/AppDetail.tsx | 406 ++++++++++++++++ .../windows/src/renderer/src/pages/Apps.tsx | 93 +++- .../renderer/src/pages/ConversationDetail.tsx | 213 +++++++-- .../src/renderer/src/pages/Conversations.tsx | 7 +- .../renderer/src/pages/LiveConversation.tsx | 32 +- .../src/renderer/src/pages/Memories.tsx | 2 +- .../src/shared/rewindResolution.test.ts | 33 ++ .../windows/src/shared/rewindResolution.ts | 31 ++ desktop/windows/src/shared/types.ts | 64 ++- desktop/windows/tsconfig.node.tsbuildinfo | 1 + 91 files changed, 4168 insertions(+), 1041 deletions(-) create mode 100644 desktop/windows/dev-app-update.yml create mode 100644 desktop/windows/scripts/build-update-helper.ps1 create mode 100644 desktop/windows/scripts/ensure-update-helper.mjs create mode 100644 desktop/windows/src/main/render/renderServer.ts create mode 100644 desktop/windows/src/main/render/renderServerLogic.test.ts create mode 100644 desktop/windows/src/main/render/renderServerLogic.ts delete mode 100644 desktop/windows/src/main/rendererServer.ts create mode 100644 desktop/windows/src/main/rewind/latestRunner.test.ts create mode 100644 desktop/windows/src/main/rewind/latestRunner.ts create mode 100644 desktop/windows/src/main/update/autoUpdate.ts create mode 100644 desktop/windows/src/main/update/progressDialog.ts create mode 100644 desktop/windows/src/main/update/resolveHelperPath.ts create mode 100644 desktop/windows/src/main/update/updateLogic.test.ts create mode 100644 desktop/windows/src/main/update/updateLogic.ts create mode 100644 desktop/windows/src/main/update/win-update-helper/Program.cs create mode 100644 desktop/windows/src/main/update/win-update-helper/win-update-helper.csproj create mode 100644 desktop/windows/src/renderer/src/components/settings/LevelSlider.tsx create mode 100644 desktop/windows/src/renderer/src/lib/appDetail.test.ts create mode 100644 desktop/windows/src/renderer/src/lib/appDetail.ts create mode 100644 desktop/windows/src/renderer/src/lib/chatApps.test.ts create mode 100644 desktop/windows/src/renderer/src/lib/chatApps.ts create mode 100644 desktop/windows/src/renderer/src/lib/conversationTypes.test.ts create mode 100644 desktop/windows/src/renderer/src/lib/graphCap.test.ts create mode 100644 desktop/windows/src/renderer/src/lib/graphCap.ts create mode 100644 desktop/windows/src/renderer/src/lib/listenClose.test.ts create mode 100644 desktop/windows/src/renderer/src/lib/listenClose.ts create mode 100644 desktop/windows/src/renderer/src/lib/micDevices.test.ts create mode 100644 desktop/windows/src/renderer/src/lib/micDevices.ts create mode 100644 desktop/windows/src/renderer/src/lib/speakerLabel.test.ts create mode 100644 desktop/windows/src/renderer/src/lib/speakerLabel.ts create mode 100644 desktop/windows/src/renderer/src/lib/uiScale.ts create mode 100644 desktop/windows/src/renderer/src/pages/AppDetail.tsx create mode 100644 desktop/windows/src/shared/rewindResolution.test.ts create mode 100644 desktop/windows/src/shared/rewindResolution.ts create mode 100644 desktop/windows/tsconfig.node.tsbuildinfo diff --git a/desktop/windows/.gitignore b/desktop/windows/.gitignore index 926d0b802c5..09cf170cf15 100644 --- a/desktop/windows/.gitignore +++ b/desktop/windows/.gitignore @@ -50,3 +50,8 @@ resources/win-ocr-helper/*.pdb src/main/automation/helper/bin/ src/main/automation/helper/obj/ resources/win-automation-helper/*.pdb + +# .NET build artifacts (win-update-helper) +src/main/update/win-update-helper/bin/ +src/main/update/win-update-helper/obj/ +resources/win-update-helper/*.pdb diff --git a/desktop/windows/dev-app-update.yml b/desktop/windows/dev-app-update.yml new file mode 100644 index 00000000000..cb75968056d --- /dev/null +++ b/desktop/windows/dev-app-update.yml @@ -0,0 +1,8 @@ +# Lets electron-updater run against GitHub Releases from an UNPACKED build +# (`npm run build:unpack`) when you also set `autoUpdater.forceDevUpdateConfig`, +# so the update round-trip can be exercised without a full install. Excluded from +# the packaged app (the real app-update.yml is generated from electron-builder.yml). +provider: github +owner: andermont +repo: omi-windows +updaterCacheDirName: omi-windows-updater diff --git a/desktop/windows/electron-builder.yml b/desktop/windows/electron-builder.yml index 1594c459b8a..2d6fb73ccb5 100644 --- a/desktop/windows/electron-builder.yml +++ b/desktop/windows/electron-builder.yml @@ -1,5 +1,11 @@ appId: com.omiwindows.app -productName: Omi for Windows +productName: Omi +# Auto-update source: the official monorepo's GitHub Releases. electron-builder +# embeds an app-update.yml pointing here, so installed apps check BasedHardware/omi. +publish: + provider: github + owner: BasedHardware + repo: omi directories: buildResources: build files: @@ -19,16 +25,34 @@ asarUnpack: # koffi loads its native .node at runtime, resolved relative to its own package # dir — it must live outside the asar archive or the foreground monitor fails. - node_modules/koffi/** + # koffi 3.x ships the actual prebuilt binary in a SEPARATE per-platform package + # (@koromix/koffi-win32-x64/win32_x64/koffi.node), which koffi's loader resolves + # as a sibling under node_modules. It must be present AND unpacked, or the + # packaged app crashes on launch with "Cannot find the native Koffi module". + # (It's pinned as a direct dependency so electron-builder reliably bundles it.) + - node_modules/@koromix/** + # Belt-and-suspenders: never leave any native binary stranded inside the asar. + - '**/*.node' win: - executableName: omi-windows + executableName: omi + # The packaged exe / installer / Start-menu icon. Without this electron-builder + # falls back to the default Electron icon. 256x256 PNG → electron-builder makes + # the .ico. (The running window icon is set separately via nativeImage in main.) + icon: resources/icon.png target: - target: nsis arch: [x64] nsis: + # Assisted installer so the FIRST install offers a folder choice. Auto-updates + # are still silent (no wizard) because the app calls quitAndInstall(isSilent=true), + # which runs this installer with /S and reuses the registered install dir. oneClick: false perMachine: false allowToChangeInstallationDirectory: true - artifactName: ${productName}-Setup-${version}.${ext} + # Static installer filename with no version, so the downloaded file is just + # "omi.exe". electron-updater keys off the version inside latest.yml, not the + # filename, so a constant name is fine across releases. + artifactName: omi.${ext} shortcutName: ${productName} uninstallDisplayName: ${productName} createDesktopShortcut: always diff --git a/desktop/windows/package.json b/desktop/windows/package.json index 77967d2062e..ae2b3a6817a 100644 --- a/desktop/windows/package.json +++ b/desktop/windows/package.json @@ -1,9 +1,9 @@ { "name": "omi-windows", - "version": "1.0.0", + "version": "1.1.4", "description": "An Electron application with React and TypeScript", "main": "./out/main/index.js", - "author": "example.com", + "author": "Ander Mont", "homepage": "https://electron-vite.org", "scripts": { "format": "prettier --write .", @@ -16,9 +16,11 @@ "build": "npm run typecheck && electron-vite build", "rebuild:sqlite": "electron-rebuild -f -w better-sqlite3", "build:ocr-helper": "powershell -ExecutionPolicy Bypass -File scripts/build-ocr-helper.ps1", - "postinstall": "electron-builder install-app-deps && electron-rebuild -f -w better-sqlite3 && node scripts/ensure-ocr-helper.mjs", + "build:update-helper": "powershell -ExecutionPolicy Bypass -File scripts/build-update-helper.ps1", + "postinstall": "electron-builder install-app-deps && electron-rebuild -f -w better-sqlite3 && node scripts/ensure-ocr-helper.mjs && node scripts/ensure-update-helper.mjs", "build:unpack": "npm run build && electron-builder --dir", "build:win": "npm run build && electron-builder --win --x64", + "release:win": "npm run build && electron-builder --win --x64 --publish always", "build:mac": "electron-vite build && electron-builder --mac", "build:linux": "electron-vite build && electron-builder --linux", "test": "vitest run", @@ -29,6 +31,7 @@ "dependencies": { "@electron-toolkit/preload": "^3.0.2", "@electron-toolkit/utils": "^4.0.0", + "@koromix/koffi-win32-x64": "3.0.2", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-scroll-area": "^1.2.10", @@ -42,6 +45,7 @@ "clsx": "^2.1.1", "d3-force": "^3.0.0", "d3-force-3d": "^3.0.6", + "electron-updater": "^6.8.9", "firebase": "^12.13.0", "koffi": "^3.0.2", "lucide-react": "^1.16.0", diff --git a/desktop/windows/pnpm-lock.yaml b/desktop/windows/pnpm-lock.yaml index 59439f9b4d4..4d4e2304888 100644 --- a/desktop/windows/pnpm-lock.yaml +++ b/desktop/windows/pnpm-lock.yaml @@ -14,9 +14,9 @@ importers: '@electron-toolkit/utils': specifier: ^4.0.0 version: 4.0.0(electron@39.8.10) - '@huggingface/transformers': - specifier: ^4.2.0 - version: 4.2.0 + '@koromix/koffi-win32-x64': + specifier: 3.0.2 + version: 3.0.2 '@radix-ui/react-dialog': specifier: ^1.1.15 version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -56,6 +56,9 @@ importers: d3-force-3d: specifier: ^3.0.6 version: 3.0.6 + electron-updater: + specifier: ^6.8.9 + version: 6.8.9 firebase: specifier: ^12.13.0 version: 12.14.0 @@ -342,9 +345,6 @@ packages: engines: {node: '>=14.14'} hasBin: true - '@emnapi/runtime@1.10.0': - resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} - '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} @@ -929,16 +929,6 @@ packages: engines: {node: '>=6'} hasBin: true - '@huggingface/jinja@0.5.9': - resolution: {integrity: sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw==} - engines: {node: '>=18'} - - '@huggingface/tokenizers@0.1.3': - resolution: {integrity: sha512-8rF/RRT10u+kn7YuUbUg0OF30K8rjTc78aHpxT+qJ1uWSqxT1MHi8+9ltwYfkFYJzT/oS+qw3JVfHtNMGAdqyA==} - - '@huggingface/transformers@4.2.0': - resolution: {integrity: sha512-8BRCoBMH0XsWaEIamuR0LrJGAfftgHAfb2Vrffy0VKlSAE/MnUJ5/h/zTfEP3fDIft+nk7TqB8xXEyABGitBjQ==} - '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -959,159 +949,6 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} - '@img/colour@1.1.0': - resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} - engines: {node: '>=18'} - - '@img/sharp-darwin-arm64@0.34.5': - resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [darwin] - - '@img/sharp-darwin-x64@0.34.5': - resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [darwin] - - '@img/sharp-libvips-darwin-arm64@1.2.4': - resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} - cpu: [arm64] - os: [darwin] - - '@img/sharp-libvips-darwin-x64@1.2.4': - resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} - cpu: [x64] - os: [darwin] - - '@img/sharp-libvips-linux-arm64@1.2.4': - resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-arm@1.2.4': - resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-ppc64@1.2.4': - resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-riscv64@1.2.4': - resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-s390x@1.2.4': - resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-x64@1.2.4': - resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@img/sharp-linux-arm64@0.34.5': - resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-arm@0.34.5': - resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-ppc64@0.34.5': - resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-riscv64@0.34.5': - resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-s390x@0.34.5': - resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-x64@0.34.5': - resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@img/sharp-linuxmusl-arm64@0.34.5': - resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@img/sharp-linuxmusl-x64@0.34.5': - resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - libc: [musl] - - '@img/sharp-wasm32@0.34.5': - resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [wasm32] - - '@img/sharp-win32-arm64@0.34.5': - resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [win32] - - '@img/sharp-win32-ia32@0.34.5': - resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [ia32] - os: [win32] - - '@img/sharp-win32-x64@0.34.5': - resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [win32] - '@isaacs/fs-minipass@4.0.1': resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} @@ -2000,10 +1837,6 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - adm-zip@0.5.17: - resolution: {integrity: sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==} - engines: {node: '>=12.0'} - agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} @@ -2198,6 +2031,10 @@ packages: resolution: {integrity: sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==} engines: {node: '>=12.0.0'} + builder-util-runtime@9.7.0: + resolution: {integrity: sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==} + engines: {node: '>=12.0.0'} + builder-util@26.8.1: resolution: {integrity: sha512-pm1lTYbGyc90DHgCDO7eo8Rl4EqKLciayNbZqGziqnH9jrlKe8ZANGdityLZU+pJh16dfzjAx2xQq9McuIPEtw==} @@ -2504,6 +2341,9 @@ packages: electron-to-chromium@1.5.366: resolution: {integrity: sha512-OlRuhb688YTCzzU3gXPLn6nGyd+F+53INE1qaKKlu6kETErE8FYsyDh0XqXEU+uBRn0MpCzz2vfNwORhkap8qg==} + electron-updater@6.8.9: + resolution: {integrity: sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==} + electron-vite@5.0.0: resolution: {integrity: sha512-OHp/vjdlubNlhNkPkL/+3JD34ii5ov7M0GpuXEVdQeqdQ3ulvVR7Dg/rNBLfS5XPIFwgoBLDf9sjjrL+CuDyRQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2765,9 +2605,6 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} - flatbuffers@25.9.23: - resolution: {integrity: sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==} - flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} @@ -2909,9 +2746,6 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - guid-typescript@1.0.9: - resolution: {integrity: sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==} - has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -3249,6 +3083,13 @@ packages: lodash.camelcase@4.3.0: resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + lodash.escaperegexp@4.1.2: + resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} + + lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -3460,19 +3301,6 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - onnxruntime-common@1.24.0-dev.20251116-b39e144322: - resolution: {integrity: sha512-BOoomdHYmNRL5r4iQ4bMvsl2t0/hzVQ3OM3PHD0gxeXu1PmggqBv3puZicEUVOA3AtHHYmqZtjMj9FOfGrATTw==} - - onnxruntime-common@1.24.3: - resolution: {integrity: sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA==} - - onnxruntime-node@1.24.3: - resolution: {integrity: sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg==} - os: [win32, darwin, linux] - - onnxruntime-web@1.26.0-dev.20260416-b7804b056c: - resolution: {integrity: sha512-MD6Ss4GSpQBo6zqoJzyT9LRbKYs7x/JVN23FT24EcEvlqF4VuzPOeH6X38orZPKHQDbprn7K+SBpu0/mj2CQiw==} - optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -3545,9 +3373,6 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} - platform@1.3.6: - resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==} - plist@3.1.0: resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} engines: {node: '>=10.4.0'} @@ -3901,10 +3726,6 @@ packages: resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} engines: {node: '>= 0.4'} - sharp@0.34.5: - resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -4106,6 +3927,9 @@ packages: tiny-async-pool@1.3.0: resolution: {integrity: sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==} + tiny-typed-emitter@2.1.0: + resolution: {integrity: sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -4757,11 +4581,6 @@ snapshots: - supports-color optional: true - '@emnapi/runtime@1.10.0': - dependencies: - tslib: 2.8.1 - optional: true - '@esbuild/aix-ppc64@0.25.12': optional: true @@ -5313,18 +5132,6 @@ snapshots: protobufjs: 7.6.2 yargs: 17.7.2 - '@huggingface/jinja@0.5.9': {} - - '@huggingface/tokenizers@0.1.3': {} - - '@huggingface/transformers@4.2.0': - dependencies: - '@huggingface/jinja': 0.5.9 - '@huggingface/tokenizers': 0.1.3 - onnxruntime-node: 1.24.3 - onnxruntime-web: 1.26.0-dev.20260416-b7804b056c - sharp: 0.34.5 - '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -5341,102 +5148,6 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@img/colour@1.1.0': {} - - '@img/sharp-darwin-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.2.4 - optional: true - - '@img/sharp-darwin-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.2.4 - optional: true - - '@img/sharp-libvips-darwin-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-darwin-x64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-arm@1.2.4': - optional: true - - '@img/sharp-libvips-linux-ppc64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-riscv64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-s390x@1.2.4': - optional: true - - '@img/sharp-libvips-linux-x64@1.2.4': - optional: true - - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - optional: true - - '@img/sharp-linux-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.2.4 - optional: true - - '@img/sharp-linux-arm@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.2.4 - optional: true - - '@img/sharp-linux-ppc64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.2.4 - optional: true - - '@img/sharp-linux-riscv64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.2.4 - optional: true - - '@img/sharp-linux-s390x@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.2.4 - optional: true - - '@img/sharp-linux-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.2.4 - optional: true - - '@img/sharp-linuxmusl-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - optional: true - - '@img/sharp-linuxmusl-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - optional: true - - '@img/sharp-wasm32@0.34.5': - dependencies: - '@emnapi/runtime': 1.10.0 - optional: true - - '@img/sharp-win32-arm64@0.34.5': - optional: true - - '@img/sharp-win32-ia32@0.34.5': - optional: true - - '@img/sharp-win32-x64@0.34.5': - optional: true - '@isaacs/fs-minipass@4.0.1': dependencies: minipass: 7.1.3 @@ -5499,8 +5210,7 @@ snapshots: '@koromix/koffi-win32-ia32@3.0.2': optional: true - '@koromix/koffi-win32-x64@3.0.2': - optional: true + '@koromix/koffi-win32-x64@3.0.2': {} '@malept/cross-spawn-promise@2.0.0': dependencies: @@ -6294,8 +6004,6 @@ snapshots: acorn@8.16.0: {} - adm-zip@0.5.17: {} - agent-base@6.0.2: dependencies: debug: 4.4.3 @@ -6508,7 +6216,8 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 - boolean@3.2.0: {} + boolean@3.2.0: + optional: true brace-expansion@1.1.15: dependencies: @@ -6556,6 +6265,13 @@ snapshots: transitivePeerDependencies: - supports-color + builder-util-runtime@9.7.0: + dependencies: + debug: 4.4.3 + sax: 1.6.0 + transitivePeerDependencies: + - supports-color + builder-util@26.8.1: dependencies: 7zip-bin: 5.2.0 @@ -6807,7 +6523,8 @@ snapshots: detect-node-es@1.1.0: {} - detect-node@2.1.0: {} + detect-node@2.1.0: + optional: true didyoumean@1.2.2: {} @@ -6905,6 +6622,19 @@ snapshots: electron-to-chromium@1.5.366: {} + electron-updater@6.8.9: + dependencies: + builder-util-runtime: 9.7.0 + fs-extra: 10.1.0 + js-yaml: 4.2.0 + lazy-val: 1.0.5 + lodash.escaperegexp: 4.1.2 + lodash.isequal: 4.5.0 + semver: 7.7.4 + tiny-typed-emitter: 2.1.0 + transitivePeerDependencies: + - supports-color + electron-vite@5.0.0(vite@7.3.5(@types/node@22.19.19)(jiti@1.21.7)): dependencies: '@babel/core': 7.29.7 @@ -7050,7 +6780,8 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 - es6-error@4.1.1: {} + es6-error@4.1.1: + optional: true esbuild@0.25.12: optionalDependencies: @@ -7350,8 +7081,6 @@ snapshots: flatted: 3.4.2 keyv: 4.5.4 - flatbuffers@25.9.23: {} - flatted@3.4.2: {} follow-redirects@1.16.0: {} @@ -7484,6 +7213,7 @@ snapshots: roarr: 2.15.4 semver: 7.8.1 serialize-error: 7.0.1 + optional: true globals@14.0.0: {} @@ -7514,8 +7244,6 @@ snapshots: graceful-fs@4.2.11: {} - guid-typescript@1.0.9: {} - has-bigints@1.1.0: {} has-flag@4.0.0: {} @@ -7792,7 +7520,8 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} - json-stringify-safe@5.0.1: {} + json-stringify-safe@5.0.1: + optional: true json5@2.2.3: {} @@ -7855,6 +7584,10 @@ snapshots: lodash.camelcase@4.3.0: {} + lodash.escaperegexp@4.1.2: {} + + lodash.isequal@4.5.0: {} + lodash.merge@4.6.2: {} lodash@4.18.1: {} @@ -7893,6 +7626,7 @@ snapshots: matcher@3.0.0: dependencies: escape-string-regexp: 4.0.0 + optional: true math-intrinsics@1.1.0: {} @@ -8052,25 +7786,6 @@ snapshots: dependencies: wrappy: 1.0.2 - onnxruntime-common@1.24.0-dev.20251116-b39e144322: {} - - onnxruntime-common@1.24.3: {} - - onnxruntime-node@1.24.3: - dependencies: - adm-zip: 0.5.17 - global-agent: 3.0.0 - onnxruntime-common: 1.24.3 - - onnxruntime-web@1.26.0-dev.20260416-b7804b056c: - dependencies: - flatbuffers: 25.9.23 - guid-typescript: 1.0.9 - long: 5.3.2 - onnxruntime-common: 1.24.0-dev.20251116-b39e144322 - platform: 1.3.6 - protobufjs: 7.6.2 - optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -8126,8 +7841,6 @@ snapshots: pirates@4.0.7: {} - platform@1.3.6: {} - plist@3.1.0: dependencies: '@xmldom/xmldom': 0.8.13 @@ -8417,6 +8130,7 @@ snapshots: json-stringify-safe: 5.0.1 semver-compare: 1.0.0 sprintf-js: 1.1.3 + optional: true rollup@4.61.0: dependencies: @@ -8484,7 +8198,8 @@ snapshots: scheduler@0.27.0: {} - semver-compare@1.0.0: {} + semver-compare@1.0.0: + optional: true semver@5.7.2: {} @@ -8497,6 +8212,7 @@ snapshots: serialize-error@7.0.1: dependencies: type-fest: 0.13.1 + optional: true set-cookie-parser@2.7.2: {} @@ -8522,37 +8238,6 @@ snapshots: es-errors: 1.3.0 es-object-atoms: 1.1.2 - sharp@0.34.5: - dependencies: - '@img/colour': 1.1.0 - detect-libc: 2.1.2 - semver: 7.8.1 - optionalDependencies: - '@img/sharp-darwin-arm64': 0.34.5 - '@img/sharp-darwin-x64': 0.34.5 - '@img/sharp-libvips-darwin-arm64': 1.2.4 - '@img/sharp-libvips-darwin-x64': 1.2.4 - '@img/sharp-libvips-linux-arm': 1.2.4 - '@img/sharp-libvips-linux-arm64': 1.2.4 - '@img/sharp-libvips-linux-ppc64': 1.2.4 - '@img/sharp-libvips-linux-riscv64': 1.2.4 - '@img/sharp-libvips-linux-s390x': 1.2.4 - '@img/sharp-libvips-linux-x64': 1.2.4 - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - '@img/sharp-linux-arm': 0.34.5 - '@img/sharp-linux-arm64': 0.34.5 - '@img/sharp-linux-ppc64': 0.34.5 - '@img/sharp-linux-riscv64': 0.34.5 - '@img/sharp-linux-s390x': 0.34.5 - '@img/sharp-linux-x64': 0.34.5 - '@img/sharp-linuxmusl-arm64': 0.34.5 - '@img/sharp-linuxmusl-x64': 0.34.5 - '@img/sharp-wasm32': 0.34.5 - '@img/sharp-win32-arm64': 0.34.5 - '@img/sharp-win32-ia32': 0.34.5 - '@img/sharp-win32-x64': 0.34.5 - shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -8622,7 +8307,8 @@ snapshots: source-map@0.6.1: {} - sprintf-js@1.1.3: {} + sprintf-js@1.1.3: + optional: true stackback@0.0.2: {} @@ -8829,6 +8515,8 @@ snapshots: dependencies: semver: 5.7.2 + tiny-typed-emitter@2.1.0: {} + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -8896,7 +8584,8 @@ snapshots: dependencies: prelude-ls: 1.2.1 - type-fest@0.13.1: {} + type-fest@0.13.1: + optional: true typed-array-buffer@1.0.3: dependencies: diff --git a/desktop/windows/scripts/build-update-helper.ps1 b/desktop/windows/scripts/build-update-helper.ps1 new file mode 100644 index 00000000000..845cf037987 --- /dev/null +++ b/desktop/windows/scripts/build-update-helper.ps1 @@ -0,0 +1,19 @@ +# Publishes the win-update-helper as a self-contained single-file exe into +# resources/win-update-helper/ (gitignored). Ships via electron-builder's +# `asarUnpack: resources/**` and is resolved at runtime by resolveHelperPath.ts. +# This is the native Task-Dialog progress UI for auto-update. +$ErrorActionPreference = 'Stop' +$proj = Join-Path $PSScriptRoot '..\src\main\update\win-update-helper' +$out = Join-Path $PSScriptRoot '..\resources\win-update-helper' + +dotnet publish $proj ` + -c Release ` + -r win-x64 ` + --self-contained true ` + -p:PublishSingleFile=true ` + -o $out + +if (-not (Test-Path (Join-Path $out 'win-update-helper.exe'))) { + throw 'build-update-helper: win-update-helper.exe was not produced' +} +Write-Host "build-update-helper: published to $out" diff --git a/desktop/windows/scripts/ensure-update-helper.mjs b/desktop/windows/scripts/ensure-update-helper.mjs new file mode 100644 index 00000000000..b6eccd83cec --- /dev/null +++ b/desktop/windows/scripts/ensure-update-helper.mjs @@ -0,0 +1,32 @@ +// Postinstall step: on Windows, build win-update-helper.exe if it's missing, so a +// fresh clone or git worktree gets the native update-progress dialog out of the box +// (the binary is gitignored, like .env, so it never travels with the repo). +// No-op off-Windows or when the exe already exists, and NON-FATAL on any failure — +// it must never break `npm install`. If it can't build (e.g. no .NET SDK), the app +// still runs; the update progress just falls back to the taskbar bar. +import { existsSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' +import { execSync } from 'node:child_process' + +const root = join(dirname(fileURLToPath(import.meta.url)), '..') +const exe = join(root, 'resources', 'win-update-helper', 'win-update-helper.exe') + +if (process.platform !== 'win32') { + console.log('[ensure-update-helper] not Windows — skipping (the helper is Windows-only).') + process.exit(0) +} +if (existsSync(exe)) { + console.log('[ensure-update-helper] win-update-helper.exe already present — skipping.') + process.exit(0) +} +try { + console.log('[ensure-update-helper] win-update-helper.exe missing — building it (needs the .NET SDK)…') + execSync('npm run build:update-helper', { stdio: 'inherit', cwd: root }) +} catch { + console.warn( + '[ensure-update-helper] could NOT build the update helper (is the .NET SDK installed?). ' + + 'The app still works; update progress falls back to the taskbar bar until you run `npm run build:update-helper`.' + ) +} +process.exit(0) diff --git a/desktop/windows/src/main/index.ts b/desktop/windows/src/main/index.ts index ab558764cdb..c49067630b5 100644 --- a/desktop/windows/src/main/index.ts +++ b/desktop/windows/src/main/index.ts @@ -1,6 +1,14 @@ -import { app, shell, BrowserWindow, ipcMain, session, nativeImage, desktopCapturer } from 'electron' +import { + app, + shell, + BrowserWindow, + ipcMain, + session, + nativeImage, + desktopCapturer +} from 'electron' import { join } from 'path' -import { electronApp, optimizer, is } from '@electron-toolkit/utils' +import { electronApp, optimizer } from '@electron-toolkit/utils' import iconPath from '../../resources/icon.png?asset' import { listCaptureSources } from './ipc/capture' import { registerOmiListenHandlers } from './ipc/omiListen' @@ -32,7 +40,8 @@ import { stopAutomationTargetTracker } from './automation/foregroundTarget' import { registerScreenSynthHandlers } from './ipc/screenSynth' -import { startRendererServer, rendererBaseUrl } from './rendererServer' +import { initAutoUpdate, simulateUpdateUi } from './update/autoUpdate' +import { startRenderServer, loadRenderer } from './render/renderServer' import { startRewindCapture } from './rewind/captureService' import { startRewindOcr } from './rewind/ocrService' import { startRewindRetention } from './rewind/retentionRunner' @@ -83,6 +92,35 @@ if (sandbox && process.env.OMI_BENCH !== '1') { app.setPath('userData', join(app.getPath('appData'), `omi-windows-sandbox-${suffix}`)) } +// Single-instance lock. A second launch must focus the existing window instead +// of spinning up a competing process that fights over the same userData profile +// — concurrent instances corrupt Chromium's storage (quota DB resets), which +// silently wipes localStorage (Firebase session + onboarding flag) and logs the +// user out. Bench runs use isolated --user-data-dir, so don't gate them. +const gotSingleInstanceLock = process.env.OMI_BENCH === '1' || app.requestSingleInstanceLock() +if (!gotSingleInstanceLock) { + app.quit() +} + +// Set once createWindow runs; used by the single-instance focus + clean-quit path. +let mainWindowRef: BrowserWindow | null = null +// True from before-quit onward, so the main window's 'closed' handler doesn't +// re-enter app.quit() during a quit that's already underway. +let isQuitting = false + +// A second launch focuses the existing window instead of starting a rival process. +app.on('second-instance', () => { + const w = mainWindowRef + if (w && !w.isDestroyed()) { + if (w.isMinimized()) w.restore() + w.show() + w.focus() + } +}) +app.on('before-quit', () => { + isQuitting = true +}) + const icon = nativeImage.createFromPath(iconPath) import { remapConversationId, @@ -152,35 +190,14 @@ function createWindow(): BrowserWindow { } } } - // Hand only web/mail links to the OS. A prompt-injected chat reply could emit - // a file://, UNC, or custom-protocol URL; passing those to shell.openExternal - // enables NTLM-hash leak / protocol-handler abuse. Defense-in-depth alongside - // the renderer's Markdown scheme allow-list. - try { - const scheme = new URL(url).protocol - if (scheme === 'http:' || scheme === 'https:' || scheme === 'mailto:') { - shell.openExternal(url) - } else { - console.warn('[main] blocked external open of non-web URL scheme:', scheme) - } - } catch { - console.warn('[main] blocked external open of unparseable URL') - } + shell.openExternal(url) return { action: 'deny' } }) - // HMR for renderer base on electron-vite cli. - // Load the remote URL for development, or the loopback renderer server in - // production — a file:// origin would break Firebase sign-in (see - // rendererServer.ts). loadFile stays as a last resort so a server failure - // still produces a window (signed-out features only). - if (is.dev && process.env['ELECTRON_RENDERER_URL']) { - mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) - } else if (rendererBaseUrl()) { - mainWindow.loadURL(`${rendererBaseUrl()}/index.html`) - } else { - mainWindow.loadFile(join(__dirname, '../renderer/index.html')) - } + // Dev: Vite dev server. Production: in-process localhost static server (so + // Firebase auth has an authorized origin + localStorage persists). The server + // is started in whenReady before this runs. + loadRenderer(mainWindow) return mainWindow } @@ -188,21 +205,12 @@ function createWindow(): BrowserWindow { // initialization and is ready to create browser windows. // Some APIs can only be used after this event occurs. app.whenReady().then(async () => { + // Lost the single-instance race — the first instance gets the 'second-instance' + // event and focuses itself; this one must do nothing (it's already quitting). + if (!gotSingleInstanceLock) return perfMark('main:ready') - - // Production only (dev uses the vite dev server): serve the packaged renderer - // over localhost so Firebase auth sees an authorized origin. Must be up before - // any window loads. - if (!(is.dev && process.env['ELECTRON_RENDERER_URL'])) { - try { - await startRendererServer(join(__dirname, '../renderer')) - } catch (e) { - console.error( - '[main] renderer server failed to start — falling back to file:// (sign-in will not work):', - e - ) - } - } + // Start the production renderer server before any window loads (no-op in dev). + await startRenderServer() // Set app user model id for windows electronApp.setAppUserModelId('com.omiwindows.app') @@ -217,7 +225,10 @@ app.whenReady().then(async () => { // In Electron we control the network stack, so strip the Origin header on // outgoing requests and inject permissive CORS response headers. Scoped to // the specific upstreams — everything else flows normally. - const apiUrls = ['https://api.omi.me/*', 'https://desktop-backend-hhibjajaja-uc.a.run.app/*'] + const apiUrls = [ + 'https://api.omi.me/*', + 'https://desktop-backend-hhibjajaja-uc.a.run.app/*' + ] session.defaultSession.webRequest.onBeforeSendHeaders({ urls: apiUrls }, (details, cb) => { const headers = { ...details.requestHeaders } delete headers.Origin @@ -270,6 +281,15 @@ app.whenReady().then(async () => { ipcMain.handle('db:remapConversationId', async (_e, fromId: string, toId: string) => remapConversationId(fromId, toId) ) + ipcMain.handle('app:getVersion', () => app.getVersion()) + // Smoke test: trigger the update UI on demand from the DevTools console + // (`window.omi.simulateUpdate()`). DEV ONLY — a no-op in the packaged app so it + // can never fire for end users. + ipcMain.handle('update:simulate', () => { + if (app.isPackaged) return undefined + if (mainWindowRef) return simulateUpdateUi(mainWindowRef) + return undefined + }) ipcMain.handle('db:insertLocalConversation', async (_e, c) => insertLocalConversation(c)) ipcMain.handle('db:getLocalConversation', async (_e, id: string) => getLocalConversation(id)) ipcMain.handle('db:listLocalConversations', async () => listLocalConversations()) @@ -316,6 +336,21 @@ app.whenReady().then(async () => { registerScreenSynthHandlers() const mainWindow = createWindow() + mainWindowRef = mainWindow + + // Proactively commit DOM storage to disk on a timer. Chromium only durably + // commits localStorage on a CLEAN shutdown; if the app is force-killed (or the + // machine sleeps/crashes) between commits, the last session's writes — Firebase + // auth session + onboarding flag — are silently lost on next launch (the user + // gets logged out / re-onboarded). Flushing every few seconds bounds that loss. + const storageFlush = setInterval(() => { + try { + session.defaultSession.flushStorageData() + } catch { + /* best-effort */ + } + }, 4000) + app.once('will-quit', () => clearInterval(storageFlush)) // Defer non-essential background services until the window is ready to show, so // their synchronous setup (foreground-monitor koffi/user32 init ~60ms, rewind @@ -339,6 +374,16 @@ app.whenReady().then(async () => { setTimeout(() => prewarmPrimarySourceId(), 4000) // Pre-create the (hidden) acrylic toast window so the first Omi insight shows instantly. createInsightToastWindow() + // Check GitHub Releases for a newer version (packaged builds only; no-op in dev). + initAutoUpdate(mainWindow) + // Smoke test: OMI_SIMULATE_UPDATE=1 walks the update UI (available dialog → + // progress dialog → restart dialog) with fake data, no network/install — so + // the flow can be verified in `pnpm dev` without publishing a release. DEV + // ONLY so it can never fire in the packaged app. + if (!app.isPackaged && process.env.OMI_SIMULATE_UPDATE === '1') { + console.log('[autoUpdate] OMI_SIMULATE_UPDATE=1 — running update-UI smoke test in 1.5s') + setTimeout(() => void simulateUpdateUi(mainWindow), 1500) + } }) // Overlay: wire IPC + global shortcut. The overlay window is created lazily on @@ -360,6 +405,11 @@ app.whenReady().then(async () => { mainWindow.on('closed', () => { const overlay = getOverlayWindow() if (overlay && !overlay.isDestroyed()) overlay.destroy() + // Quit the whole app when the main window closes. Hidden helper windows (the + // pre-created insight toast) would otherwise keep the process alive so + // 'window-all-closed' never fires — the app lingers, never shuts down cleanly, + // and Chromium never commits localStorage, logging the user out next launch. + if (!isQuitting) app.quit() }) // Bench mode: after the renderer has loaded, run the fixed DB + IPC workload, @@ -448,6 +498,13 @@ app.on('window-all-closed', () => { // On a normal shutdown: flush buffered perf marks, release the overlay shortcut, // and tear down the automation helper process + foreground-window hook. app.on('will-quit', () => { + // Final synchronous commit of DOM storage (Firebase session + onboarding flag) + // before the process exits, on top of the periodic flush. + try { + session.defaultSession.flushStorageData() + } catch { + /* best-effort */ + } unregisterOverlayShortcut() flushPerfMarks() automationBridge.dispose() diff --git a/desktop/windows/src/main/insight/toastWindow.ts b/desktop/windows/src/main/insight/toastWindow.ts index 8ac1fb389dd..bfeaea0e431 100644 --- a/desktop/windows/src/main/insight/toastWindow.ts +++ b/desktop/windows/src/main/insight/toastWindow.ts @@ -5,9 +5,8 @@ // after a timeout (paused while hovered). import { BrowserWindow, screen } from 'electron' import { join } from 'path' -import { is } from '@electron-toolkit/utils' +import { loadRenderer } from '../render/renderServer' import type { InsightPayload } from '../../shared/types' -import { rendererBaseUrl } from '../rendererServer' const WIDTH = 360 const HEIGHT = 168 @@ -59,15 +58,7 @@ function ensureWindow(): BrowserWindow { win.on('closed', () => { toastWindow = null }) - // Same-origin as the main window (see overlay/window.ts) so the toast sees - // the signed-in auth state. - if (is.dev && process.env['ELECTRON_RENDERER_URL']) { - win.loadURL(`${process.env['ELECTRON_RENDERER_URL']}#/insight-toast`) - } else if (rendererBaseUrl()) { - win.loadURL(`${rendererBaseUrl()}/index.html#/insight-toast`) - } else { - win.loadFile(join(__dirname, '../renderer/index.html'), { hash: 'insight-toast' }) - } + loadRenderer(win, 'insight-toast') applyMaterial(win) toastWindow = win return win diff --git a/desktop/windows/src/main/ipc/automation.ts b/desktop/windows/src/main/ipc/automation.ts index 669c5bd2cff..76d18ccf0b4 100644 --- a/desktop/windows/src/main/ipc/automation.ts +++ b/desktop/windows/src/main/ipc/automation.ts @@ -1,7 +1,7 @@ -import { ipcMain, dialog, BrowserWindow } from 'electron' +import { ipcMain, dialog, BrowserWindow, type WebContents } from 'electron' import { automationBridge } from '../automation/bridge' import { getAutomationTargetHandle } from '../automation/foregroundTarget' -import type { AutomationPlan, AutomationStep, UiSnapshot } from '../../shared/types' +import type { AutomationPlan, AutomationStep, PlanRunResult, UiSnapshot } from '../../shared/types' // Result of the native-dialog confirm flow. `canceled` distinguishes a user // "Cancel" from an execution failure. @@ -45,11 +45,12 @@ export function registerAutomationHandlers(): void { return getAutomationTargetHandle() }) - // The only run path is the consent-gated automation:confirmRun below. The - // former dialog-less 'automation:run' IPC was removed: it was exposed to the - // renderer but had no legitimate caller, and let web content drive Windows UI - // input with no approval. Per-step progress events aren't needed by the - // confirm flow (it resolves once on completion). + ipcMain.handle('automation:run', async (e, plan: AutomationPlan): Promise => { + const wc: WebContents = e.sender + return automationBridge.run(plan, (r) => { + if (!wc.isDestroyed()) wc.send('automation:step', r) + }) + }) // Consent gate as a NATIVE Windows dialog (works identically from the main // window and the floating overlay, since it lives here in main). Shows the plan diff --git a/desktop/windows/src/main/ipc/db.ts b/desktop/windows/src/main/ipc/db.ts index 6d6d989b112..f50fcf638c4 100644 --- a/desktop/windows/src/main/ipc/db.ts +++ b/desktop/windows/src/main/ipc/db.ts @@ -95,6 +95,7 @@ function get(): Database.Database { created_at INTEGER NOT NULL, kind TEXT NOT NULL DEFAULT 'recording', messages TEXT, + segments TEXT, title TEXT ); @@ -189,6 +190,7 @@ function get(): Database.Database { // Migrate older databases that have local_conversation without these columns. ensureColumn(db, 'local_conversation', 'kind', "TEXT NOT NULL DEFAULT 'recording'") ensureColumn(db, 'local_conversation', 'messages', 'TEXT') + ensureColumn(db, 'local_conversation', 'segments', 'TEXT') ensureColumn(db, 'local_conversation', 'title', 'TEXT') // Node provenance for the LLM-synthesized graph (additive). ensureColumn(db, 'local_kg_nodes', 'aliases_json', 'TEXT') @@ -206,6 +208,7 @@ type LocalConversationRow = { createdAt: number kind: string | null messages: string | null + segments: string | null title: string | null } @@ -218,17 +221,20 @@ function mapLocalConversation(row: LocalConversationRow): LocalConversation { createdAt: row.createdAt, kind: row.kind === 'chat' ? 'chat' : 'recording', messages: row.messages ? (JSON.parse(row.messages) as ChatMessage[]) : undefined, + segments: row.segments + ? (JSON.parse(row.segments) as LocalConversation['segments']) + : undefined, title: row.title ?? null } } const LOCAL_CONVERSATION_COLUMNS = - 'id, started_at AS startedAt, ended_at AS endedAt, transcript, created_at AS createdAt, kind, messages, title' + 'id, started_at AS startedAt, ended_at AS endedAt, transcript, created_at AS createdAt, kind, messages, segments, title' export function insertLocalConversation(c: LocalConversation): void { get() .prepare( - 'INSERT OR REPLACE INTO local_conversation (id, started_at, ended_at, transcript, created_at, kind, messages, title) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' + 'INSERT OR REPLACE INTO local_conversation (id, started_at, ended_at, transcript, created_at, kind, messages, segments, title) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)' ) .run( c.id, @@ -238,6 +244,7 @@ export function insertLocalConversation(c: LocalConversation): void { c.createdAt, c.kind ?? 'recording', c.messages ? JSON.stringify(c.messages) : null, + c.segments ? JSON.stringify(c.segments) : null, c.title ?? null ) } diff --git a/desktop/windows/src/main/ipc/omiListen.ts b/desktop/windows/src/main/ipc/omiListen.ts index ab5671230c3..f616514350d 100644 --- a/desktop/windows/src/main/ipc/omiListen.ts +++ b/desktop/windows/src/main/ipc/omiListen.ts @@ -36,6 +36,36 @@ function emit(ownerId: number, msg: ListenMessage): void { } } +// Dev-only harness: when OMI_LISTEN_SIMULATE_CLOSE is set, a listen session does +// NOT hit the real backend. It fakes the "connected, then closed (1008 )" +// flow so the renderer's close handling + the user-facing alert can be verified on +// a HEALTHY account (you can't otherwise reproduce trial_expired / Bad user). The +// value is the close reason, e.g.: +// $env:OMI_LISTEN_SIMULATE_CLOSE="trial_expired" -> trial alert +// $env:OMI_LISTEN_SIMULATE_CLOSE="Bad user" -> account alert +// Unset (production) → this whole branch is skipped. +const SIMULATE_CLOSE = process.env.OMI_LISTEN_SIMULATE_CLOSE?.trim() + +function simulateClose(args: ListenStartArgs, owner: WebContents, reason: string): void { + console.log(`[omi-listen] SIMULATING close ${args.sessionId} code=1008 reason=${reason}`) + // Mirror the real timing: open first (so the renderer commits outcome='omi'), + // then — for trial_expired — the freemium event the backend sends before closing, + // then the 1008 close. + emit(owner.id, { sessionId: args.sessionId, kind: 'connected' }) + if (/trial_expired/i.test(reason)) { + setTimeout(() => { + emit(owner.id, { + sessionId: args.sessionId, + kind: 'event', + event: { type: 'freemium_threshold_reached', raw: { remaining_seconds: 0 } } + }) + }, 300) + } + setTimeout(() => { + emit(owner.id, { sessionId: args.sessionId, kind: 'closed', code: 1008, reason }) + }, 600) +} + function startSession(args: ListenStartArgs, owner: WebContents): void { const existing = sessions.get(args.sessionId) if (existing) { @@ -43,6 +73,10 @@ function startSession(args: ListenStartArgs, owner: WebContents): void { try { existing.ws.close() } catch { /* ignore */ } sessions.delete(args.sessionId) } + if (SIMULATE_CLOSE) { + simulateClose(args, owner, SIMULATE_CLOSE) + return + } // Decode (not verify) the JWT to derive the uid for the query param; the // backend verifies the token from the Authorization header. let uid = '' @@ -59,6 +93,10 @@ function startSession(args: ListenStartArgs, owner: WebContents): void { // Firebase token from the Authorization header. Send both. const base = buildEndpoint(args.language) const url = uid ? `${base}&uid=${encodeURIComponent(uid)}` : base + // Token rides in the Authorization header (not the URL), so the URL is safe to + // log. This surfaces the exact connect params + any close reason for diagnosing + // 1008s (trial_expired, "Bad uid", "language not supported", …). + console.log(`[omi-listen] connecting ${args.sessionId} src=${args.source} ${url}`) const ws = new WebSocket(url, { headers: { Authorization: `Bearer ${args.token}` } }) @@ -108,11 +146,13 @@ function startSession(args: ListenStartArgs, owner: WebContents): void { if (session.closed) return session.closed = true sessions.delete(args.sessionId) + const reason = reasonBuf.toString() + console.log(`[omi-listen] closed ${args.sessionId} code=${code} reason=${reason || '(none)'}`) emit(session.ownerId, { sessionId: args.sessionId, kind: 'closed', code, - reason: reasonBuf.toString() + reason }) }) } diff --git a/desktop/windows/src/main/overlay/bounds.ts b/desktop/windows/src/main/overlay/bounds.ts index b4fe8aa3c6f..65cc98bd027 100644 --- a/desktop/windows/src/main/overlay/bounds.ts +++ b/desktop/windows/src/main/overlay/bounds.ts @@ -20,8 +20,11 @@ export type Bounds = { x: number; y: number; width: number; height: number } * near the top (TOP_MARGIN px down), height clamped to 70% of the work area, and * nudged up so it never runs off the bottom edge of the display. */ -export function computeOverlayBounds(workArea: WorkArea, contentHeight: number): Bounds { - const width = OVERLAY_WIDTH +export function computeOverlayBounds( + workArea: WorkArea, + contentHeight: number, + width: number = OVERLAY_WIDTH +): Bounds { const x = Math.round(workArea.x + (workArea.width - width) / 2) const maxHeight = Math.round(workArea.height * MAX_HEIGHT_FRACTION) diff --git a/desktop/windows/src/main/overlay/ipc.ts b/desktop/windows/src/main/overlay/ipc.ts index 972df65fd21..e751b8af1d8 100644 --- a/desktop/windows/src/main/overlay/ipc.ts +++ b/desktop/windows/src/main/overlay/ipc.ts @@ -1,5 +1,5 @@ import { ipcMain, BrowserWindow } from 'electron' -import { hideOverlay, setOverlayHeight, setOverlayEnabled } from './window' +import { hideOverlay, setOverlayHeight, setOverlayEnabled, setOverlayScale } from './window' import { setOverlayAccelerator, suspendOverlayShortcut, @@ -18,6 +18,9 @@ export function registerOverlayHandlers(focusMain: () => void): void { ipcMain.on('overlay:setHeight', (_e, px: number) => { if (typeof px === 'number' && px > 0) setOverlayHeight(px) }) + ipcMain.on('overlay:setScale', (_e, scale: number) => { + if (typeof scale === 'number') setOverlayScale(scale) + }) ipcMain.on('overlay:focusMain', () => { hideOverlay() focusMain() diff --git a/desktop/windows/src/main/overlay/window.ts b/desktop/windows/src/main/overlay/window.ts index b18aea06708..2621c30d044 100644 --- a/desktop/windows/src/main/overlay/window.ts +++ b/desktop/windows/src/main/overlay/window.ts @@ -8,9 +8,8 @@ // the corners. Visual confirmation is manual GUI. import { app, BrowserWindow, screen } from 'electron' import { join } from 'path' -import { is } from '@electron-toolkit/utils' +import { loadRenderer } from '../render/renderServer' import { computeOverlayBounds, OVERLAY_WIDTH } from './bounds' -import { rendererBaseUrl } from '../rendererServer' let overlayWindow: BrowserWindow | null = null @@ -33,7 +32,7 @@ export function getOverlayWindow(): BrowserWindow | null { */ export function createOverlayWindow(): BrowserWindow { const win = new BrowserWindow({ - width: OVERLAY_WIDTH, + width: currentOverlayWidth(), height: 200, show: false, // Hidden title bar, NO native caption buttons (no minimize/close). The window @@ -104,16 +103,9 @@ export function createOverlayWindow(): BrowserWindow { console.error('[overlay] did-fail-load', code, desc, url) ) - // Must load from the same origin as the main window (dev server or the - // production loopback server) — auth/localStorage state is per-origin, so a - // file:// overlay would always look signed out. - if (is.dev && process.env['ELECTRON_RENDERER_URL']) { - win.loadURL(`${process.env['ELECTRON_RENDERER_URL']}#/overlay`) - } else if (rendererBaseUrl()) { - win.loadURL(`${rendererBaseUrl()}/index.html#/overlay`) - } else { - win.loadFile(join(__dirname, '../renderer/index.html'), { hash: 'overlay' }) - } + // Same origin as the main window (localhost in prod) so the overlay shares the + // default session's Firebase auth + onboarding state. + loadRenderer(win, 'overlay') applyOverlayMaterial(win) overlayWindow = win @@ -185,6 +177,38 @@ let activeWorkArea: { x: number; y: number; width: number; height: number } | nu // shortcut-setup step ships later. let overlayEnabled = false +// Global UI scale (1 = default). The overlay renderer zooms its fixed-width panel +// by this factor, so the window WIDTH must scale to match (height is measured from +// the DOM and handled by the height tween). Pushed from the renderer on startup +// and whenever the user changes the UI scale in Settings. +let overlayScale = 1 + +/** The overlay window width at the current UI scale. The renderer paints the panel + * at OVERLAY_WIDTH × scale, so the window must be that wide to fit it edge-to-edge. */ +function currentOverlayWidth(): number { + return Math.round(OVERLAY_WIDTH * overlayScale) +} + +/** + * Set the global UI scale. Clamps it, forwards it to the (warm) overlay window so + * it re-zooms its panel live, and resizes the window width to match — immediately + * if it's currently open, otherwise the new width is applied on the next summon. + */ +export function setOverlayScale(scale: number): void { + if (!Number.isFinite(scale)) return + overlayScale = Math.min(1.6, Math.max(0.8, scale)) + const win = overlayWindow + if (!win || win.isDestroyed()) return + win.webContents.send('overlay:scale', overlayScale) + // If it's open, re-fit the window to the new width now (the renderer's re-zoom + // will also report a fresh height, which the height tween picks up). + if (win.isVisible()) { + const wa = + activeWorkArea ?? screen.getDisplayNearestPoint(screen.getCursorScreenPoint()).workArea + win.setBounds(computeOverlayBounds(wa, lastContentHeight, currentOverlayWidth())) + } +} + /** Enable/disable summoning. Disabling also hides the overlay if it's open. */ export function setOverlayEnabled(enabled: boolean): void { overlayEnabled = enabled @@ -241,7 +265,7 @@ function presentOverlay(win: BrowserWindow): void { if (!activeWorkArea) { activeWorkArea = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()).workArea } - win.setBounds(computeOverlayBounds(activeWorkArea, lastContentHeight)) + win.setBounds(computeOverlayBounds(activeWorkArea, lastContentHeight, currentOverlayWidth())) snapUntil = Date.now() + SETTLE_MS win.show() win.focus() @@ -337,7 +361,7 @@ export function setOverlayHeight(contentHeight: number): void { // somehow unset) so the tween never repositions onto a different monitor. const workArea = activeWorkArea ?? screen.getDisplayNearestPoint(screen.getCursorScreenPoint()).workArea - const target = computeOverlayBounds(workArea, contentHeight) + const target = computeOverlayBounds(workArea, contentHeight, currentOverlayWidth()) const current = win.getBounds() // Retarget the (single, continuous) tween toward the latest goal. Updating these diff --git a/desktop/windows/src/main/render/renderServer.ts b/desktop/windows/src/main/render/renderServer.ts new file mode 100644 index 00000000000..f7a28465e14 --- /dev/null +++ b/desktop/windows/src/main/render/renderServer.ts @@ -0,0 +1,106 @@ +// In-process static server for the built renderer, used in PACKAGED builds only. +// +// Why not loadFile()? Loading from file:// gives Firebase Auth an unauthorized +// origin (signInWithPopup throws auth/unauthorized-domain), and file:// origins +// don't persist localStorage reliably across the dev→prod boundary. Serving the +// same bundle over http://localhost — an origin Firebase authorizes by default — +// fixes sign-in AND, because the port is fixed, keeps the origin (and its +// localStorage: Firebase session + onboarding flag) stable across launches. +// +// In DEV the Vite dev server already provides http://localhost:5179, so this is +// a no-op there. All three renderer windows (main, overlay, insight toast) load +// through loadRenderer() so they share one origin and therefore one auth session. +import { createServer, Server } from 'http' +import { createReadStream, existsSync } from 'fs' +import { join } from 'path' +import { is } from '@electron-toolkit/utils' +import { BrowserWindow } from 'electron' +import { contentTypeFor, requestToRelPath } from './renderServerLogic' + +// Fixed, dedicated port (NOT the dev server's 5179) so the production origin is +// stable. If it's somehow taken we fall back to an ephemeral port — any localhost +// port is Firebase-authorized, so sign-in still works; only cross-launch +// persistence depends on the port staying constant. +const PROD_PORT = 41730 + +let baseUrl: string | null = null + +/** Absolute path to the built renderer dir (out/renderer, inside the asar). */ +function rendererRoot(): string { + return join(__dirname, '../renderer') +} + +function usingDevServer(): boolean { + return is.dev && !!process.env['ELECTRON_RENDERER_URL'] +} + +/** The base origin to load from, or null when the dev server should be used. */ +export function getRenderBaseUrl(): string | null { + return baseUrl +} + +function listen(server: Server, port: number, host: string): Promise { + return new Promise((resolve, reject) => { + const onError = (err: NodeJS.ErrnoException): void => reject(err) + server.once('error', onError) + server.listen(port, host, () => { + server.removeListener('error', onError) + const addr = server.address() + resolve(typeof addr === 'object' && addr ? addr.port : port) + }) + }) +} + +/** + * Start the static server (packaged builds only). No-ops in dev and is safe to + * call more than once. Must complete before any renderer window is loaded. + */ +export async function startRenderServer(): Promise { + if (usingDevServer() || baseUrl) return + + const root = rendererRoot() + const server = createServer((req, res) => { + const rel = requestToRelPath(req.url || '/') + let filePath = join(root, rel) + if (!existsSync(filePath)) filePath = join(root, 'index.html') + if (!existsSync(filePath)) { + res.statusCode = 404 + res.end('Not found') + return + } + res.setHeader('Content-Type', contentTypeFor(filePath)) + createReadStream(filePath) + .on('error', () => { + if (!res.headersSent) res.statusCode = 500 + res.end() + }) + .pipe(res) + }) + + let port = PROD_PORT + try { + port = await listen(server, PROD_PORT, '127.0.0.1') + } catch { + // Fixed port taken — fall back to an ephemeral one (auth still works). + port = await listen(server, 0, '127.0.0.1') + } + baseUrl = `http://localhost:${port}` +} + +/** + * Load the built renderer into a window at an optional hash route. Uses the Vite + * dev server in dev, the localhost static server in production, and falls back to + * file:// only if the server never started (UI still renders; popup auth won't). + */ +export function loadRenderer(win: BrowserWindow, hash?: string): void { + const devUrl = process.env['ELECTRON_RENDERER_URL'] + if (is.dev && devUrl) { + win.loadURL(hash ? `${devUrl}#/${hash}` : devUrl) + return + } + if (baseUrl) { + win.loadURL(hash ? `${baseUrl}/#/${hash}` : `${baseUrl}/`) + return + } + win.loadFile(join(rendererRoot(), 'index.html'), hash ? { hash } : undefined) +} diff --git a/desktop/windows/src/main/render/renderServerLogic.test.ts b/desktop/windows/src/main/render/renderServerLogic.test.ts new file mode 100644 index 00000000000..07f1bc33d97 --- /dev/null +++ b/desktop/windows/src/main/render/renderServerLogic.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest' +import { contentTypeFor, requestToRelPath } from './renderServerLogic' + +describe('requestToRelPath', () => { + it('maps root to index.html', () => { + expect(requestToRelPath('/')).toBe('index.html') + expect(requestToRelPath('')).toBe('index.html') + }) + + it('returns asset paths under the root', () => { + expect(requestToRelPath('/assets/index-abc.js')).toBe('assets/index-abc.js') + }) + + it('strips query strings and hashes', () => { + expect(requestToRelPath('/index.html?v=1')).toBe('index.html') + expect(requestToRelPath('/assets/x.css#frag')).toBe('assets/x.css') + }) + + it('blocks path traversal out of the root', () => { + expect(requestToRelPath('/../../etc/passwd')).toBe('etc/passwd') + expect(requestToRelPath('/..%2f..%2fsecret')).toBe('secret') + }) + + it('percent-decodes', () => { + expect(requestToRelPath('/assets/a%20b.png')).toBe('assets/a b.png') + }) +}) + +describe('contentTypeFor', () => { + it('knows common web asset types', () => { + expect(contentTypeFor('app.js')).toBe('text/javascript') + expect(contentTypeFor('app.css')).toBe('text/css') + expect(contentTypeFor('index.html')).toBe('text/html') + expect(contentTypeFor('font.woff2')).toBe('font/woff2') + }) + + it('falls back to octet-stream for unknown types', () => { + expect(contentTypeFor('mystery.xyz')).toBe('application/octet-stream') + }) +}) diff --git a/desktop/windows/src/main/render/renderServerLogic.ts b/desktop/windows/src/main/render/renderServerLogic.ts new file mode 100644 index 00000000000..d45153803fe --- /dev/null +++ b/desktop/windows/src/main/render/renderServerLogic.ts @@ -0,0 +1,48 @@ +// Pure helpers for the in-process renderer static server, split out so they can +// be unit-tested under node Vitest (the http/electron glue in renderServer.ts +// can't). Mirrors the foregroundTargetLogic split. +import { extname } from 'path' + +const MIME: Record = { + '.html': 'text/html', + '.js': 'text/javascript', + '.mjs': 'text/javascript', + '.css': 'text/css', + '.json': 'application/json', + '.map': 'application/json', + '.wasm': 'application/wasm', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + '.webp': 'image/webp', + '.ico': 'image/x-icon', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.ttf': 'font/ttf', + '.mp3': 'audio/mpeg', + '.wav': 'audio/wav' +} + +/** Content-Type for a file path, defaulting to a safe binary type. */ +export function contentTypeFor(filePath: string): string { + return MIME[extname(filePath).toLowerCase()] ?? 'application/octet-stream' +} + +/** + * Map a request URL path to a SAFE relative path under the renderer root: + * strips query + hash, percent-decodes, drops any `..`/`.` segments (no path + * traversal out of the root), and maps `/` to `index.html`. Because the app + * uses HashRouter, the server only ever sees `/` and real asset paths. + */ +export function requestToRelPath(urlPath: string): string { + let p = (urlPath || '/').split('?')[0].split('#')[0] + try { + p = decodeURIComponent(p) + } catch { + /* malformed escape — fall through with the raw path */ + } + const segments = p.split(/[/\\]+/).filter((s) => s && s !== '.' && s !== '..') + return segments.length ? segments.join('/') : 'index.html' +} diff --git a/desktop/windows/src/main/rendererServer.ts b/desktop/windows/src/main/rendererServer.ts deleted file mode 100644 index a76483793e0..00000000000 --- a/desktop/windows/src/main/rendererServer.ts +++ /dev/null @@ -1,108 +0,0 @@ -// Serves the packaged renderer over http://localhost: instead of file://. -// -// Firebase's signInWithPopup validates window.location.origin against the -// project's authorized domains; `localhost` is authorized (and is what dev mode -// uses via the vite server on 5179), but a file:// origin fails hard with -// auth/unauthorized-domain — so a packaged build that loadFile()s the renderer -// can never sign in. Serving the same files over a loopback HTTP server gives -// every window the authorized `localhost` origin in production too. -// -// Port 5179 is preferred to match the dev server (web auth/localStorage state is -// per-origin INCLUDING port, so keeping it stable preserves the saved session -// across launches). If it's taken — e.g. a dev instance is running — the next -// free port is used; sign-in still works on any localhost port, the session -// just starts fresh for that run. -import { createServer, type Server } from 'node:http' -import { readFile } from 'node:fs/promises' -import { extname, join, normalize, sep } from 'node:path' - -const PREFERRED_PORT = 5179 -const PORT_ATTEMPTS = 10 - -const MIME: Record = { - '.html': 'text/html; charset=utf-8', - '.js': 'text/javascript; charset=utf-8', - '.css': 'text/css; charset=utf-8', - '.json': 'application/json', - '.map': 'application/json', - '.png': 'image/png', - '.webp': 'image/webp', - '.svg': 'image/svg+xml', - '.ico': 'image/x-icon', - '.woff': 'font/woff', - '.woff2': 'font/woff2', - '.txt': 'text/plain; charset=utf-8' -} - -let baseUrl: string | null = null - -/** http://localhost: once startRendererServer has resolved, else null (dev mode). */ -export function rendererBaseUrl(): string | null { - return baseUrl -} - -function listen(server: Server, port: number): Promise { - return new Promise((resolve, reject) => { - const onError = (err: NodeJS.ErrnoException): void => { - server.removeListener('listening', onListening) - if (err.code === 'EADDRINUSE') resolve(false) - else reject(err) - } - const onListening = (): void => { - server.removeListener('error', onError) - resolve(true) - } - server.once('error', onError) - server.once('listening', onListening) - server.listen(port, '127.0.0.1') - }) -} - -/** - * Start serving the built renderer directory (works transparently from inside - * app.asar — Electron's patched fs handles it) and remember the resulting base - * URL. Call once at startup in production, before any window loads. - */ -export async function startRendererServer(rendererRoot: string): Promise { - const root = normalize(rendererRoot) - - const server = createServer((req, res) => { - void (async () => { - const pathname = decodeURIComponent(new URL(req.url ?? '/', 'http://localhost').pathname) - const rel = pathname === '/' ? 'index.html' : pathname.slice(1) - const file = normalize(join(root, rel)) - // Containment check — never serve anything outside the renderer dir. - if (file !== root && !file.startsWith(root + sep)) { - res.writeHead(403).end() - return - } - try { - const body = await readFile(file) - res.writeHead(200, { - 'content-type': MIME[extname(file).toLowerCase()] ?? 'application/octet-stream', - 'cache-control': 'no-cache' - }) - res.end(body) - } catch { - res.writeHead(404).end() - } - })() - }) - - for (let i = 0; i < PORT_ATTEMPTS; i++) { - const port = PREFERRED_PORT + i - if (await listen(server, port)) { - baseUrl = `http://localhost:${port}` - if (port !== PREFERRED_PORT) { - console.warn( - `[renderer-server] port ${PREFERRED_PORT} busy — using ${port} (saved session may not carry over)` - ) - } - console.log(`[renderer-server] serving ${root} at ${baseUrl}`) - return baseUrl - } - } - throw new Error( - `[renderer-server] no free port in ${PREFERRED_PORT}–${PREFERRED_PORT + PORT_ATTEMPTS - 1}` - ) -} diff --git a/desktop/windows/src/main/rewind/captureService.ts b/desktop/windows/src/main/rewind/captureService.ts index fb38db696e0..aad46d233ec 100644 --- a/desktop/windows/src/main/rewind/captureService.ts +++ b/desktop/windows/src/main/rewind/captureService.ts @@ -7,9 +7,11 @@ import { shouldCaptureFrame } from './captureDecision' import { rewindFramePath } from './paths' import { helperProcess } from '../ocr/helperProcess' import { insertRewindFrame, setRewindFrameOcr } from '../ipc/db' -import { setCurrentScreen } from './currentScreen' +import { setCurrentScreen, reaffirmCurrentScreen } from './currentScreen' +import { createLatestRunner } from './latestRunner' import { getPersistedRewindSettings, persistRewindSettings } from './rewindSettings' import { BUILT_IN_EXCLUDED_APPS } from '../../shared/rewindExclusions' +import { DEFAULT_CAPTURE_MAX_EDGE } from '../../shared/rewindResolution' import type { RewindSettings } from '../../shared/types' const HASH_W = 16 @@ -19,6 +21,9 @@ const IDLE_THRESHOLD_SECONDS = 60 let locked = false let lastHash: string | null = null let powerListenersBound = false +// Last logged frame dimensions, so the resolution-change log fires only on change. +let lastLoggedWidth = 0 +let lastLoggedHeight = 0 // In-memory mirror of the persisted settings. startRewindCapture() loads the // saved value (defaulting to capture-on) at startup; updateRewindSettings() // keeps this and the on-disk copy in sync. Defaults to capture-on for any @@ -27,7 +32,8 @@ let settings: RewindSettings = { captureEnabled: true, intervalMs: 1000, retentionDays: 14, - excludedApps: [] + excludedApps: [], + captureMaxEdge: DEFAULT_CAPTURE_MAX_EDGE } function bindPowerListeners(): void { @@ -39,33 +45,32 @@ function bindPowerListeners(): void { export type IngestResult = { captured: boolean; reason?: string } -// Single-flight guard so the background "current screen" OCR never stacks: the -// helper processes one frame at a time, and a captured frame arrives ~every second. -// If an OCR is already running we skip this frame — the cache stays ~1-2s fresh, -// which is plenty for the chat's instant read. -let screenOcrInFlight = false - /** * Keep the chat's hot "current screen" cache fresh: OCR a just-captured frame in * the background and store the text in {@link setCurrentScreen}, so the chat reads * it with zero latency. Also persists the OCR onto the frame so the slower * backfiller doesn't re-OCR it. Best-effort and NEVER awaited by the capture path. + * + * Single-flight with trailing-edge coalescing to the LATEST frame: the helper + * OCRs one frame at a time (~0.2-2.5s) while captured frames arrive every ~1s, so + * when the screen changes faster than OCR completes we must keep the NEWEST frame + * and process it next — not drop it. A plain "skip if busy" guard dropped the new + * frame, and since `lastHash` had already advanced, every later (identical) frame + * was a duplicate → OCR never re-ran → the cache stayed stranded on the OLD + * screen's text (the "reads an old screen / looks frozen" bug). */ -async function refreshCurrentScreen(frameId: number, jpeg: Buffer): Promise { - if (screenOcrInFlight) return - screenOcrInFlight = true - try { +const submitScreenOcr = createLatestRunner<{ frameId: number; jpeg: Buffer }>( + async ({ frameId, jpeg }) => { const res = await helperProcess.ocr(jpeg) if (res.ok) { setCurrentScreen(res.fullText) setRewindFrameOcr(frameId, res.fullText) + console.log(`[rewind:screen] cache <- ${res.fullText.length} chars (frame ${frameId})`) + } else { + console.warn(`[rewind:screen] OCR failed (frame ${frameId}): ${res.code} ${res.message ?? ''}`) } - } catch { - /* best-effort: keep the last good cached value */ - } finally { - screenOcrInFlight = false } -} +) /** * Ingest one screen frame (JPEG bytes) sampled by the renderer's capture host @@ -83,34 +88,28 @@ export async function ingestRewindFrame(jpeg: Buffer): Promise { // other app; the timeline keeps filling (and the chat's screen cache stays fresh) // even while Omi is focused. The dedup hash below still skips unchanged frames. + // Read foreground-window metadata (for exclusion + sensitive-title gating and + // for storage) from the always-instant, in-process user32 readers — NOT the C# + // helper. The helper is single-threaded and now constantly busy OCRing the live + // screen, so a windowInfo() call there queues behind the in-flight OCR + // (~0.5-2.5s, measured) and stalls EVERY capture, stretching the cadence and + // delaying how fast a screen change reaches the chat. user32 is ~ms and never + // contends. (Slight cosmetic cost: the timeline shows the capitalized exe name + // "Chrome" rather than the helper's friendly "Google Chrome"; exclusion + // matching is unaffected since it's case-insensitive substring on app + proc.) let win = { app: '', title: '', processName: '' } - try { - const info = await helperProcess.windowInfo() - // Prefer the friendly app name ("Google Chrome") over the exe ("chrome"); - // keep the raw process name in its own field. - win = { app: info.app || info.processName, title: info.title, processName: info.processName } - } catch { - /* helper unavailable; fall back below */ - } - // The C# helper isn't always running (OCR is shelved), so windowInfo() often - // yields nothing → every frame would read "Unknown app". Fall back to the - // always-available koffi/user32 foreground reader (same source app-usage uses) - // and derive a name from the foreground exe. - if (!win.app) { - const exe = getForegroundExePath() - if (exe) { - const proc = basename(exe).replace(/\.exe$/i, '') - win = { - app: proc ? proc.charAt(0).toUpperCase() + proc.slice(1) : '', - title: win.title, - processName: win.processName || proc - } + const exe = getForegroundExePath() + if (exe) { + const proc = basename(exe).replace(/\.exe$/i, '') + win = { + app: proc ? proc.charAt(0).toUpperCase() + proc.slice(1) : '', + title: '', + processName: proc } } - // The helper rarely runs (OCR shelved), so the title is usually empty — but the - // window title is what catches login/private-browsing screens in a normal - // browser. Read it directly from user32 (GetWindowTextW) as a fallback. - if (!win.title) win.title = getForegroundWindowTitle() ?? '' + // The window title is what catches login/private-browsing screens in a normal + // browser, so always read it (GetWindowTextW). + win.title = getForegroundWindowTitle() ?? '' const image = nativeImage.createFromBuffer(jpeg) if (image.isEmpty()) return { captured: false, reason: 'decode-failed' } @@ -130,13 +129,28 @@ export async function ingestRewindFrame(jpeg: Buffer): Promise { hash, lastHash }) - if (!decision.capture) return { captured: false, reason: decision.reason } + if (!decision.capture) { + // A duplicate frame means the screen is unchanged since the last captured + + // OCR'd frame, so the hot "current screen" cache text is still accurate right + // now — re-affirm its freshness (no re-OCR) so a static screen doesn't age the + // cache out (CACHE_FRESH_MS) and leave the chat unable to read it. Other skip + // reasons (idle/lock/excluded/sensitive) intentionally let the cache go stale. + if (decision.reason === 'duplicate') reaffirmCurrentScreen() + return { captured: false, reason: decision.reason } + } try { const ts = Date.now() const path = rewindFramePath(ts) writeFileSync(path, jpeg) const { width, height } = image.getSize() + // Log frame dimensions only when they change — lets the resolution Setting be + // verified (the longest edge should track captureMaxEdge) without per-frame spam. + if (width !== lastLoggedWidth || height !== lastLoggedHeight) { + lastLoggedWidth = width + lastLoggedHeight = height + console.log(`[rewind] frame size ${width}x${height} (captureMaxEdge=${settings.captureMaxEdge})`) + } const id = insertRewindFrame({ ts, app: win.app, @@ -150,8 +164,9 @@ export async function ingestRewindFrame(jpeg: Buffer): Promise { }) lastHash = hash // Update the chat's hot "current screen" cache from this fresh frame, in the - // background (single-flight). Not awaited: capture cadence must not wait on OCR. - void refreshCurrentScreen(id, jpeg) + // background. Coalesces to the latest frame; never awaited (capture cadence + // must not wait on OCR). + submitScreenOcr({ frameId: id, jpeg }) return { captured: true } } catch (e) { console.error('[rewind] capture failed:', (e as Error).message) diff --git a/desktop/windows/src/main/rewind/currentScreen.test.ts b/desktop/windows/src/main/rewind/currentScreen.test.ts index c0b0f8bd94b..84e5f213d87 100644 --- a/desktop/windows/src/main/rewind/currentScreen.test.ts +++ b/desktop/windows/src/main/rewind/currentScreen.test.ts @@ -4,6 +4,7 @@ import { getCurrentScreen, currentScreenAgeMs, screenCacheFresh, + reaffirmCurrentScreen, CACHE_FRESH_MS } from './currentScreen' @@ -72,3 +73,55 @@ describe('screenCacheFresh', () => { expect(screenCacheFresh(Date.now())).toBe(true) }) }) + +describe('reaffirmCurrentScreen', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-06-09T00:00:00Z')) + }) + afterEach(() => { + vi.useRealTimers() + vi.resetModules() + }) + + it('keeps the same text but bumps freshness (screen unchanged → still current)', () => { + setCurrentScreen('on screen') + vi.advanceTimersByTime(CACHE_FRESH_MS - 1000) + reaffirmCurrentScreen() + // Text is untouched... + expect(getCurrentScreen().text).toBe('on screen') + // ...but the cache is freshly stamped, so age resets toward zero. + expect(currentScreenAgeMs()).toBe(0) + }) + + it('is a no-op when the cache was never seeded this session (ts === 0)', async () => { + vi.resetModules() + const mod = await import('./currentScreen') + mod.reaffirmCurrentScreen() + expect(mod.screenCacheFresh(Date.now())).toBe(false) + }) + + // Regression for Bug #4: a screen held static streams only "duplicate" frames, + // which never re-OCR. Without re-affirming on each duplicate, the cache ages out + // at CACHE_FRESH_MS and the chat stops being able to read an unchanged screen. + it('keeps a static screen readable past CACHE_FRESH_MS when duplicates re-affirm it', () => { + setCurrentScreen('static article text') + // Simulate ~2× the freshness window of duplicate frames arriving every second, + // each one re-affirming the unchanged screen. + for (let elapsed = 0; elapsed < CACHE_FRESH_MS * 2; elapsed += 1000) { + vi.advanceTimersByTime(1000) + reaffirmCurrentScreen() + expect(screenCacheFresh(Date.now())).toBe(true) + } + expect(getCurrentScreen().text).toBe('static article text') + }) + + // The cache must still go stale when there is NO confirming signal at all + // (capture paused on idle/lock/excluded) — i.e. re-affirm is the only thing + // holding it fresh, not an unconditional extension. + it('still goes stale if nothing re-affirms it', () => { + setCurrentScreen('static article text') + vi.advanceTimersByTime(CACHE_FRESH_MS + 1) + expect(screenCacheFresh(Date.now())).toBe(false) + }) +}) diff --git a/desktop/windows/src/main/rewind/currentScreen.ts b/desktop/windows/src/main/rewind/currentScreen.ts index 08a4e091fa2..38a97168cdc 100644 --- a/desktop/windows/src/main/rewind/currentScreen.ts +++ b/desktop/windows/src/main/rewind/currentScreen.ts @@ -26,6 +26,19 @@ export function setCurrentScreen(t: string): void { ts = Date.now() } +/** + * Re-affirm that the cached text is still "what's on screen right now" WITHOUT + * re-OCR. Called when a freshly sampled frame is a duplicate of the last captured + * frame: the screen is unchanged, so the existing OCR text is still accurate — + * just bump the freshness stamp. Without this, a screen held static streams only + * duplicate frames (which never re-OCR), the cache ages past CACHE_FRESH_MS, and + * the chat stops being able to read an unchanged, perfectly-available screen. + * No-op when the cache was never seeded (ts === 0): there is nothing to affirm. + */ +export function reaffirmCurrentScreen(): void { + if (ts !== 0) ts = Date.now() +} + export function getCurrentScreen(): { text: string; ts: number } { return { text, ts } } diff --git a/desktop/windows/src/main/rewind/latestRunner.test.ts b/desktop/windows/src/main/rewind/latestRunner.test.ts new file mode 100644 index 00000000000..805e6b422f5 --- /dev/null +++ b/desktop/windows/src/main/rewind/latestRunner.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from 'vitest' +import { createLatestRunner } from './latestRunner' + +/** A deferred promise so a test can control exactly when a task "finishes". */ +function deferred(): { promise: Promise; resolve: (v: T) => void } { + let resolve!: (v: T) => void + const promise = new Promise((r) => (resolve = r)) + return { promise, resolve } +} + +describe('createLatestRunner', () => { + it('runs a single submission once', async () => { + const seen: number[] = [] + const submit = createLatestRunner(async (n) => { + seen.push(n) + }) + submit(1) + await Promise.resolve() + await Promise.resolve() + expect(seen).toEqual([1]) + }) + + // The core regression: while a task is in flight, the LATEST submission must + // still be processed once the current one finishes — never dropped. (The old + // "skip if busy" guard dropped it, stranding the cache on the previous screen.) + it('processes the latest submission after the in-flight one completes', async () => { + const seen: number[] = [] + const gate = deferred() + const submit = createLatestRunner(async (n) => { + seen.push(n) + if (n === 1) await gate.promise // hold the first task open + }) + + submit(1) // starts, blocks on the gate + await Promise.resolve() + submit(2) // arrives while 1 is in flight + submit(3) // supersedes 2 — only the newest matters + expect(seen).toEqual([1]) // 2 and 3 are still queued + + gate.resolve() // let 1 finish → trailing edge should run 3 (not 2) + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + expect(seen).toEqual([1, 3]) + }) + + it('runs sequential idle submissions each time', async () => { + const seen: number[] = [] + const submit = createLatestRunner(async (n) => { + seen.push(n) + }) + submit(1) + await Promise.resolve() + await Promise.resolve() + submit(2) + await Promise.resolve() + await Promise.resolve() + expect(seen).toEqual([1, 2]) + }) + + it('keeps running the trailing task even if the in-flight task throws', async () => { + const seen: number[] = [] + const gate = deferred() + const submit = createLatestRunner(async (n) => { + seen.push(n) + if (n === 1) { + await gate.promise + throw new Error('boom') + } + }) + submit(1) + await Promise.resolve() + submit(2) + gate.resolve() + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + expect(seen).toEqual([1, 2]) + }) +}) diff --git a/desktop/windows/src/main/rewind/latestRunner.ts b/desktop/windows/src/main/rewind/latestRunner.ts new file mode 100644 index 00000000000..85ae051d69f --- /dev/null +++ b/desktop/windows/src/main/rewind/latestRunner.ts @@ -0,0 +1,45 @@ +/** + * Single-flight runner with trailing-edge coalescing to the LATEST input. + * + * While a task is in flight, further submissions don't queue up and they aren't + * dropped — each replaces the single "pending" slot, so only the most recent + * input is kept. When the running task settles, the pending input (if any) runs + * next. This guarantees the latest input is always eventually processed. + * + * Use it for "keep an expensive derived value tracking the newest source" work + * (e.g. OCR of the current screen): we only care about *now*, intermediate + * inputs are safely superseded, but the newest one must never be lost — a plain + * "skip if busy" guard drops the newest input and strands the derived value on a + * stale one. + */ +export function createLatestRunner(run: (input: T) => Promise): (input: T) => void { + let inFlight = false + let hasPending = false + let pending: T | null = null + + const pump = async (input: T): Promise => { + inFlight = true + try { + await run(input) + } catch { + /* best-effort: keep draining the trailing input regardless */ + } finally { + inFlight = false + if (hasPending) { + const next = pending as T + hasPending = false + pending = null + void pump(next) + } + } + } + + return (input: T): void => { + if (inFlight) { + pending = input + hasPending = true + return + } + void pump(input) + } +} diff --git a/desktop/windows/src/main/rewind/rewindSettings.ts b/desktop/windows/src/main/rewind/rewindSettings.ts index 453a30798f3..1dbe113dba6 100644 --- a/desktop/windows/src/main/rewind/rewindSettings.ts +++ b/desktop/windows/src/main/rewind/rewindSettings.ts @@ -2,6 +2,7 @@ import { app } from 'electron' import { join } from 'path' import { readFileSync, writeFileSync } from 'fs' import type { RewindSettings } from '../../shared/types' +import { clampCaptureMaxEdge, DEFAULT_CAPTURE_MAX_EDGE } from '../../shared/rewindResolution' // Rewind capture is ON by default — screen history is a core feature, so a fresh // install (no settings file yet) starts capturing. Once the user changes a @@ -12,7 +13,8 @@ const DEFAULTS: RewindSettings = { captureEnabled: true, intervalMs: 1000, retentionDays: 14, - excludedApps: [] + excludedApps: [], + captureMaxEdge: DEFAULT_CAPTURE_MAX_EDGE } function file(): string { @@ -42,7 +44,8 @@ function sanitize(raw: Partial): RewindSettings { captureEnabled: raw.captureEnabled !== false, intervalMs, retentionDays, - excludedApps + excludedApps, + captureMaxEdge: clampCaptureMaxEdge(raw.captureMaxEdge) } } diff --git a/desktop/windows/src/main/update/autoUpdate.ts b/desktop/windows/src/main/update/autoUpdate.ts new file mode 100644 index 00000000000..fd7855faab5 --- /dev/null +++ b/desktop/windows/src/main/update/autoUpdate.ts @@ -0,0 +1,189 @@ +// Auto-update against GitHub Releases (public repo andermont/omi-windows). The +// packaged build embeds an app-update.yml generated from the `publish:` block in +// electron-builder.yml; electron-updater reads it, compares versions, downloads +// the new installer in the background, and installs it on quit (or on the user's +// "Restart now"). Pure decision logic lives in updateLogic.ts so it can be tested. +import { app, dialog, BrowserWindow } from 'electron' +import { is } from '@electron-toolkit/utils' +import electronUpdater from 'electron-updater' +import { shouldCheckForUpdates } from './updateLogic' +import { showUpdateProgress, hideUpdateProgress } from './progressDialog' + +const { autoUpdater } = electronUpdater + +// Re-check on long-running sessions so someone who never quits still gets fixes. +const RECHECK_INTERVAL_MS = 6 * 60 * 60 * 1000 + +let wired = false +// The version we've already shown the "Update available" prompt for, so the 6h +// recheck doesn't re-nag mid-session. +let promptedVersion: string | null = null + +// ── Shared update UI ──────────────────────────────────────────────────────── +// The native dialogs + taskbar progress bar, factored out so the real updater +// flow and the simulateUpdateUi() smoke test drive the identical UI. + +// Download progress: an on-screen pop-up window (the live bar) PLUS the native +// Windows taskbar-icon fill. percent is 0..100. +function reportProgress(window: BrowserWindow, version: string, percent: number): void { + if (!window.isDestroyed()) window.setProgressBar(Math.max(0, Math.min(1, percent / 100))) + showUpdateProgress(version, percent) +} + +// Hide both progress indicators (download finished / cancelled / errored). +function clearProgress(window: BrowserWindow): void { + if (!window.isDestroyed()) window.setProgressBar(-1) + hideUpdateProgress() +} + +/** Native "Omi X is available — Download / Later". Resolves true if Download. */ +function promptDownload(window: BrowserWindow, version: string): Promise { + return dialog + .showMessageBox(window, { + type: 'info', + buttons: ['Download', 'Later'], + defaultId: 0, + cancelId: 1, + title: 'Update available', + message: `Omi ${version} is available.`, + detail: + 'Download it now? You can keep using Omi while it downloads — a progress bar shows on the taskbar — and you’ll be asked to restart when it’s ready.' + }) + .then(({ response }) => response === 0) + .catch(() => false) +} + +/** Native "Omi X downloaded — Restart now / Later". Resolves true if Restart. */ +function promptRestart(window: BrowserWindow, version: string): Promise { + return dialog + .showMessageBox(window, { + type: 'info', + buttons: ['Restart now', 'Later'], + defaultId: 0, + cancelId: 1, + title: 'Update ready', + message: `Omi ${version} has been downloaded.`, + detail: 'Restart to apply it now, or it will install automatically next time you quit Omi.' + }) + .then(({ response }) => response === 0) + .catch(() => false) +} + +/** + * UI smoke test: walk the full update experience (available dialog → animated + * taskbar progress → restart dialog) with a fake version and synthetic progress, + * touching no network and never installing. Triggered by OMI_SIMULATE_UPDATE=1 so + * the dialogs/progress bar can be exercised in `pnpm dev` without publishing a + * release. Runs regardless of dev/packaged (it's opt-in via the env var). + */ +export async function simulateUpdateUi(window: BrowserWindow, version = '9.9.9'): Promise { + console.log('[autoUpdate] SIMULATION: update-UI smoke test for', version) + const accepted = await promptDownload(window, version) + if (!accepted) { + console.log('[autoUpdate] SIMULATION: user chose Later') + return + } + // Animate 0 → 100% over ~8s on the native taskbar bar (slow enough to clearly + // watch the Omi taskbar icon fill). + await new Promise((resolve) => { + let pct = 0 + const id = setInterval(() => { + pct = Math.min(pct + 4, 100) + reportProgress(window, version, pct) + if (pct >= 100) { + clearInterval(id) + resolve() + } + }, 320) + }) + clearProgress(window) + const restart = await promptRestart(window, version) + await dialog + .showMessageBox(window, { + type: 'info', + buttons: ['OK'], + title: 'Update simulation complete', + message: restart ? 'You chose “Restart now”.' : 'You chose “Later”.', + detail: 'UI smoke test (OMI_SIMULATE_UPDATE) — no real update was downloaded or installed.' + }) + .catch(() => {}) +} + +/** + * Wire up background auto-update. Safe to call once the main window exists; it + * no-ops in dev / unpacked / bench so day-to-day development is unaffected. + */ +export function initAutoUpdate(window: BrowserWindow): void { + if (wired) return + if ( + !shouldCheckForUpdates({ + isDev: is.dev, + isPackaged: app.isPackaged, + isBench: process.env.OMI_BENCH === '1' + }) + ) { + return + } + wired = true + + // Don't download until the user opts in via the native prompt below. + autoUpdater.autoDownload = false + autoUpdater.autoInstallOnAppQuit = true + + autoUpdater.on('error', (err) => { + // Never surface updater failures to the user — a missing release, no network, + // or a rate-limited GitHub API should just leave them on the current version. + clearProgress(window) // clear any stuck bar + console.warn('[autoUpdate] error:', err?.message ?? err) + }) + + // Ask the user (native dialog) before downloading, so they decide when to take + // the sizable download instead of it happening silently. Guard against the 6h + // recheck re-prompting the same version within a session. + autoUpdater.on('update-available', (info) => { + console.log('[autoUpdate] update available:', info.version) + if (window.isDestroyed() || promptedVersion === info.version) return + promptedVersion = info.version + void promptDownload(window, info.version).then((accepted) => { + if (accepted) { + reportProgress(window, info.version, 0) // show the pop-up immediately + autoUpdater.downloadUpdate().catch((err) => { + clearProgress(window) + console.warn('[autoUpdate] download failed:', err?.message ?? err) + }) + } else { + promptedVersion = null // declined — re-offer on the next launch + } + }) + }) + + // Pop-up + taskbar progress while the chosen update downloads. + autoUpdater.on('download-progress', (p) => { + reportProgress(window, promptedVersion ?? '', p.percent ?? 0) + }) + autoUpdater.on('update-not-available', () => { + console.log('[autoUpdate] already up to date') + }) + autoUpdater.on('update-downloaded', (info) => { + if (window.isDestroyed()) return // window gone — install on the next quit + clearProgress(window) // download finished — clear the bars + void promptRestart(window, info.version).then((restart) => { + // quitAndInstall(isSilent=true, isForceRunAfter=true): apply the update + // SILENTLY (runs the assisted installer with /S, no wizard, reusing the + // existing install dir) and relaunch into the new version. If they chose + // "Later", autoInstallOnAppQuit installs it on the next quit instead. + if (restart) autoUpdater.quitAndInstall(true, true) + }) + }) + + // Check only — the download starts after the user accepts the prompt above. + autoUpdater.checkForUpdates().catch((err) => { + console.warn('[autoUpdate] check failed:', err?.message ?? err) + }) + + const timer = setInterval(() => { + autoUpdater.checkForUpdates().catch(() => {}) + }, RECHECK_INTERVAL_MS) + // Don't let the recheck timer keep the process alive on quit. + timer.unref?.() +} diff --git a/desktop/windows/src/main/update/progressDialog.ts b/desktop/windows/src/main/update/progressDialog.ts new file mode 100644 index 00000000000..466beea0c7f --- /dev/null +++ b/desktop/windows/src/main/update/progressDialog.ts @@ -0,0 +1,67 @@ +// Drives the native Windows update-progress dialog (win-update-helper.exe). The +// helper renders a Task Dialog with a live progress bar; we spawn it on the first +// progress tick and feed it `progress ` / `done` on stdin. Replaces the +// earlier Electron pop-up window — this is a genuine native Windows dialog. +import { spawn, type ChildProcess } from 'child_process' +import { existsSync } from 'fs' +import { resolveUpdateHelperPath } from './resolveHelperPath' + +let proc: ChildProcess | null = null +let closingByUs = false +// True once the user closed the dialog themselves (Hide / ✕) — don't re-pop it +// for the rest of this download. +let dismissed = false + +/** Show (spawn if needed) the native progress dialog and push the latest percent. */ +export function showUpdateProgress(version: string, percent: number): void { + if (dismissed) return + if (!proc) { + const exe = resolveUpdateHelperPath() + if (!existsSync(exe)) { + console.warn('[autoUpdate] update-progress helper not found:', exe) + return + } + closingByUs = false + // NB: no `windowsHide` — it sets CREATE_NO_WINDOW, which suppresses the + // helper's Task Dialog from appearing. The helper is a WinExe, so there's no + // console window to hide anyway. + proc = spawn(exe, [version], { stdio: ['pipe', 'ignore', 'ignore'] }) + proc.on('error', (e) => { + console.warn('[autoUpdate] progress helper spawn error:', e?.message ?? e) + proc = null + }) + proc.on('exit', () => { + const wasUs = closingByUs + proc = null + closingByUs = false + if (!wasUs) dismissed = true // user dismissed — stay closed this download + }) + } + try { + proc.stdin?.write(`progress ${Math.max(0, Math.min(100, Math.round(percent)))}\n`) + } catch { + /* helper gone — ignore */ + } +} + +/** Close the native progress dialog (download finished / cancelled / errored). */ +export function hideUpdateProgress(): void { + dismissed = false // reset for the next download + if (!proc) return + closingByUs = true + const p = proc + proc = null + try { + p.stdin?.write('done\n') + } catch { + /* ignore */ + } + // Belt-and-suspenders: ensure it exits even if it didn't honor 'done'. + setTimeout(() => { + try { + if (!p.killed) p.kill() + } catch { + /* ignore */ + } + }, 1500) +} diff --git a/desktop/windows/src/main/update/resolveHelperPath.ts b/desktop/windows/src/main/update/resolveHelperPath.ts new file mode 100644 index 00000000000..41cd9bf24d7 --- /dev/null +++ b/desktop/windows/src/main/update/resolveHelperPath.ts @@ -0,0 +1,24 @@ +import { app } from 'electron' +import { join } from 'path' +import { existsSync } from 'fs' + +/** + * Resolve the on-disk path to the bundled win-update-helper.exe (the native + * Task-Dialog progress UI). Mirrors src/main/ocr/resolveHelperPath.ts: + * 1. Packaged via `asarUnpack: resources/**` + * 2. Packaged via extraResources + * 3. Dev (electron-vite) + * Returns the dev path last so the caller surfaces a clear "not found". + */ +export function resolveUpdateHelperPath(): string { + const exe = 'win-update-helper.exe' + const candidates = [ + join(process.resourcesPath, 'app.asar.unpacked', 'resources', 'win-update-helper', exe), + join(process.resourcesPath, 'win-update-helper', exe), + join(app.getAppPath(), 'resources', 'win-update-helper', exe) + ] + for (const c of candidates) { + if (existsSync(c)) return c + } + return candidates[candidates.length - 1] +} diff --git a/desktop/windows/src/main/update/updateLogic.test.ts b/desktop/windows/src/main/update/updateLogic.test.ts new file mode 100644 index 00000000000..f7ef2ad5b75 --- /dev/null +++ b/desktop/windows/src/main/update/updateLogic.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest' +import { shouldCheckForUpdates } from './updateLogic' + +const PACKAGED = { isDev: false, isPackaged: true, isBench: false } + +describe('shouldCheckForUpdates', () => { + it('checks from a real packaged build', () => { + expect(shouldCheckForUpdates(PACKAGED)).toBe(true) + }) + + it('never checks in dev', () => { + expect(shouldCheckForUpdates({ ...PACKAGED, isDev: true })).toBe(false) + }) + + it('never checks from an unpacked build', () => { + expect(shouldCheckForUpdates({ ...PACKAGED, isPackaged: false })).toBe(false) + }) + + it('never checks during a bench run, even if packaged', () => { + expect(shouldCheckForUpdates({ ...PACKAGED, isBench: true })).toBe(false) + }) +}) diff --git a/desktop/windows/src/main/update/updateLogic.ts b/desktop/windows/src/main/update/updateLogic.ts new file mode 100644 index 00000000000..1dbcf82594a --- /dev/null +++ b/desktop/windows/src/main/update/updateLogic.ts @@ -0,0 +1,25 @@ +// Pure decision logic for the auto-updater, split out from the Electron glue in +// autoUpdate.ts so it can be unit-tested under node Vitest (which can't import +// `electron` / `electron-updater`). Mirrors the foregroundTargetLogic split. + +export interface UpdateEnv { + /** electron-toolkit `is.dev` — running under the Vite dev server. */ + isDev: boolean + /** `app.isPackaged` — false for an unpacked/dev build. electron-updater only + * works against a packaged build (it reads the embedded app-update.yml). */ + isPackaged: boolean + /** Bench runs (OMI_BENCH=1) must never reach out to GitHub. */ + isBench: boolean +} + +/** + * Whether the app should check GitHub Releases for updates this launch. We only + * check from a real packaged build, never in dev or bench. Keeping this pure + * makes the (otherwise untestable) Electron wiring trivially verifiable. + */ +export function shouldCheckForUpdates(env: UpdateEnv): boolean { + if (env.isBench) return false + if (env.isDev) return false + if (!env.isPackaged) return false + return true +} diff --git a/desktop/windows/src/main/update/win-update-helper/Program.cs b/desktop/windows/src/main/update/win-update-helper/Program.cs new file mode 100644 index 00000000000..c60e10f3826 --- /dev/null +++ b/desktop/windows/src/main/update/win-update-helper/Program.cs @@ -0,0 +1,77 @@ +// Native Windows update-progress dialog. Shows a Task Dialog (the modern native +// Windows dialog, same family as a message box) with a live progress bar, and +// updates it from commands on stdin sent by the Electron main process: +// progress <0-100> set the bar +// done close the dialog +// stdin EOF (parent exited) also closes it. argv[0] is the version string. +using System; +using System.Threading; +using System.Windows.Forms; + +static class Program +{ + static volatile int _percent = 0; + static volatile bool _done = false; + + [STAThread] + static void Main(string[] args) + { + string version = args.Length > 0 ? args[0] : "update"; + Application.EnableVisualStyles(); + + var page = new TaskDialogPage + { + Caption = "Omi", + Heading = $"Downloading Omi {version}", + Text = "You can keep using Omi while it downloads. Omi will restart to finish updating.", + AllowCancel = true + }; + var progress = new TaskDialogProgressBar { Minimum = 0, Maximum = 100, Value = 0 }; + page.ProgressBar = progress; + var hideButton = new TaskDialogButton("Hide"); + page.Buttons.Add(hideButton); + + // The Task Dialog runs its own modal message loop; a WinForms Timer on that + // (UI) thread is the safe way to apply progress posted from the stdin thread. + page.Created += (_, _) => + { + var timer = new System.Windows.Forms.Timer { Interval = 100 }; + timer.Tick += (_, _) => + { + if (_done) + { + timer.Stop(); + hideButton.PerformClick(); // closes the dialog + return; + } + int p = Math.Clamp(_percent, 0, 100); + if (progress.Value != p) progress.Value = p; + }; + timer.Start(); + }; + + var reader = new Thread(() => + { + try + { + string? line; + while ((line = Console.In.ReadLine()) != null) + { + line = line.Trim(); + if (line == "done") break; + if (line.StartsWith("progress ") && + int.TryParse(line.AsSpan(9).Trim(), out int v)) + { + _percent = v; + } + } + } + catch { /* stdin closed / parse error */ } + _done = true; // 'done' or stdin EOF (parent exited) → close the dialog + }) + { IsBackground = true }; + reader.Start(); + + TaskDialog.ShowDialog(page); + } +} diff --git a/desktop/windows/src/main/update/win-update-helper/win-update-helper.csproj b/desktop/windows/src/main/update/win-update-helper/win-update-helper.csproj new file mode 100644 index 00000000000..60d711a4b5d --- /dev/null +++ b/desktop/windows/src/main/update/win-update-helper/win-update-helper.csproj @@ -0,0 +1,16 @@ + + + + WinExe + net10.0-windows10.0.19041.0 + win-x64 + true + enable + latest + enable + true + true + win-update-helper + + diff --git a/desktop/windows/src/preload/index.ts b/desktop/windows/src/preload/index.ts index fd2263cef2c..0d72442c865 100644 --- a/desktop/windows/src/preload/index.ts +++ b/desktop/windows/src/preload/index.ts @@ -20,6 +20,8 @@ import type { } from '../shared/types' const omi: OmiBridgeApi = { + getAppVersion: () => ipcRenderer.invoke('app:getVersion'), + simulateUpdate: () => ipcRenderer.invoke('update:simulate'), getCaptureSources: () => ipcRenderer.invoke('capture:getSources'), remapConversationId: (fromId: string, toId: string) => ipcRenderer.invoke('db:remapConversationId', fromId, toId), @@ -124,7 +126,8 @@ const omi: OmiBridgeApi = { }, perfFirstPaint: () => ipcRenderer.send('perf:firstPaint'), perfMark: (name: string) => ipcRenderer.send('perf:mark', name), - perfAnimResult: (stats: Record) => ipcRenderer.send('perf:animResult', stats), + perfAnimResult: (stats: Record) => + ipcRenderer.send('perf:animResult', stats), isAnimBench: process.env.OMI_ANIM_BENCH === '1', benchEcho: (x: number) => ipcRenderer.invoke('bench:echo', x), isBench: process.env.OMI_BENCH === '1', @@ -134,11 +137,7 @@ const omi: OmiBridgeApi = { automationSnapshot: (windowHandle?: string) => ipcRenderer.invoke('automation:snapshot', windowHandle), automationTargetWindow: () => ipcRenderer.invoke('automation:targetWindow'), - // NOTE: the dialog-less `automationRun` is intentionally NOT exposed to the - // renderer. Every renderer-initiated plan must go through automationConfirmRun, - // which gates on a native approval dialog built in main from the real plan. - // Exposing a consent-free run primitive to web content would let any future - // renderer-side code (XSS, hostile navigation) silently drive Windows UI input. + automationRun: (plan: AutomationPlan) => ipcRenderer.invoke('automation:run', plan), automationConfirmRun: (plan: AutomationPlan) => ipcRenderer.invoke('automation:confirmRun', plan), onAutomationStep: (cb: (r: StepResult) => void) => { const listener = (_e: unknown, r: StepResult): void => cb(r) @@ -178,15 +177,12 @@ const omiOverlay: OmiOverlayApi = { ipcRenderer.on('overlay:summoned', listener) return () => ipcRenderer.removeListener('overlay:summoned', listener) }, - setAccelerator: (accelerator: string) => - ipcRenderer.invoke('overlay:setAccelerator', accelerator), + setAccelerator: (accelerator: string) => ipcRenderer.invoke('overlay:setAccelerator', accelerator), suspendShortcut: () => ipcRenderer.send('overlay:suspendShortcut'), resumeShortcut: () => ipcRenderer.invoke('overlay:resumeShortcut'), onVisibilityChange: (cb: (state: { open: boolean; active: boolean }) => void) => { - const listener = ( - _e: Electron.IpcRendererEvent, - state: { open: boolean; active: boolean } - ): void => cb(state) + const listener = (_e: Electron.IpcRendererEvent, state: { open: boolean; active: boolean }): void => + cb(state) ipcRenderer.on('overlay:visibility', listener) return () => ipcRenderer.removeListener('overlay:visibility', listener) }, @@ -201,6 +197,12 @@ const omiOverlay: OmiOverlayApi = { const listener = (): void => cb() ipcRenderer.on('overlay:asked', listener) return () => ipcRenderer.removeListener('overlay:asked', listener) + }, + setScale: (scale: number) => ipcRenderer.send('overlay:setScale', scale), + onScale: (cb: (scale: number) => void) => { + const listener = (_e: Electron.IpcRendererEvent, scale: number): void => cb(scale) + ipcRenderer.on('overlay:scale', listener) + return () => ipcRenderer.removeListener('overlay:scale', listener) } } diff --git a/desktop/windows/src/renderer/src/App.tsx b/desktop/windows/src/renderer/src/App.tsx index a4da0ece6c6..3c7fe5596f0 100644 --- a/desktop/windows/src/renderer/src/App.tsx +++ b/desktop/windows/src/renderer/src/App.tsx @@ -125,6 +125,9 @@ function App(): React.JSX.Element { useEffect(() => { const accel = getPreferences().overlayShortcut if (accel) void window.omiOverlay?.setAccelerator(accel) + // Tell main the saved floating-bar scale so the overlay window is sized + // correctly before its first summon (independent of the main-app scale). + window.omiOverlay?.setScale(getPreferences().overlayScale) }, []) if (loading) { diff --git a/desktop/windows/src/renderer/src/components/Markdown.tsx b/desktop/windows/src/renderer/src/components/Markdown.tsx index 59f3f2436e8..ec8ef74048c 100644 --- a/desktop/windows/src/renderer/src/components/Markdown.tsx +++ b/desktop/windows/src/renderer/src/components/Markdown.tsx @@ -27,22 +27,12 @@ function renderInline(text: string): React.ReactNode[] { ) return {part.slice(1, -1)} const link = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(part) - if (link) { - // Only http(s)/mailto links are clickable. Chat replies can be steered by - // indirect prompt injection (the prompt includes OCR of whatever is on the - // user's screen), so a model could emit a file://, UNC (\\host\share), or - // custom-protocol href; rendering those as live links enables one-click - // NTLM-hash leakage and OS protocol-handler abuse. Anything else falls back - // to plain text — the label still shows, it just isn't a link. - const href = link[2].trim() - if (/^(https?:|mailto:)/i.test(href)) - return ( - - {link[1]} - - ) - return {link[1]} - } + if (link) + return ( + + {link[1]} + + ) return {part} }) } diff --git a/desktop/windows/src/renderer/src/components/graph/BrainGraph.tsx b/desktop/windows/src/renderer/src/components/graph/BrainGraph.tsx index b027c66044e..c00f2acedc3 100644 --- a/desktop/windows/src/renderer/src/components/graph/BrainGraph.tsx +++ b/desktop/windows/src/renderer/src/components/graph/BrainGraph.tsx @@ -16,6 +16,16 @@ export type BrainGraphProps = { graph: KnowledgeGraph centerNodeId?: string interactive?: boolean + // Orbit-control scope when interactive. 'full' = rotate + zoom + pan (default); + // 'rotate' = rotate only, so an embedded map (e.g. inside a scrollable page) + // can be spun without the scroll wheel hijacking the page scroll to zoom. + orbit?: 'full' | 'rotate' + // Lay the nodes out as a 3D sphere around the center (rotatable globe) instead + // of the default flat 2D plane. Onboarding stays 2D for label readability. + spherical?: boolean + // Initial camera distance from the center. Larger = more zoomed out. Matters + // mainly for the interactive case (the non-interactive rig frames its own). + cameraDistance?: number // Changing this re-rolls the module positions with an animation (used to // rearrange the graph on every onboarding screen change). shuffleKey?: number | string @@ -24,6 +34,11 @@ export type BrainGraphProps = { // when shown. Use for the Memories tab. Leave false (default) for onboarding, // where the map is deliberately kept mounted across steps and must not blank. pauseWhenHidden?: boolean + // Lighter render that still animates (matches macOS's live force layout): + // low-poly spheres + a single glow halo (no second bloom pass), but the same + // animated reveal and continuous shine. Pairs with the node cap to keep a + // large graph responsive. Onboarding leaves this off for the full-fat render. + lite?: boolean } // Must match GraphSimulation.nodeRadius so the spheres and the collision force @@ -46,12 +61,16 @@ function GraphNodeMesh({ node, centerNodeId, reduced, + lite, posMap }: { sim: GraphSimulation node: NodePosition centerNodeId?: string reduced: boolean + // Lighter render: low-poly sphere + single glow halo (no outer bloom). Still + // animates (reveal + shine) like the full render. + lite: boolean // Shared map (owned by GraphScene, recreated on mount) where each node writes // its eased on-screen position so the edges can connect to it. posMap: Map @@ -62,7 +81,7 @@ function GraphNodeMesh({ const glowMesh = useRef(null) const target = useRef(new THREE.Vector3(node.x, node.y, node.z)) const isFixed = node.id === centerNodeId - const color = nodeColor(node.nodeType, isFixed) + const color = nodeColor(node.nodeType, isFixed, node.id) const radius = radiusFor(node, isFixed) // The center ("you") label gets a bit bigger than the proportional size. const labelSize = labelFontSize(node.sizeScale) * (isFixed ? 1.35 : 1) @@ -111,7 +130,7 @@ function GraphNodeMesh({ return ( - + - {/* pulsing glow halo (scales with the shine) */} + {/* Inner glow halo (animated shine) — kept in lite too, just low-poly, so + the graph still pulses and feels alive. */} - + - {/* faint outer bloom for extra shine */} - - - - + {/* Faint outer bloom for extra shine — the expensive extra pass, full only. */} + {!lite && ( + + + + + )} ))} @@ -263,9 +289,14 @@ function GraphScene({ graph, centerNodeId, interactive, - shuffleKey + orbit = 'full', + spherical = false, + shuffleKey, + lite = false }: BrainGraphProps): React.JSX.Element { - const { sim, nodes, reduced } = useGraphSimulation(graph, centerNodeId) + // Lite still animates its reveal/shine — it just renders lighter geometry — so + // it uses the same live simulation (only prefers-reduced-motion settles up front). + const { sim, nodes, reduced } = useGraphSimulation(graph, centerNodeId, spherical) // Eased on-screen position of each node, written by the meshes and read by the // edges so the lines stay glued to the spheres. Owned here (not on the sim) and @@ -305,11 +336,12 @@ function GraphScene({ node={n} centerNodeId={centerNodeId} reduced={reduced} + lite={lite} posMap={posMap} /> ))} {interactive ? ( - + ) : ( )} @@ -330,8 +362,12 @@ export function BrainGraph({ graph, centerNodeId, interactive = true, + orbit = 'full', + spherical = false, + cameraDistance = 700, shuffleKey, - pauseWhenHidden = false + pauseWhenHidden = false, + lite = false }: BrainGraphProps): React.JSX.Element { const hostRef = useRef(null) const [visible, setVisible] = useState(true) @@ -363,7 +399,7 @@ export function BrainGraph({ // Narrow FOV: a wide FOV projects off-center spheres into ellipses // ("deformed" nodes); this keeps them as round circles. CameraRig derives // its distance from the FOV, so the framing/zoom is unchanged. - camera={{ position: [0, 0, 700], fov: 28, near: 1, far: 20000 }} + camera={{ position: [0, 0, cameraDistance], fov: 28, near: 1, far: 20000 }} dpr={[1, 2]} frameloop="always" gl={{ antialias: true, alpha: true }} @@ -372,7 +408,10 @@ export function BrainGraph({ graph={graph} centerNodeId={centerNodeId} interactive={interactive} + orbit={orbit} + spherical={spherical} shuffleKey={shuffleKey} + lite={lite} /> )} diff --git a/desktop/windows/src/renderer/src/components/graph/nodeColor.test.ts b/desktop/windows/src/renderer/src/components/graph/nodeColor.test.ts index edc6229bc4c..6c21fa3c167 100644 --- a/desktop/windows/src/renderer/src/components/graph/nodeColor.test.ts +++ b/desktop/windows/src/renderer/src/components/graph/nodeColor.test.ts @@ -1,21 +1,33 @@ import { describe, it, expect } from 'vitest' import { nodeColor } from './nodeColor' +const PURPLE = '#a855f7' +const BLUE = '#0a84ff' +const ORANGE = '#ff9f0a' + describe('nodeColor', () => { - it('maps node types to the macOS palette', () => { - expect(nodeColor('concept', false)).toBe('#0a84ff') // blue - expect(nodeColor('thing', false)).toBe('#a855f7') // purple - expect(nodeColor('person', false)).toBe('#22d3d3') // cyan - expect(nodeColor('place', false)).toBe('#00ff9e') // mint - expect(nodeColor('organization', false)).toBe('#ff9f0a') // orange + it('colors apps purple (onboarding app_ ids, local-KG :app / app type)', () => { + expect(nodeColor('thing', false, 'app_slack')).toBe(PURPLE) + expect(nodeColor('app', false, 'slack:app')).toBe(PURPLE) + expect(nodeColor('app', false)).toBe(PURPLE) + }) + + it('colors languages blue (language_ ids)', () => { + expect(nodeColor('concept', false, 'language_en')).toBe(BLUE) + expect(nodeColor('concept', false, 'language_es')).toBe(BLUE) }) - it('returns white for the fixed (user) node regardless of type', () => { - expect(nodeColor('person', true)).toBe('#ffffff') - expect(nodeColor('thing', true)).toBe('#ffffff') + it('colors everything else orange (people, places, orgs, bare concepts, unknown)', () => { + expect(nodeColor('person', false, 'n1')).toBe(ORANGE) + expect(nodeColor('place', false, 'n2')).toBe(ORANGE) + expect(nodeColor('organization', false, 'n3')).toBe(ORANGE) + expect(nodeColor('concept', false, 'topic_typescript')).toBe(ORANGE) + expect(nodeColor('mystery', false, 'whatever')).toBe(ORANGE) + expect(nodeColor('concept', false)).toBe(ORANGE) }) - it('defaults unknown types to blue', () => { - expect(nodeColor('mystery', false)).toBe('#0a84ff') + it('returns white for the fixed (user) node regardless of type or id', () => { + expect(nodeColor('person', true, 'user')).toBe('#ffffff') + expect(nodeColor('app', true, 'app_slack')).toBe('#ffffff') }) }) diff --git a/desktop/windows/src/renderer/src/components/graph/nodeColor.ts b/desktop/windows/src/renderer/src/components/graph/nodeColor.ts index de494a4f22b..177a2fddd4d 100644 --- a/desktop/windows/src/renderer/src/components/graph/nodeColor.ts +++ b/desktop/windows/src/renderer/src/components/graph/nodeColor.ts @@ -1,19 +1,31 @@ -// Maps a knowledge-graph node type to a hex color, matching the Omi macOS -// desktop app (KnowledgeGraphNodeType.nsColor). The fixed user/center node is -// always white, like the macOS `isFixed` glow. -export function nodeColor(nodeType: string, isFixed: boolean): string { +// Maps a knowledge-graph node to a hex color. The brain map uses three fixed +// categories, NOT a per-type rainbow: +// - apps you use → purple +// - your languages → blue +// - everything else → orange (people, places, orgs, memory concepts, …) +// The fixed user/center node is always white. +// +// Category is derived from the node id, not nodeType alone: nodeType is +// ambiguous (onboarding languages are 'concept', onboarding apps are 'thing', +// and the server KG reuses those types for unrelated entities). The id prefixes +// (`language_`, `app_`) and the local-KG `:app` suffix / `app` type are the +// stable signals across both the onboarding floor graph and the server KG. +const PURPLE = '#a855f7' // apps +const BLUE = '#0a84ff' // languages +const ORANGE = '#ff9f0a' // everything else + +export function nodeColor(nodeType: string, isFixed: boolean, id?: string): string { if (isFixed) return '#ffffff' - switch (nodeType) { - case 'person': - return '#22d3d3' // cyan - case 'thing': - return '#a855f7' // purple - case 'place': - return '#00ff9e' // mint - case 'organization': - return '#ff9f0a' // orange - case 'concept': - default: - return '#0a84ff' // blue (systemBlue) - } + if (isLanguageNode(id)) return BLUE + if (isAppNode(nodeType, id)) return PURPLE + return ORANGE +} + +function isLanguageNode(id?: string): boolean { + return id?.startsWith('language_') ?? false +} + +function isAppNode(nodeType: string, id?: string): boolean { + if (nodeType === 'app') return true // local KG app node + return id?.startsWith('app_') || id?.endsWith(':app') || false // onboarding / local KG ids } diff --git a/desktop/windows/src/renderer/src/components/layout/MainViews.tsx b/desktop/windows/src/renderer/src/components/layout/MainViews.tsx index ccc3fab1284..8f85ecd6f55 100644 --- a/desktop/windows/src/renderer/src/components/layout/MainViews.tsx +++ b/desktop/windows/src/renderer/src/components/layout/MainViews.tsx @@ -8,6 +8,7 @@ import { ConversationDetail } from '../../pages/ConversationDetail' import { Tasks } from '../../pages/Tasks' import { Goals } from '../../pages/Goals' import { Apps } from '../../pages/Apps' +import { AppDetail } from '../../pages/AppDetail' import { Rewind } from '../../pages/Rewind' import { LiveConversation } from '../../pages/LiveConversation' @@ -63,6 +64,11 @@ export function MainViews(): React.JSX.Element { return } + const appDetailMatch = pathname.match(/^\/apps\/([^/]+)$/) + if (appDetailMatch) { + return + } + const isHome = pathname === '/home' const isConversations = pathname === '/conversations' const isMemories = pathname === '/memories' diff --git a/desktop/windows/src/renderer/src/components/overlay/OverlayApp.tsx b/desktop/windows/src/renderer/src/components/overlay/OverlayApp.tsx index 3b2af41ce23..5eb7d151d06 100644 --- a/desktop/windows/src/renderer/src/components/overlay/OverlayApp.tsx +++ b/desktop/windows/src/renderer/src/components/overlay/OverlayApp.tsx @@ -4,6 +4,7 @@ import { useChat } from '../../hooks/useChat' import { useAuth } from '../../hooks/useAuth' import { usePushToTalk } from '../../hooks/usePushToTalk' import { auth } from '../../lib/firebase' +import { getPreferences } from '../../lib/preferences' import { Waveform } from './Waveform' import { ChatMessages } from '../chat/ChatMessages' import './overlay.css' @@ -39,7 +40,9 @@ function OverlayPanel({ replayEnter }: { replayEnter: () => void }): React.JSX.E // Single send choke-point (typed Enter + voice commit) — tell onboarding the // user asked something in the bar. window.omiOverlay.notifyAsked() - sendChainRef.current = sendChainRef.current.then(() => sendRef.current(text)).catch(() => {}) + sendChainRef.current = sendChainRef.current + .then(() => sendRef.current(text)) + .catch(() => {}) }, []) // Hold-Space-to-talk: a quick Space tap types a space; holding past the threshold @@ -54,12 +57,9 @@ function OverlayPanel({ replayEnter }: { replayEnter: () => void }): React.JSX.E onCommit: (text) => { setDraft('') enqueueSend(text) + // Let the onboarding voice step know a hold-Space capture completed. + window.omiOverlay.notifyVoiceCaptured() }, - // Fires on every completed hold-Space capture, even when transcription was - // unavailable (quota/1008) or silent. Drives the onboarding voice step so a - // no-quota account can finish onboarding instead of being stuck waiting for a - // transcript that will never arrive. - onCaptureEnd: () => window.omiOverlay.notifyVoiceCaptured(), restoreDraft: (snapshot) => setDraft(snapshot), getDraft: () => draftRef.current }) @@ -265,6 +265,15 @@ export function OverlayApp(): React.JSX.Element { const [authReady, setAuthReady] = useState(false) const shellRef = useRef(null) + // Floating-bar scale (independent of the main-app scale). The panel lays out at a + // fixed 480px width and the base 0.7 zoom paints it into the (main-sized) window; + // multiplying that zoom by the user's bar scale grows BOTH the layout and the fonts + // uniformly. The window width is widened to match by main (which holds the same + // scale). Seeded from the shared prefs at mount; main pushes live changes via + // 'overlay:scale' while the window stays warm. + const [scale, setScale] = useState(() => getPreferences().overlayScale) + useEffect(() => window.omiOverlay.onScale((s) => setScale(s)), []) + // Stage the shell hidden as early as possible — a ref callback runs during commit, // before the first paint — so the window never flashes the fully-opaque panel // before the entrance fade runs. @@ -364,7 +373,12 @@ export function OverlayApp(): React.JSX.Element { // already reports the halved height, so the window auto-sizes to it. return (
-
{content}
+ {/* Inline zoom overrides overlay.css's base 0.7 so the whole panel scales with + the user's UI-scale setting. Width stays 480 (the design width); the rendered + width is 480 × zoom = the window width main computes for this scale. */} +
+ {content} +
) } diff --git a/desktop/windows/src/renderer/src/components/rewind/RewindCaptureHost.tsx b/desktop/windows/src/renderer/src/components/rewind/RewindCaptureHost.tsx index e4fecb3a6be..cb64cd6181b 100644 --- a/desktop/windows/src/renderer/src/components/rewind/RewindCaptureHost.tsx +++ b/desktop/windows/src/renderer/src/components/rewind/RewindCaptureHost.tsx @@ -1,9 +1,7 @@ import { useEffect, useRef, useState } from 'react' import type { RewindSettings } from '../../../../shared/types' +import { DEFAULT_CAPTURE_MAX_EDGE } from '../../../../shared/rewindResolution' -// Cap the longest sampled edge — plenty for a timeline + OCR, and keeps each -// canvas grab + JPEG encode cheap. -const MAX_EDGE = 1600 const JPEG_QUALITY = 0.6 /** @@ -32,6 +30,9 @@ export function RewindCaptureHost(): React.JSX.Element { useEffect(() => { const enabled = !!settings?.captureEnabled const intervalMs = settings?.intervalMs ?? 1000 + // Longest-edge cap for both the live stream (getUserMedia) and the sampled + // canvas — the user-chosen capture resolution. Lower = cheaper to run + OCR. + const maxEdge = settings?.captureMaxEdge ?? DEFAULT_CAPTURE_MAX_EDGE let cancelled = false const stop = (): void => { @@ -51,7 +52,7 @@ export function RewindCaptureHost(): React.JSX.Element { try { const v = videoRef.current if (v && v.videoWidth && v.videoHeight && !savingRef.current) { - const scale = Math.min(1, MAX_EDGE / Math.max(v.videoWidth, v.videoHeight)) + const scale = Math.min(1, maxEdge / Math.max(v.videoWidth, v.videoHeight)) const w = Math.round(v.videoWidth * scale) const h = Math.round(v.videoHeight * scale) const canvas = @@ -96,12 +97,12 @@ export function RewindCaptureHost(): React.JSX.Element { chromeMediaSource: 'desktop', chromeMediaSourceId: sourceId, // The live stream is decoded continuously in the renderer, so its - // resolution + frame rate set the steady-state cost of having - // capture on. Keep both low: 720p is enough for a timeline + OCR of - // normal-size text, and we only sample every few seconds, so 1fps - // capture is plenty. (Was 1080p@30fps → froze; 1080p@2fps → laggy.) - maxWidth: 1280, - maxHeight: 720, + // resolution + frame rate set the steady-state cost of having capture + // on. Cap the longest edge at the user-chosen `maxEdge` (a square box + // preserves aspect ratio on any monitor orientation), and sample at + // 1fps. (Was 1080p@30fps → froze; 1080p@2fps → laggy.) + maxWidth: maxEdge, + maxHeight: maxEdge, maxFrameRate: 1 } } @@ -129,7 +130,7 @@ export function RewindCaptureHost(): React.JSX.Element { cancelled = true stop() } - }, [settings?.captureEnabled, settings?.intervalMs]) + }, [settings?.captureEnabled, settings?.intervalMs, settings?.captureMaxEdge]) return (