From f37ef9d9eca1b4222bbc26f90c61d2440333918c Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 16:56:41 -0600 Subject: [PATCH 01/43] feat(desktop): tray workspace actions, startup settings, and notification levels --- .github/workflows/desktop-ci.yml | 31 + desktop/package-lock.json | 1159 +++++++++++++++-- desktop/package.json | 4 + desktop/scripts/tray-smoke-xfce.sh | 112 ++ .../src/main/__tests__/app-settings.test.ts | 186 +++ .../src/main/__tests__/launch-context.test.ts | 96 ++ .../main/__tests__/settings-service.test.ts | 108 ++ .../main/__tests__/tray-notifications.test.ts | 194 +++ .../src/main/__tests__/tray-support.test.ts | 36 + desktop/src/main/__tests__/tray.test.ts | 345 ++++- desktop/src/main/app-settings.ts | 95 ++ desktop/src/main/autostart.ts | 199 +++ desktop/src/main/index.ts | 87 +- desktop/src/main/ipc.ts | 66 +- desktop/src/main/launch-context.ts | 35 + desktop/src/main/portal-background.ts | 80 ++ desktop/src/main/settings-service.ts | 68 + desktop/src/main/tray-notifications.ts | 168 +++ desktop/src/main/tray-support.ts | 50 + desktop/src/main/tray.ts | 322 +++-- desktop/src/renderer/src/App.svelte | 8 + desktop/src/renderer/src/lib/ipc/commands.ts | 18 + desktop/src/renderer/src/lib/ipc/events.ts | 12 + desktop/src/renderer/src/lib/ipc/mock.ts | 45 + .../src/renderer/src/lib/stores/settings.ts | 63 +- .../renderer/src/pages/SettingsPage.svelte | 76 ++ .../renderer/src/pages/SettingsPage.test.ts | 25 +- .../src/pages/WorkspaceDetailPage.svelte | 4 + desktop/src/shared/app-settings.ts | 19 + 29 files changed, 3408 insertions(+), 303 deletions(-) create mode 100644 desktop/scripts/tray-smoke-xfce.sh create mode 100644 desktop/src/main/__tests__/app-settings.test.ts create mode 100644 desktop/src/main/__tests__/launch-context.test.ts create mode 100644 desktop/src/main/__tests__/settings-service.test.ts create mode 100644 desktop/src/main/__tests__/tray-notifications.test.ts create mode 100644 desktop/src/main/__tests__/tray-support.test.ts create mode 100644 desktop/src/main/app-settings.ts create mode 100644 desktop/src/main/autostart.ts create mode 100644 desktop/src/main/launch-context.ts create mode 100644 desktop/src/main/portal-background.ts create mode 100644 desktop/src/main/settings-service.ts create mode 100644 desktop/src/main/tray-notifications.ts create mode 100644 desktop/src/main/tray-support.ts create mode 100644 desktop/src/shared/app-settings.ts diff --git a/.github/workflows/desktop-ci.yml b/.github/workflows/desktop-ci.yml index 3fe0bc07c..2f0b86ff8 100644 --- a/.github/workflows/desktop-ci.yml +++ b/.github/workflows/desktop-ci.yml @@ -175,6 +175,37 @@ jobs: desktop/release/*.rpm if-no-files-found: warn + tray-smoke-xfce: + needs: [changes, lint-and-test] + if: needs.changes.outputs.desktop == 'true' + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 24 + cache: npm + cache-dependency-path: desktop/package-lock.json + + - name: Install Xfce panel and Xvfb + run: | + sudo apt-get update + sudo apt-get install -y xvfb xfce4-panel dbus-x11 x11-utils + + - name: Install dependencies + working-directory: desktop + run: npm ci + + - name: Build desktop app + working-directory: desktop + run: npm run electron:build + + - name: Assert tray icon registers with the Xfce panel + working-directory: desktop + run: bash scripts/tray-smoke-xfce.sh + build-flatpak: needs: [changes, build-desktop] if: needs.changes.outputs.desktop == 'true' diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 445e126d2..f558af515 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -10,6 +10,7 @@ "hasInstallScript": true, "dependencies": { "chokidar": "^5.0.0", + "dbus-next": "^0.10.2", "dompurify": "^3.4.7", "electron-updater": "^6.8.3", "node-pty": "^1.0.0", @@ -1493,9 +1494,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1505,6 +1503,15 @@ "node": "^22.20 || ^24.12 || >=25" } }, + "node_modules/@nornagon/put": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/@nornagon/put/-/put-0.0.8.tgz", + "integrity": "sha512-ugvXJjwF5ldtUpa7D95kruNJ41yFQDEKyF5CW4TgKJnh+W/zmlBzXXeKTyqIgwMFrkePN2JqOBqcF0M0oOunow==", + "license": "MIT/X11", + "engines": { + "node": ">=0.3.0" + } + }, "node_modules/@peculiar/asn1-schema": { "version": "2.9.4", "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.9.4.tgz", @@ -1683,9 +1690,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1700,9 +1704,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1717,9 +1718,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1734,9 +1732,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1751,9 +1746,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1768,9 +1760,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1785,9 +1774,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1802,9 +1788,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1819,9 +1802,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1836,9 +1816,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1853,9 +1830,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1870,9 +1844,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1887,9 +1858,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2214,9 +2182,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2234,9 +2199,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2254,9 +2216,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2274,9 +2233,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2853,7 +2809,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -3077,6 +3033,25 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/aproba": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", + "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==", + "license": "ISC", + "optional": true + }, + "node_modules/are-we-there-yet": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.7.tgz", + "integrity": "sha512-nxwy40TuMiUGqMyRHgCSWZ9FM4VAoRP4xUYSTv5ImRog+h9yISPbVH7H8fASCIzYn9wlEv4zvFL7uKDMCFQm3g==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^2.0.6" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -3093,6 +3068,16 @@ "dequal": "^2.0.3" } }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, "node_modules/asn1js": { "version": "3.0.10", "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", @@ -3108,6 +3093,16 @@ "node": ">=12.0.0" } }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.8" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -3139,7 +3134,7 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/at-least-node": { @@ -3152,11 +3147,21 @@ "node": ">= 4.0.0" } }, + "node_modules/aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": "*" + } + }, "node_modules/aws4": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/axobject-query": { @@ -3212,6 +3217,16 @@ "node": ">=6.0.0" } }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, "node_modules/bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", @@ -3222,6 +3237,16 @@ "require-from-string": "^2.0.2" } }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, "node_modules/bits-ui": { "version": "2.19.0", "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.19.0.tgz", @@ -3409,7 +3434,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -3440,6 +3465,13 @@ ], "license": "CC-BY-4.0" }, + "node_modules/caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", + "license": "Apache-2.0", + "optional": true + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -3552,6 +3584,16 @@ "node": ">=6" } }, + "node_modules/code-point-at": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", + "integrity": "sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -3576,7 +3618,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -3609,9 +3651,16 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC", + "optional": true + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -3623,7 +3672,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/cross-dirname": { @@ -3671,6 +3720,19 @@ "dev": true, "license": "MIT" }, + "node_modules/dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -3700,6 +3762,24 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/dbus-next": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/dbus-next/-/dbus-next-0.10.2.tgz", + "integrity": "sha512-kLNQoadPstLgKKGIXKrnRsMgtAK/o+ix3ZmcfTfvBHzghiO9yHXpoKImGnB50EXwnfSFaSAullW/7UrSkAISSQ==", + "license": "MIT", + "dependencies": { + "@nornagon/put": "0.0.8", + "event-stream": "3.3.4", + "hexy": "^0.2.10", + "jsbi": "^2.0.5", + "long": "^4.0.0", + "safe-buffer": "^5.1.1", + "xml2js": "^0.4.17" + }, + "optionalDependencies": { + "usocket": "^0.3.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3815,12 +3895,19 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.4.0" } }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT", + "optional": true + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -3959,7 +4046,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -3970,6 +4057,12 @@ "node": ">= 0.4" } }, + "node_modules/duplexer": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", + "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", + "license": "MIT" + }, "node_modules/duplexer2": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", @@ -3980,6 +4073,17 @@ "readable-stream": "^2.0.2" } }, + "node_modules/ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", + "license": "MIT", + "optional": true, + "dependencies": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -4201,7 +4305,7 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/end-of-stream": { @@ -4265,7 +4369,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4275,7 +4379,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4292,7 +4396,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -4305,7 +4409,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -4424,6 +4528,21 @@ "@types/estree": "^1.0.0" } }, + "node_modules/event-stream": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/event-stream/-/event-stream-3.3.4.tgz", + "integrity": "sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g==", + "license": "MIT", + "dependencies": { + "duplexer": "~0.1.1", + "from": "~0", + "map-stream": "~0.1.0", + "pause-stream": "0.0.11", + "split": "0.3", + "stream-combiner": "~0.0.4", + "through": "~2.3.1" + } + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -4441,13 +4560,37 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT", + "optional": true + }, + "node_modules/extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "optional": true + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT", + "optional": true + }, "node_modules/fast-uri": { "version": "3.1.7", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", @@ -4483,6 +4626,13 @@ } } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT", + "optional": true + }, "node_modules/filelist": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", @@ -4523,6 +4673,16 @@ "node": ">=10" } }, + "node_modules/forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": "*" + } + }, "node_modules/form-data": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", @@ -4540,6 +4700,12 @@ "node": ">= 6" } }, + "node_modules/from": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/from/-/from-0.1.7.tgz", + "integrity": "sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==", + "license": "MIT" + }, "node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", @@ -4554,11 +4720,44 @@ "node": ">=12" } }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs-minipass/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC", + "optional": true + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/fsevents": { @@ -4580,12 +4779,81 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, + "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gauge": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz", + "integrity": "sha512-14x4kjc6lkD3ltw589k0NrPD6cCNTD6CWoVUNpB85+DrtONoZn+Rug6xZU5RvSC4+TZPxA5AnBibQYAvZn41Hg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "aproba": "^1.0.3", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.0", + "object-assign": "^4.1.0", + "signal-exit": "^3.0.0", + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1", + "wide-align": "^1.1.0" + } + }, + "node_modules/gauge/node_modules/ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gauge/node_modules/is-fullwidth-code-point": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw==", + "license": "MIT", + "optional": true, + "dependencies": { + "number-is-nan": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gauge/node_modules/string-width": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha512-0XsVpQLnVCXHJfyEs8tC0zpTVIr5PKKsQtkT29IwupnPTjtPmQ3xT/4yCREF9hYkV/3M3kzcUTSAZT6a6h81tw==", + "license": "MIT", + "optional": true, + "dependencies": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gauge/node_modules/strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -4610,7 +4878,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -4635,7 +4903,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -4661,12 +4929,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0" + } + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -4687,14 +4965,14 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/glob/node_modules/brace-expansion": { "version": "1.1.18", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -4705,7 +4983,7 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -4755,7 +5033,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4796,6 +5074,55 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/har-schema": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", + "integrity": "sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/har-validator": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz", + "integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==", + "deprecated": "this library is no longer supported", + "license": "MIT", + "optional": true, + "dependencies": { + "ajv": "^6.12.3", + "har-schema": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/har-validator/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "license": "MIT", + "optional": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/har-validator/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "license": "MIT", + "optional": true + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -4806,6 +5133,14 @@ "node": ">=8" } }, + "node_modules/has-own": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-own/-/has-own-1.0.1.tgz", + "integrity": "sha512-RDKhzgQTQfMaLvIFhjahU+2gGnRBK6dYOd5Gd9BzkmnBneOCRYjRC003RIMrdAbH52+l+CnMS4bBCXGer8tEhg==", + "deprecated": "This project is not maintained. Use Object.hasOwn() instead.", + "license": "MIT", + "optional": true + }, "node_modules/has-property-descriptors": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", @@ -4824,7 +5159,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4837,7 +5172,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -4849,11 +5184,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC", + "optional": true + }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -4862,6 +5204,15 @@ "node": ">= 0.4" } }, + "node_modules/hexy": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/hexy/-/hexy-0.2.11.tgz", + "integrity": "sha512-ciq6hFsSG/Bpt2DmrZJtv+56zpPdnq+NQ4ijEFrveKN0ZG1mhl/LdT1NQZ9se6ty1fACcI4d4vYqC9v8EYpH2A==", + "license": "MIT", + "bin": { + "hexy": "bin/hexy_cmd.js" + } + }, "node_modules/hosted-git-info": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", @@ -4929,6 +5280,22 @@ "node": ">= 14" } }, + "node_modules/http-signature": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", + "integrity": "sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + }, + "engines": { + "node": ">=0.8", + "npm": ">=1.3.7" + } + }, "node_modules/http2-wrapper": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", @@ -4972,7 +5339,7 @@ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "once": "^1.3.0", @@ -4983,7 +5350,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/inline-style-parser": { @@ -4997,7 +5364,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -5019,11 +5386,18 @@ "@types/estree": "^1.0.6" } }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "license": "MIT", + "optional": true + }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/isbinaryfile": { @@ -5043,9 +5417,16 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, + "devOptional": true, "license": "ISC" }, + "node_modules/isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", + "license": "MIT", + "optional": true + }, "node_modules/jake": { "version": "10.9.4", "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", @@ -5103,6 +5484,18 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsbi": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/jsbi/-/jsbi-2.0.5.tgz", + "integrity": "sha512-TzO/62Hxeb26QMb4IGlI/5X+QLr9Uqp1FPkwp2+KOICW+Q+vSuFj61c8pkT6wAns4WcK56X7CmSHhJeDGWOqxQ==" + }, + "node_modules/jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", + "license": "MIT", + "optional": true + }, "node_modules/jsdom": { "version": "30.0.1", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", @@ -5184,6 +5577,13 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)", + "optional": true + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -5195,7 +5595,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "dev": true, "license": "ISC", "optional": true }, @@ -5224,6 +5623,22 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsprim": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", + "integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==", + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.4.0", + "verror": "1.10.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5383,9 +5798,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5407,9 +5819,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5431,9 +5840,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5455,9 +5861,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5539,6 +5942,12 @@ "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", "license": "MIT" }, + "node_modules/long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", + "license": "Apache-2.0" + }, "node_modules/lowercase-keys": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", @@ -5578,6 +5987,11 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/map-stream": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz", + "integrity": "sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g==" + }, "node_modules/matcher": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", @@ -5596,7 +6010,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5626,7 +6040,7 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -5636,7 +6050,7 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -5744,6 +6158,13 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/nan": { + "version": "2.29.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.29.0.tgz", + "integrity": "sha512-GlGk3HIvitbvs+LT3g6XUP1kpirKNvmDFwF/bmo6XNWSb/eYEs/O4bfgIEIXCZ+lIOTS5xNwDvSGMw6FJdAhtA==", + "license": "MIT", + "optional": true + }, "node_modules/nanoid": { "version": "3.3.16", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", @@ -5919,6 +6340,50 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/npmlog": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz", + "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "are-we-there-yet": "~1.1.2", + "console-control-strings": "~1.1.0", + "gauge": "~2.7.3", + "set-blocking": "~2.0.0" + } + }, + "node_modules/number-is-nan": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", + "integrity": "sha512-4jbtZXNAsfZbAHiiqjLPBiCl16dES1zI4Hpzzxw61Tk+loF+sBDBKx1ICKKKwIqQ7M0mFn1TmkN7euSncWgHiQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/oauth-sign": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", + "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": "*" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", @@ -5948,7 +6413,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -6065,7 +6530,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6088,6 +6553,18 @@ "dev": true, "license": "MIT" }, + "node_modules/pause-stream": { + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz", + "integrity": "sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==", + "license": [ + "MIT", + "Apache2" + ], + "dependencies": { + "through": "~2.3" + } + }, "node_modules/pe-library": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz", @@ -6103,6 +6580,13 @@ "url": "https://github.com/sponsors/jet2jet" } }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -6322,7 +6806,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/progress": { @@ -6361,6 +6845,19 @@ "signal-exit": "^3.0.2" } }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "optional": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -6376,7 +6873,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -6402,6 +6899,16 @@ "node": ">=16.0.0" } }, + "node_modules/qs": { + "version": "6.5.5", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.5.tgz", + "integrity": "sha512-mzR4sElr1bfCaPJe7m8ilJ6ZXdDaGoObcYR0ZHSsktM/Lt21MVHj5De30GQH2eiZ1qGRTO7LCAzQsUeXTNexWQ==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.6" + } + }, "node_modules/quick-lru": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", @@ -6439,7 +6946,7 @@ "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "core-util-is": "~1.0.0", @@ -6487,6 +6994,93 @@ "node": ">=8" } }, + "node_modules/request": { + "version": "2.88.2", + "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", + "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", + "deprecated": "request has been deprecated, see https://github.com/request/request/issues/3142", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~2.3.2", + "har-validator": "~5.1.3", + "http-signature": "~1.2.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "oauth-sign": "~0.9.0", + "performance-now": "^2.1.0", + "qs": "~6.5.2", + "safe-buffer": "^5.1.2", + "tough-cookie": "~2.5.0", + "tunnel-agent": "^0.6.0", + "uuid": "^3.3.2" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/request/node_modules/form-data": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.4.tgz", + "integrity": "sha512-Y/3MmRiR8Nd+0CUtrbvcKtKzLWiUfpQ7DFVggH8PwmGt/0r7RSy32GuP4hpCJlQNEBusisSx1DLtD8uD386HJQ==", + "deprecated": "This version has an incorrect dependency; please use v2.5.5", + "license": "MIT", + "optional": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "has-own": "^1.0.1", + "mime-types": "^2.1.35", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/request/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/request/node_modules/tough-cookie": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", + "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "psl": "^1.1.28", + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -6677,9 +7271,15 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, "license": "MIT" }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT", + "optional": true + }, "node_modules/sanitize-filename": { "version": "1.6.4", "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.4.tgz", @@ -6749,6 +7349,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC", + "optional": true + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -6783,7 +7390,7 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/simple-update-notifier": { @@ -6830,6 +7437,18 @@ "source-map": "^0.6.0" } }, + "node_modules/split": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/split/-/split-0.3.3.tgz", + "integrity": "sha512-wD2AeVmxXRBoX44wAycgjVpMhvbwdI2aZjCkvfNcH1YqHQvJVa1duWc73OyVGJUc05fhFaTZeQ/PYsrmyH0JVA==", + "license": "MIT", + "dependencies": { + "through": "2" + }, + "engines": { + "node": "*" + } + }, "node_modules/sprintf-js": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", @@ -6838,6 +7457,32 @@ "license": "BSD-3-Clause", "optional": true }, + "node_modules/sshpk": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", + "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + }, + "bin": { + "sshpk-conv": "bin/sshpk-conv", + "sshpk-sign": "bin/sshpk-sign", + "sshpk-verify": "bin/sshpk-verify" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -6862,11 +7507,20 @@ "dev": true, "license": "MIT" }, + "node_modules/stream-combiner": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.0.4.tgz", + "integrity": "sha512-rT00SPnTVyRsaSz5zgSPma/aHSOic5U1prhYdRy5HS2kTZviFpmDgzilbtsJsxiroqACmayynDN/9VzIbX5DOw==", + "license": "MIT", + "dependencies": { + "duplexer": "~0.1.1" + } + }, "node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.1.0" @@ -6876,7 +7530,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -6891,7 +7545,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -7227,6 +7881,12 @@ "fs-extra": "^10.0.0" } }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "license": "MIT" + }, "node_modules/tiny-async-pool": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", @@ -7380,6 +8040,19 @@ "dev": true, "license": "0BSD" }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/tw-animate-css": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", @@ -7390,6 +8063,13 @@ "url": "https://github.com/sponsors/Wombosvideo" } }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense", + "optional": true + }, "node_modules/type-fest": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", @@ -7514,6 +8194,190 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "license": "BSD-2-Clause", + "optional": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/usocket": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/usocket/-/usocket-0.3.0.tgz", + "integrity": "sha512-V/H02RNiaOCJZuPoKont/y12VJaImC6C5xW7OzPFjYu9qnig0yv9hyp9E7Wqjm6d8yZuZouH3NAfDATVMgh2SQ==", + "hasInstallScript": true, + "license": "ISC", + "optional": true, + "dependencies": { + "bindings": "^1.5.0", + "nan": "^2.14.2", + "node-gyp": "^7.1.2" + } + }, + "node_modules/usocket/node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC", + "optional": true + }, + "node_modules/usocket/node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/usocket/node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/usocket/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/usocket/node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "optional": true, + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/usocket/node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/usocket/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "optional": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/usocket/node_modules/node-gyp": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-7.1.2.tgz", + "integrity": "sha512-CbpcIo7C3eMu3dL1c3d0xw449fHIGALIJsRP4DDPHpyiW8vcriNY7ubh9TE4zEKfSxscY7PjeFnshE7h75ynjQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "env-paths": "^2.2.0", + "glob": "^7.1.4", + "graceful-fs": "^4.2.3", + "nopt": "^5.0.0", + "npmlog": "^4.1.2", + "request": "^2.88.2", + "rimraf": "^3.0.2", + "semver": "^7.3.2", + "tar": "^6.0.2", + "which": "^2.0.2" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": ">= 10.12.0" + } + }, + "node_modules/usocket/node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/usocket/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "optional": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/usocket/node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "optional": true, + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/usocket/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC", + "optional": true + }, "node_modules/utf8-byte-length": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", @@ -7525,9 +8389,20 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "optional": true, + "bin": { + "uuid": "bin/uuid" + } + }, "node_modules/vaul-svelte": { "version": "1.0.0-next.7", "resolved": "https://registry.npmjs.org/vaul-svelte/-/vaul-svelte-1.0.0-next.7.tgz", @@ -7583,6 +8458,28 @@ "svelte": "^5.0.0" } }, + "node_modules/verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "node_modules/verror/node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "license": "MIT", + "optional": true + }, "node_modules/vite": { "version": "7.3.6", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", @@ -8333,7 +9230,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -8362,6 +9259,16 @@ "node": ">=8" } }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -8384,7 +9291,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/xml-name-validator": { @@ -8397,6 +9304,28 @@ "node": ">=18" } }, + "node_modules/xml2js": { + "version": "0.4.23", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz", + "integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xml2js/node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/xmlbuilder": { "version": "15.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", diff --git a/desktop/package.json b/desktop/package.json index 901623021..a39b35a9b 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -22,6 +22,7 @@ }, "dependencies": { "chokidar": "^5.0.0", + "dbus-next": "^0.10.2", "dompurify": "^3.4.7", "electron-updater": "^6.8.3", "node-pty": "^1.0.0", @@ -61,5 +62,8 @@ "vaul-svelte": "1.0.0-next.7", "vite": "7.3.6", "vitest": "4.1.11" + }, + "overrides": { + "form-data@>=2.0.0 <3.0.0": "2.5.4" } } diff --git a/desktop/scripts/tray-smoke-xfce.sh b/desktop/scripts/tray-smoke-xfce.sh new file mode 100644 index 000000000..1c58e6588 --- /dev/null +++ b/desktop/scripts/tray-smoke-xfce.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# Tier 2 Linux smoke check: launch the built desktop app under a minimal +# Xfce session and assert the tray icon registers as a StatusNotifierItem. +set -euo pipefail + +export DISPLAY="${DISPLAY:-:99}" + +Xvfb "$DISPLAY" -screen 0 1280x800x24 & +XVFB_PID=$! +trap 'kill "$XVFB_PID" 2>/dev/null || true' EXIT + +for _ in $(seq 1 50); do + if xdpyinfo -display "$DISPLAY" >/dev/null 2>&1; then + break + fi + sleep 0.2 +done + +printf '#!/bin/sh\necho "[]"\n' >/tmp/devsy-cli-stub +chmod +x /tmp/devsy-cli-stub + +dbus-run-session -- bash <<'INNER' +set -euo pipefail + +ls /usr/lib/*/xfce4/panel/plugins/ 2>/dev/null | grep -i status \ + || echo "no statustray plugin file found" + +# Give the panel a deterministic layout with the Status Tray plugin so it +# claims org.kde.StatusNotifierWatcher. The default layout may omit it. +export XDG_CONFIG_HOME=/tmp/xfce-config +mkdir -p "$XDG_CONFIG_HOME/xfce4/xfconf/xfce-perchannel-xml" +cat > "$XDG_CONFIG_HOME/xfce4/xfconf/xfce-perchannel-xml/xfce4-panel.xml" <<'XML' + + + + + + + + + + + + + + + + + + + +XML + +xfce4-panel & +PANEL_PID=$! + +WATCHER="" +for i in $(seq 1 30); do + if busctl --user list 2>/dev/null | grep -q 'org\.kde\.StatusNotifierWatcher'; then + WATCHER=1 + echo "panel claimed org.kde.StatusNotifierWatcher after ${i}s" + break + fi + sleep 1 +done +if [ -z "$WATCHER" ]; then + echo "WARN: panel did not claim org.kde.StatusNotifierWatcher within 30s" +fi + +DEVSY_DISABLE_DAEMON=true DEVSY_CLI_PATH=/tmp/devsy-cli-stub npx electron . --no-sandbox & +APP_PID=$! +trap 'kill "$APP_PID" "$PANEL_PID" 2>/dev/null || true' EXIT + +APP_OK="" +WATCH_OK="" +for i in $(seq 1 90); do + NAMES=$(busctl --user list 2>/dev/null || true) + if [ -z "$APP_OK" ] && printf '%s\n' "$NAMES" | grep -q 'StatusNotifierItem-'; then + APP_OK=1 + echo "app owns a StatusNotifierItem name after ${i}s" + fi + if [ -n "$WATCHER" ]; then + ITEMS=$(busctl --user get-property org.kde.StatusNotifierWatcher \ + /StatusNotifierWatcher org.kde.StatusNotifierWatcher \ + RegisteredStatusNotifierItems 2>/dev/null || true) + COUNT=$(printf '%s' "$ITEMS" | awk '{print $2}') + if [ -n "$COUNT" ] && [ "$COUNT" != "0" ]; then + WATCH_OK=1 + echo "tray icon registered with the Xfce panel: $ITEMS" + break + fi + fi + if [ $((i % 15)) -eq 0 ]; then + echo "poll ${i}s: app_sni=${APP_OK:-no} watcher=${WATCHER:-no} registered=${WATCH_OK:-no}" + fi + sleep 1 +done + +if [ -n "$WATCHER" ] && [ -z "$WATCH_OK" ]; then + echo "FAIL: a StatusNotifierWatcher exists but the tray item never registered with it" >&2 + exit 1 +fi +if [ -z "$APP_OK" ]; then + echo "FAIL: the app never owned a StatusNotifierItem name" >&2 + exit 1 +fi +if [ -z "$WATCHER" ]; then + echo "NOTE: xfce4-panel did not claim org.kde.StatusNotifierWatcher;" + echo "passing on app-side StatusNotifierItem registration alone." +fi +echo "tray smoke check passed" +INNER diff --git a/desktop/src/main/__tests__/app-settings.test.ts b/desktop/src/main/__tests__/app-settings.test.ts new file mode 100644 index 000000000..aaa0e6779 --- /dev/null +++ b/desktop/src/main/__tests__/app-settings.test.ts @@ -0,0 +1,186 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { afterEach, beforeEach, describe, expect, it } from "vitest" +import { + AppSettingsStore, + DEFAULT_APP_SETTINGS, + normalizeAppSettings, + patchAppSettings, + sanitizeAppSettingsPatch, +} from "../app-settings.js" + +describe("normalizeAppSettings", () => { + it("returns defaults for missing or malformed input", () => { + expect(normalizeAppSettings(undefined)).toEqual(DEFAULT_APP_SETTINGS) + expect(normalizeAppSettings(null)).toEqual(DEFAULT_APP_SETTINGS) + expect(normalizeAppSettings("nope")).toEqual(DEFAULT_APP_SETTINGS) + expect(normalizeAppSettings({})).toEqual(DEFAULT_APP_SETTINGS) + }) + + it("keeps valid persisted values", () => { + expect( + normalizeAppSettings({ + runAtStartup: true, + openToTrayOnStartup: true, + trayNotifications: "all", + }), + ).toEqual({ + runAtStartup: true, + openToTrayOnStartup: true, + trayNotifications: "all", + }) + }) + + it("forces open-to-tray off when run-at-startup is off", () => { + expect( + normalizeAppSettings({ runAtStartup: false, openToTrayOnStartup: true }), + ).toEqual({ + runAtStartup: false, + openToTrayOnStartup: false, + trayNotifications: "failures", + }) + }) + + it("falls back to the default notification level for unknown values", () => { + expect( + normalizeAppSettings({ trayNotifications: "loud" }).trayNotifications, + ).toBe("failures") + }) + + it("ignores non-boolean toggles", () => { + expect( + normalizeAppSettings({ runAtStartup: "yes" }).runAtStartup, + ).toBe(false) + }) +}) + +describe("patchAppSettings", () => { + it("clears the dependent toggle when run-at-startup is disabled", () => { + const current = { + runAtStartup: true, + openToTrayOnStartup: true, + trayNotifications: "all" as const, + } + expect(patchAppSettings(current, { runAtStartup: false })).toEqual({ + runAtStartup: false, + openToTrayOnStartup: false, + trayNotifications: "all", + }) + }) + + it("leaves unrelated settings untouched", () => { + const current = { + runAtStartup: true, + openToTrayOnStartup: false, + trayNotifications: "off" as const, + } + expect( + patchAppSettings(current, { openToTrayOnStartup: true }), + ).toEqual({ + runAtStartup: true, + openToTrayOnStartup: true, + trayNotifications: "off", + }) + }) +}) + +describe("sanitizeAppSettingsPatch", () => { + it("accepts a valid partial patch", () => { + expect( + sanitizeAppSettingsPatch({ runAtStartup: true, trayNotifications: "all" }), + ).toEqual({ runAtStartup: true, trayNotifications: "all" }) + }) + + it("returns an empty patch for non-object input", () => { + expect(sanitizeAppSettingsPatch(undefined)).toEqual({}) + expect(sanitizeAppSettingsPatch("x")).toEqual({}) + }) + + it("rejects mistyped values", () => { + expect(() => sanitizeAppSettingsPatch({ runAtStartup: 1 })).toThrow() + expect(() => + sanitizeAppSettingsPatch({ trayNotifications: "everything" }), + ).toThrow() + }) +}) + +describe("AppSettingsStore", () => { + let dir: string + let file: string + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "app-settings-")) + file = join(dir, "app-settings.json") + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + it("loads defaults when the file does not exist", () => { + const store = new AppSettingsStore(file) + expect(store.load()).toEqual(DEFAULT_APP_SETTINGS) + }) + + it("survives a corrupt file", () => { + writeFileSync(file, "{not json") + const store = new AppSettingsStore(file) + expect(store.load()).toEqual(DEFAULT_APP_SETTINGS) + }) + + it("round-trips saved settings", () => { + const store = new AppSettingsStore(file) + store.load() + store.save({ + runAtStartup: true, + openToTrayOnStartup: true, + trayNotifications: "off", + }) + const reloaded = new AppSettingsStore(file) + expect(reloaded.load()).toEqual({ + runAtStartup: true, + openToTrayOnStartup: true, + trayNotifications: "off", + }) + }) + + it("normalizes on save", () => { + const store = new AppSettingsStore(file) + store.load() + store.save({ + runAtStartup: false, + openToTrayOnStartup: true, + trayNotifications: "failures", + }) + expect(JSON.parse(readFileSync(file, "utf-8"))).toEqual({ + runAtStartup: false, + openToTrayOnStartup: false, + trayNotifications: "failures", + }) + }) + + it("propagates write failures and keeps the previous settings", () => { + const store = new AppSettingsStore(join(dir, "missing", "app-settings.json")) + store.load() + let calls = 0 + store.onChange(() => calls++) + expect(() => + store.save({ ...DEFAULT_APP_SETTINGS, runAtStartup: true }), + ).toThrow() + expect(store.get()).toEqual(DEFAULT_APP_SETTINGS) + expect(calls).toBe(0) + }) + + it("notifies listeners on save", () => { + const store = new AppSettingsStore(file) + store.load() + let calls = 0 + const off = store.onChange(() => calls++) + store.save({ ...DEFAULT_APP_SETTINGS, runAtStartup: true }) + expect(calls).toBe(1) + off() + store.save({ ...DEFAULT_APP_SETTINGS }) + expect(calls).toBe(1) + }) +}) diff --git a/desktop/src/main/__tests__/launch-context.test.ts b/desktop/src/main/__tests__/launch-context.test.ts new file mode 100644 index 000000000..917afc574 --- /dev/null +++ b/desktop/src/main/__tests__/launch-context.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest" +import { + AUTO_LAUNCH_ARG, + isAutomaticLoginLaunch, + shouldSuppressInitialWindow, +} from "../launch-context.js" + +const base = { runAtStartup: true, openToTrayOnStartup: true, trayNotifications: "failures" as const } + +describe("isAutomaticLoginLaunch", () => { + it("detects the launch argument on Windows and Linux", () => { + expect( + isAutomaticLoginLaunch({ + argv: ["/app/devsy", AUTO_LAUNCH_ARG], + platform: "win32", + }), + ).toBe(true) + expect( + isAutomaticLoginLaunch({ + argv: ["/app/devsy", AUTO_LAUNCH_ARG], + platform: "linux", + }), + ).toBe(true) + }) + + it("detects a macOS login launch from login item settings", () => { + expect( + isAutomaticLoginLaunch({ + argv: ["/app/devsy"], + platform: "darwin", + wasOpenedAtLogin: true, + }), + ).toBe(true) + expect( + isAutomaticLoginLaunch({ + argv: ["/app/devsy"], + platform: "darwin", + wasOpenedAsHidden: true, + }), + ).toBe(true) + }) + + it("treats an explicit launch as manual", () => { + expect( + isAutomaticLoginLaunch({ argv: ["/app/devsy"], platform: "linux" }), + ).toBe(false) + expect( + isAutomaticLoginLaunch({ + argv: ["/app/devsy"], + platform: "darwin", + wasOpenedAtLogin: false, + wasOpenedAsHidden: false, + }), + ).toBe(false) + }) + + it("ignores the macOS flags on other platforms", () => { + expect( + isAutomaticLoginLaunch({ + argv: ["/app/devsy"], + platform: "linux", + wasOpenedAtLogin: true, + }), + ).toBe(false) + }) +}) + +describe("shouldSuppressInitialWindow", () => { + it("suppresses only an automatic launch with both toggles on and a tray host", () => { + expect(shouldSuppressInitialWindow(base, true, true)).toBe(true) + }) + + it("never suppresses an explicit launch", () => { + expect(shouldSuppressInitialWindow(base, false, true)).toBe(false) + }) + + it("never suppresses without a tray host (GNOME without an extension)", () => { + expect(shouldSuppressInitialWindow(base, true, false)).toBe(false) + }) + + it("never suppresses when open-to-tray is off", () => { + expect( + shouldSuppressInitialWindow({ ...base, openToTrayOnStartup: false }, true, true), + ).toBe(false) + }) + + it("never suppresses when run-at-startup is off", () => { + expect( + shouldSuppressInitialWindow( + { ...base, runAtStartup: false, openToTrayOnStartup: false }, + true, + true, + ), + ).toBe(false) + }) +}) diff --git a/desktop/src/main/__tests__/settings-service.test.ts b/desktop/src/main/__tests__/settings-service.test.ts new file mode 100644 index 000000000..e4a89ca7a --- /dev/null +++ b/desktop/src/main/__tests__/settings-service.test.ts @@ -0,0 +1,108 @@ +import { mkdtempSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { AppSettingsStore, DEFAULT_APP_SETTINGS } from "../app-settings.js" +import type { AutostartApplyResult } from "../autostart.js" +import { SettingsService } from "../settings-service.js" + +const enabled: AutostartApplyResult = { applied: true, enabled: true, status: "enabled" } +const disabled: AutostartApplyResult = { applied: true, enabled: false, status: "disabled" } + +describe("SettingsService", () => { + let dir: string + let store: AppSettingsStore + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "settings-service-")) + store = new AppSettingsStore(join(dir, "app-settings.json")) + store.load() + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + function service(applyAutostart: (s: typeof DEFAULT_APP_SETTINGS) => Promise) { + const onChanged = vi.fn() + const svc = new SettingsService({ + store, + applyAutostart, + currentAutostartEnabled: () => false, + onChanged, + }) + return { svc, onChanged } + } + + it("applies autostart and persists when enabling succeeds", async () => { + const { svc, onChanged } = service(async () => enabled) + const result = await svc.update({ runAtStartup: true }) + expect(result.settings.runAtStartup).toBe(true) + expect(store.get().runAtStartup).toBe(true) + expect(onChanged).toHaveBeenCalledTimes(1) + }) + + it("persists and broadcasts the reverted state when the platform denies it", async () => { + const { svc, onChanged } = service(async () => ({ + applied: false, + enabled: false, + status: "denied", + detail: "denied by the system", + })) + const result = await svc.update({ runAtStartup: true }) + expect(result.settings.runAtStartup).toBe(false) + expect(result.settings.openToTrayOnStartup).toBe(false) + expect(result.startup.status).toBe("denied") + expect(result.startup.detail).toBe("denied by the system") + expect(store.get().runAtStartup).toBe(false) + expect(onChanged).toHaveBeenCalledTimes(1) + expect(onChanged).toHaveBeenCalledWith({ + settings: expect.objectContaining({ runAtStartup: false }), + startup: expect.objectContaining({ status: "denied" }), + }) + }) + + it("keeps autostart off when enabling errors", async () => { + const { svc } = service(async () => ({ + applied: false, + enabled: false, + status: "error", + detail: "boom", + })) + const result = await svc.update({ runAtStartup: true, openToTrayOnStartup: true }) + expect(result.settings.runAtStartup).toBe(false) + expect(store.get().runAtStartup).toBe(false) + }) + + it("reapplies autostart when the dependent toggle changes", async () => { + const calls: boolean[] = [] + const { svc } = service(async (s) => { + calls.push(s.openToTrayOnStartup) + return enabled + }) + await svc.update({ runAtStartup: true }) + await svc.update({ openToTrayOnStartup: true }) + expect(calls).toEqual([false, true]) + expect(store.get().openToTrayOnStartup).toBe(true) + }) + + it("does not touch autostart for unrelated changes", async () => { + const apply = vi.fn(async () => enabled) + const { svc } = service(apply) + await svc.update({ trayNotifications: "all" }) + expect(apply).not.toHaveBeenCalled() + expect(store.get().trayNotifications).toBe("all") + }) + + it("disables autostart when run-at-startup turns off", async () => { + const { svc } = service(async (s) => (s.runAtStartup ? enabled : disabled)) + await svc.update({ runAtStartup: true, openToTrayOnStartup: true }) + const result = await svc.update({ runAtStartup: false }) + expect(result.settings).toEqual({ + runAtStartup: false, + openToTrayOnStartup: false, + trayNotifications: "failures", + }) + expect(result.startup.status).toBe("disabled") + }) +}) diff --git a/desktop/src/main/__tests__/tray-notifications.test.ts b/desktop/src/main/__tests__/tray-notifications.test.ts new file mode 100644 index 000000000..113ce2c1d --- /dev/null +++ b/desktop/src/main/__tests__/tray-notifications.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it, vi } from "vitest" +import type { WorkspaceJob } from "../../shared/workspace-operation.js" +import { + collectTerminalOutcomes, + outcomeNotificationBody, + outcomeNotifies, + TrayNotifier, + updateNotifies, + type NotificationRequest, +} from "../tray-notifications.js" +import type { UpdateStatus } from "../updater.js" + +function job(partial: Partial): WorkspaceJob { + return { + commandId: "cmd-1", + activity: "starting", + state: "running", + phase: "Preparing", + ...partial, + } +} + +describe("collectTerminalOutcomes", () => { + it("emits an outcome when a job reaches a terminal state", () => { + const previous = { ws: job({ state: "running" }) } + const current = { ws: job({ state: "succeeded" }) } + expect(collectTerminalOutcomes(previous, current, new Set())).toEqual([ + { workspaceId: "ws", commandId: "cmd-1", activity: "starting", state: "succeeded" }, + ]) + }) + + it("does not emit for non-terminal states", () => { + const current = { ws: job({ state: "reconciling", refreshError: "x" }) } + expect(collectTerminalOutcomes({}, current, new Set())).toEqual([]) + }) + + it("does not re-emit for an already terminal command", () => { + const previous = { ws: job({ state: "failed", error: "boom" }) } + const current = { ws: job({ state: "failed", error: "boom" }) } + expect(collectTerminalOutcomes(previous, current, new Set())).toEqual([]) + }) + + it("skips commands already notified", () => { + const current = { ws: job({ state: "failed", error: "boom" }) } + expect( + collectTerminalOutcomes({}, current, new Set(["cmd-1"])), + ).toEqual([]) + }) + + it("treats a new command id as a new outcome", () => { + const previous = { ws: job({ state: "failed", error: "boom" }) } + const current = { ws: job({ commandId: "cmd-2", state: "failed", error: "boom" }) } + const outcomes = collectTerminalOutcomes(previous, current, new Set(["cmd-1"])) + expect(outcomes).toHaveLength(1) + expect(outcomes[0].commandId).toBe("cmd-2") + }) +}) + +describe("outcomeNotifies", () => { + const failed = { workspaceId: "ws", commandId: "c", activity: "stopping" as const, state: "failed" as const } + const succeeded = { ...failed, state: "succeeded" as const } + + it("notifies nothing when off", () => { + expect(outcomeNotifies(failed, "off")).toBe(false) + expect(outcomeNotifies(succeeded, "off")).toBe(false) + }) + + it("notifies only failures by default", () => { + expect(outcomeNotifies(failed, "failures")).toBe(true) + expect(outcomeNotifies(succeeded, "failures")).toBe(false) + }) + + it("notifies all terminal outcomes when set to all", () => { + expect(outcomeNotifies(failed, "all")).toBe(true) + expect(outcomeNotifies(succeeded, "all")).toBe(true) + }) +}) + +describe("outcomeNotificationBody", () => { + it("uses text verbs", () => { + expect( + outcomeNotificationBody({ workspaceId: "api", commandId: "c", activity: "stopping", state: "succeeded" }), + ).toBe("api: Stop completed") + expect( + outcomeNotificationBody({ workspaceId: "api", commandId: "c", activity: "starting", state: "failed" }), + ).toBe("api: Start failed") + }) +}) + +describe("updateNotifies", () => { + const downloaded: UpdateStatus = { + state: "downloaded", + currentVersion: "1.0.0", + availableVersion: "1.1.0", + } + + it("notifies downloaded updates only at the all level, once per version", () => { + const first = updateNotifies(downloaded, "all", undefined) + expect(first.notifies).toBe(true) + expect(first.version).toBe("1.1.0") + expect(updateNotifies(downloaded, "all", "1.1.0").notifies).toBe(false) + expect(updateNotifies(downloaded, "failures", undefined).notifies).toBe(false) + }) + + it("never notifies when off", () => { + expect(updateNotifies(downloaded, "off", undefined).notifies).toBe(false) + }) + + it("notifies for install failures once per version", () => { + const failed: UpdateStatus = { + state: "error", + currentVersion: "1.0.0", + availableVersion: "1.1.0", + code: "install-failed", + error: "x", + } + expect(updateNotifies(failed, "failures", undefined).notifies).toBe(true) + expect(updateNotifies(failed, "failures", "1.1.0").notifies).toBe(false) + }) + + it("ignores other update states", () => { + expect( + updateNotifies({ state: "checking", currentVersion: "1.0.0" }, "all", undefined).notifies, + ).toBe(false) + }) +}) + +function makeNotifier(level: () => "off" | "failures" | "all", focused: () => boolean) { + const sent: NotificationRequest[] = [] + const notifier = new TrayNotifier({ + getLevel: level, + isAppFocused: focused, + sink: (request) => sent.push(request), + openWorkspace: vi.fn(), + openWorkspaceLogs: vi.fn(), + openUpdates: vi.fn(), + }) + return { notifier, sent } +} + +describe("TrayNotifier", () => { + it("sends a notification for a failure under the default level", () => { + const { notifier, sent } = makeNotifier(() => "failures", () => false) + notifier.onJobsChanged({ ws: job({ state: "running" }) }) + notifier.onJobsChanged({ ws: job({ state: "failed", error: "boom" }) }) + expect(sent).toHaveLength(1) + expect(sent[0].body).toBe("ws: Start failed") + }) + + it("never notifies for jobs recovered in the initial snapshot", () => { + const { notifier, sent } = makeNotifier(() => "all", () => false) + const terminal = { ws: job({ state: "failed", error: "boom" }) } + notifier.onJobsChanged(terminal) + notifier.onJobsChanged(terminal) + expect(sent).toHaveLength(0) + }) + + it("deduplicates repeated snapshots of the same command after hydration", () => { + const { notifier, sent } = makeNotifier(() => "failures", () => false) + const terminal = { ws: job({ state: "failed", error: "boom" }) } + notifier.onJobsChanged({ ws: job({ state: "running" }) }) + notifier.onJobsChanged(terminal) + notifier.onJobsChanged(terminal) + expect(sent).toHaveLength(1) + }) + + it("suppresses notifications while the app is focused", () => { + const { notifier, sent } = makeNotifier(() => "failures", () => true) + notifier.onJobsChanged({ ws: job({ state: "running" }) }) + notifier.onJobsChanged({ ws: job({ state: "failed", error: "boom" }) }) + expect(sent).toHaveLength(0) + }) + + it("routes failure clicks to logs and success clicks to the workspace", () => { + const openWorkspace = vi.fn() + const openWorkspaceLogs = vi.fn() + const sent: NotificationRequest[] = [] + const notifier = new TrayNotifier({ + getLevel: () => "all", + isAppFocused: () => false, + sink: (request) => sent.push(request), + openWorkspace, + openWorkspaceLogs, + openUpdates: vi.fn(), + }) + notifier.onJobsChanged({ ws: job({ state: "running" }) }) + notifier.onJobsChanged({ ws: job({ state: "failed", error: "boom" }) }) + sent[0].onClick() + expect(openWorkspaceLogs).toHaveBeenCalledWith("ws") + notifier.onJobsChanged({ ws: job({ commandId: "cmd-2", state: "succeeded" }) }) + sent[1].onClick() + expect(openWorkspace).toHaveBeenCalledWith("ws") + }) +}) diff --git a/desktop/src/main/__tests__/tray-support.test.ts b/desktop/src/main/__tests__/tray-support.test.ts new file mode 100644 index 000000000..21fa7465e --- /dev/null +++ b/desktop/src/main/__tests__/tray-support.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest" +import { isTrayHostAvailable } from "../tray-support.js" + +describe("isTrayHostAvailable", () => { + it("treats non-Linux platforms as tray-capable", async () => { + await expect(isTrayHostAvailable({ platform: "darwin" })).resolves.toBe(true) + await expect(isTrayHostAvailable({ platform: "win32" })).resolves.toBe(true) + }) + + it("detects a registered StatusNotifierWatcher", async () => { + await expect( + isTrayHostAvailable({ platform: "linux", getNameOwner: async () => ":1.42" }), + ).resolves.toBe(true) + }) + + it("treats GNOME without a watcher extension as tray-incapable", async () => { + await expect( + isTrayHostAvailable({ + platform: "linux", + getNameOwner: async () => { + throw new Error("org.freedesktop.DBus.Error.NameHasNoOwner") + }, + }), + ).resolves.toBe(false) + }) + + it("times out a hung bus instead of suppressing the window", async () => { + await expect( + isTrayHostAvailable({ + platform: "linux", + timeoutMs: 10, + getNameOwner: () => new Promise(() => {}), + }), + ).resolves.toBe(false) + }) +}) diff --git a/desktop/src/main/__tests__/tray.test.ts b/desktop/src/main/__tests__/tray.test.ts index aed9eb07f..9885902b8 100644 --- a/desktop/src/main/__tests__/tray.test.ts +++ b/desktop/src/main/__tests__/tray.test.ts @@ -119,89 +119,306 @@ describe("buildUpdateMenuItems", () => { }) describe("buildTrayMenuTemplate", () => { - const actions = { - showDevsy: vi.fn(), - showWorkspace: vi.fn(), - showAllWorkspaces: vi.fn(), - stopWorkspace: vi.fn(), - installUpdate: vi.fn(), - quit: vi.fn(), + const settings = { + runAtStartup: true, + openToTrayOnStartup: false, + trayNotifications: "failures" as const, } - it("shows only active workspaces with open and stop actions", () => { + function makeActions() { + return { + showDevsy: vi.fn(), + showWorkspace: vi.fn(), + showWorkspaceLogs: vi.fn(), + showAllWorkspaces: vi.fn(), + showSettings: vi.fn(), + startWorkspace: vi.fn(), + stopWorkspace: vi.fn(), + toggleRunAtStartup: vi.fn(), + toggleOpenToTray: vi.fn(), + installUpdate: vi.fn(), + quit: vi.fn(), + } + } + + function model(partial: Partial[0]>) { + return { + workspaces: [], + pendingStops: new Set(), + pendingStarts: new Set(), + updateStatus: { state: "idle" as const, currentVersion: "1.0.0" }, + settings, + ...partial, + } + } + + function labels(items: Electron.MenuItemConstructorOptions[]): (string | undefined)[] { + return items.map((item) => item.label) + } + + it("shows an empty state when there are no workspaces", () => { + const items = buildTrayMenuTemplate(model({}), makeActions()) + expect(labels(items)).toContain("No Workspaces") + }) + + it("shows the running count in the header", () => { const items = buildTrayMenuTemplate( - { - activeWorkspaces: [ - { id: "running", status: "running" }, - { id: "busy", status: "busy" }, + model({ + workspaces: [ + { id: "a", status: "running" }, + { id: "b", status: "running" }, + { id: "c", status: "stopped" }, ], - pendingStops: new Set(), - updateStatus: { state: "idle", currentVersion: "1.0.0" }, - }, - actions, - ) - expect(items[0]).toMatchObject({ label: "Show Devsy" }) - const submenu = items[2].submenu as Array> - expect(submenu[0]).toMatchObject({ label: "running" }) - expect(submenu[1]).toMatchObject({ label: "busy — Busy" }) - expect(submenu[0].submenu).toEqual([ - expect.objectContaining({ label: "Open in Devsy" }), - expect.objectContaining({ label: "Stop Workspace" }), - ]) - expect(items.at(-1)).toMatchObject({ label: "Quit Devsy" }) - }) - - it("shows an empty state and disables a pending stop", () => { - const empty = buildTrayMenuTemplate( - { - activeWorkspaces: [], - pendingStops: new Set(), - updateStatus: { state: "idle", currentVersion: "1.0.0" }, - }, - actions, + }), + makeActions(), ) - expect( - (empty[2].submenu as Array>)[0], - ).toMatchObject({ - label: "No Active Workspaces", + expect(items[0]).toMatchObject({ + label: "Devsy — 2 running workspaces", enabled: false, }) + }) - const pending = buildTrayMenuTemplate( - { - activeWorkspaces: [{ id: "ws-1", status: "running" }], - pendingStops: new Set(["ws-1"]), - updateStatus: { state: "idle", currentVersion: "1.0.0" }, - }, + it("limits the list to five most recently used workspaces with an overflow link", () => { + const workspaces = Array.from({ length: 7 }, (_, i) => ({ + id: `ws-${i}`, + status: "stopped", + })) + const actions = makeActions() + const items = buildTrayMenuTemplate(model({ workspaces }), actions) + const rows = items.filter((item) => item.label?.startsWith("○")) + expect(rows).toHaveLength(5) + const overflow = items.find((item) => + item.label?.includes("View All 7 Workspaces in Devsy"), + ) + expect(overflow).toBeDefined() + ;(overflow as { click?: () => void }).click?.() + expect(actions.showAllWorkspaces).toHaveBeenCalledTimes(1) + }) + + it("does not add an overflow link at or under the limit", () => { + const workspaces = Array.from({ length: 5 }, (_, i) => ({ + id: `ws-${i}`, + status: "stopped", + })) + const items = buildTrayMenuTemplate(model({ workspaces }), makeActions()) + expect( + items.some((item) => item.label?.includes("View All")), + ).toBe(false) + }) + + it("exposes Stop for running workspaces with text labels and state glyphs", () => { + const actions = makeActions() + const items = buildTrayMenuTemplate( + model({ workspaces: [{ id: "api", status: "running" }] }), actions, ) - const stop = ( - (pending[2].submenu as Array>)[0] - .submenu as Array> - )[1] - expect(stop).toMatchObject({ label: "Stopping…", enabled: false }) + const row = items.find((item) => item.label === "● api — Running") + expect(row).toBeDefined() + const submenu = row?.submenu as Electron.MenuItemConstructorOptions[] + const stop = submenu.find((item) => item.label === "Stop Workspace") + expect(stop).toBeDefined() + expect(stop?.enabled).not.toBe(false) + ;(stop as { click?: () => void }).click?.() + expect(actions.stopWorkspace).toHaveBeenCalledWith("api") }) - it("shows shared progress and permits stopping an active start", () => { + + it("exposes Start for stopped workspaces", () => { + const actions = makeActions() const items = buildTrayMenuTemplate( - { - activeWorkspaces: [{ id: "ws", status: "Stopped" }], - pendingStops: new Set(), + model({ workspaces: [{ id: "api", status: "stopped" }] }), + actions, + ) + const row = items.find((item) => item.label === "○ api — Stopped") + const submenu = row?.submenu as Electron.MenuItemConstructorOptions[] + const start = submenu.find((item) => item.label === "Start Workspace") + expect(start).toBeDefined() + ;(start as { click?: () => void }).click?.() + expect(actions.startWorkspace).toHaveBeenCalledWith("api") + }) + + it("offers View Logs for failed, busy, and unknown rows instead of lifecycle actions", () => { + const items = buildTrayMenuTemplate( + model({ + workspaces: [ + { id: "failed-ws", status: "stopped" }, + { id: "busy-ws", status: "busy" }, + { id: "unknown-ws" }, + ], jobs: { - ws: { - commandId: "start", + "failed-ws": { + commandId: "c1", + activity: "stopping", + state: "failed", + phase: "See workspace logs for details", + error: "boom", + }, + "busy-ws": { + commandId: "c2", activity: "starting", state: "running", - phase: "Building image", + phase: "Building", }, }, - updateStatus: { state: "idle", currentVersion: "1.0.0" }, - }, + }), + makeActions(), + ) + const row = (name: string) => + items.find((item) => item.label?.includes(name)) + ?.submenu as Electron.MenuItemConstructorOptions[] + expect(row("failed-ws").some((item) => item.label === "View Logs")).toBe(true) + expect(row("failed-ws").some((item) => item.label === "Stop Workspace")).toBe(false) + expect(row("busy-ws").some((item) => item.label === "View Logs")).toBe(true) + expect(row("busy-ws").some((item) => item.label === "Start Workspace")).toBe(false) + expect(row("unknown-ws").some((item) => item.label === "View Logs")).toBe(true) + }) + + it("marks failed rows with a failed glyph and text", () => { + const items = buildTrayMenuTemplate( + model({ + workspaces: [{ id: "api", status: "stopped" }], + jobs: { + api: { + commandId: "c1", + activity: "stopping", + state: "failed", + phase: "See workspace logs for details", + error: "boom", + }, + }, + }), + makeActions(), + ) + expect(items.some((item) => item.label === "✖ api — Stop failed")).toBe(true) + }) + + it("disables Stop while a stop is pending", () => { + const items = buildTrayMenuTemplate( + model({ + workspaces: [{ id: "api", status: "running" }], + pendingStops: new Set(["api"]), + }), + makeActions(), + ) + const row = items.find((item) => item.label?.includes("api")) + const submenu = row?.submenu as Electron.MenuItemConstructorOptions[] + const stop = submenu.find((item) => item.label?.includes("Stop")) + expect(stop?.enabled).toBe(false) + }) + + it("disables Start while a start is pending", () => { + const items = buildTrayMenuTemplate( + model({ + workspaces: [{ id: "api", status: "stopped" }], + pendingStarts: new Set(["api"]), + }), + makeActions(), + ) + const row = items.find((item) => item.label === "○ api — Stopped") + const submenu = row?.submenu as Electron.MenuItemConstructorOptions[] + const start = submenu.find((item) => item.label?.includes("Start")) + expect(start?.enabled).toBe(false) + expect(start?.click).toBeUndefined() + }) + + it("mirrors the startup toggles as native checkboxes", () => { + const actions = makeActions() + const items = buildTrayMenuTemplate( + model({ + settings: { + runAtStartup: true, + openToTrayOnStartup: false, + trayNotifications: "failures", + }, + }), actions, ) - const workspace = (items[2].submenu as Array>)[0] - expect(workspace.label).toBe("ws — Starting") + const prefs = items.find((item) => item.label === "Preferences") + const submenu = prefs?.submenu as Electron.MenuItemConstructorOptions[] + const runAtStartup = submenu.find((item) => item.label === "Run at Startup") + const openToTray = submenu.find( + (item) => item.label === "Open to Tray on Startup", + ) + expect(runAtStartup).toMatchObject({ type: "checkbox", checked: true }) + expect(openToTray).toMatchObject({ + type: "checkbox", + checked: false, + enabled: true, + }) + ;(runAtStartup as { click?: () => void }).click?.() + expect(actions.toggleRunAtStartup).toHaveBeenCalledTimes(1) + ;(openToTray as { click?: () => void }).click?.() + expect(actions.toggleOpenToTray).toHaveBeenCalledTimes(1) + }) + + it("disables the dependent toggle when run at startup is off", () => { + const items = buildTrayMenuTemplate( + model({ + settings: { + runAtStartup: false, + openToTrayOnStartup: false, + trayNotifications: "failures", + }, + }), + makeActions(), + ) + const prefs = items.find((item) => item.label === "Preferences") + const submenu = prefs?.submenu as Electron.MenuItemConstructorOptions[] + expect( + submenu.find((item) => item.label === "Open to Tray on Startup")?.enabled, + ).toBe(false) + }) + + it("links into DevSy settings from the preferences submenu", () => { + const actions = makeActions() + const items = buildTrayMenuTemplate(model({}), actions) + const prefs = items.find((item) => item.label === "Preferences") + const submenu = prefs?.submenu as Electron.MenuItemConstructorOptions[] + const openSettings = submenu.find((item) => item.label === "Open Settings…") + ;(openSettings as { click?: () => void }).click?.() + expect(actions.showSettings).toHaveBeenCalledTimes(1) + }) + + it("keeps update items and Quit", () => { + const items = buildTrayMenuTemplate( + model({ + updateStatus: { + state: "downloaded", + currentVersion: "1.0.0", + availableVersion: "9.9.9", + }, + }), + makeActions(), + ) + expect(labels(items)).toContain("Update to 9.9.9") + expect(labels(items)).toContain("Quit Devsy") + }) +}) + +describe("trayWorkspaceState", () => { + it("maps statuses and jobs to row states", async () => { + const { trayWorkspaceState } = await import("../tray.js") + expect(trayWorkspaceState({ id: "a", status: "running" })).toBe("running") + expect(trayWorkspaceState({ id: "a", status: "stopped" })).toBe("stopped") + expect( + trayWorkspaceState({ id: "a", status: '{"state":"stopped"}' }), + ).toBe("stopped") + expect(trayWorkspaceState({ id: "a" })).toBe("unknown") + expect( + trayWorkspaceState({ id: "a", status: "stopped" }, { + commandId: "c", + activity: "starting", + state: "running", + phase: "Building", + }), + ).toBe("busy") expect( - (workspace.submenu as Array>)[1], - ).toMatchObject({ label: "Stop Workspace", enabled: true }) + trayWorkspaceState({ id: "a", status: "stopped" }, { + commandId: "c", + activity: "starting", + state: "failed", + phase: "", + error: "x", + }), + ).toBe("failed") }) }) diff --git a/desktop/src/main/app-settings.ts b/desktop/src/main/app-settings.ts new file mode 100644 index 000000000..0079ad7a4 --- /dev/null +++ b/desktop/src/main/app-settings.ts @@ -0,0 +1,95 @@ +import { readFileSync, renameSync, writeFileSync } from "node:fs" +import type { AppSettings, TrayNotificationLevel } from "../shared/app-settings.js" + +export type { AppSettings, TrayNotificationLevel } + +export const DEFAULT_APP_SETTINGS: AppSettings = { + runAtStartup: false, + openToTrayOnStartup: false, + trayNotifications: "failures", +} + +const LEVELS: readonly TrayNotificationLevel[] = ["off", "failures", "all"] + +export function normalizeAppSettings(raw: unknown): AppSettings { + const input = ( + typeof raw === "object" && raw !== null ? raw : {} + ) as Record + const runAtStartup = input.runAtStartup === true + const openToTrayOnStartup = runAtStartup && input.openToTrayOnStartup === true + const trayNotifications = LEVELS.includes( + input.trayNotifications as TrayNotificationLevel, + ) + ? (input.trayNotifications as TrayNotificationLevel) + : DEFAULT_APP_SETTINGS.trayNotifications + return { runAtStartup, openToTrayOnStartup, trayNotifications } +} + +export function patchAppSettings( + current: AppSettings, + patch: Partial, +): AppSettings { + return normalizeAppSettings({ ...current, ...patch }) +} + +// Throws on a mistyped value so a renderer bug cannot silently persist a +// corrupt preference. +export function sanitizeAppSettingsPatch(raw: unknown): Partial { + if (!raw || typeof raw !== "object") return {} + const input = raw as Record + const patch: Partial = {} + if ("runAtStartup" in input) { + if (typeof input.runAtStartup !== "boolean") + throw new Error("runAtStartup must be boolean") + patch.runAtStartup = input.runAtStartup + } + if ("openToTrayOnStartup" in input) { + if (typeof input.openToTrayOnStartup !== "boolean") + throw new Error("openToTrayOnStartup must be boolean") + patch.openToTrayOnStartup = input.openToTrayOnStartup + } + if ("trayNotifications" in input) { + if (!LEVELS.includes(input.trayNotifications as TrayNotificationLevel)) + throw new Error("trayNotifications must be off, failures, or all") + patch.trayNotifications = input.trayNotifications as TrayNotificationLevel + } + return patch +} + +export class AppSettingsStore { + private settings: AppSettings + private listeners = new Set<() => void>() + + constructor(private readonly filePath: string) { + this.settings = DEFAULT_APP_SETTINGS + } + + load(): AppSettings { + let raw: unknown = {} + try { + raw = JSON.parse(readFileSync(this.filePath, "utf-8")) + } catch { + raw = {} + } + this.settings = normalizeAppSettings(raw) + return this.settings + } + + get(): AppSettings { + return this.settings + } + + save(next: AppSettings): void { + const normalized = normalizeAppSettings(next) + const tmp = `${this.filePath}.tmp` + writeFileSync(tmp, JSON.stringify(normalized, null, 2)) + renameSync(tmp, this.filePath) + this.settings = normalized + for (const listener of this.listeners) listener() + } + + onChange(listener: () => void): () => void { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } +} diff --git a/desktop/src/main/autostart.ts b/desktop/src/main/autostart.ts new file mode 100644 index 000000000..d5a1e049f --- /dev/null +++ b/desktop/src/main/autostart.ts @@ -0,0 +1,199 @@ +import { existsSync } from "node:fs" +import { mkdir, rm, writeFile } from "node:fs/promises" +import { join } from "node:path" +import { app } from "electron" +import type { AppSettings } from "./app-settings.js" +import { AUTO_LAUNCH_ARG } from "./launch-context.js" +import { requestPortalBackground } from "./portal-background.js" + +export interface AutostartApplyResult { + applied: boolean + enabled: boolean + status: "enabled" | "disabled" | "denied" | "unavailable" | "error" + detail?: string +} + +export interface AutostartEnvironment { + platform: string + isFlatpak: boolean + flatpakId?: string + execPath: string + homeDir: string + packaged: boolean +} + +export function detectAutostartEnvironment(): AutostartEnvironment { + return { + platform: process.platform, + isFlatpak: Boolean(process.env.FLATPAK_ID), + flatpakId: process.env.FLATPAK_ID, + execPath: process.execPath, + homeDir: app.getPath("home"), + packaged: app.isPackaged, + } +} + +function autostartDir(homeDir: string): string { + return join(homeDir, ".config", "autostart") +} + +function xdgDesktopFilePath(env: AutostartEnvironment): string { + return join(autostartDir(env.homeDir), "devsy.desktop") +} + +function flatpakDesktopFilePath(env: AutostartEnvironment): string { + return join(autostartDir(env.homeDir), `${env.flatpakId}.desktop`) +} + +function xdgDesktopEntry(execPath: string): string { + return [ + "[Desktop Entry]", + "Type=Application", + "Name=Devsy", + `Exec="${execPath}" ${AUTO_LAUNCH_ARG}`, + "X-GNOME-Autostart-enabled=true", + "", + ].join("\n") +} + +export function readAutostartEnabled( + env: AutostartEnvironment, +): boolean | undefined { + if (env.platform === "darwin" || env.platform === "win32") { + return app.getLoginItemSettings().openAtLogin + } + try { + if (env.isFlatpak) { + const file = flatpakDesktopFilePath(env) + return existsSync(file) ? true : false + } + return existsSync(xdgDesktopFilePath(env)) + } catch { + return undefined + } +} + +export async function applyAutostart( + settings: AppSettings, + env: AutostartEnvironment, +): Promise { + if (env.platform === "darwin") { + try { + app.setLoginItemSettings({ + openAtLogin: settings.runAtStartup, + openAsHidden: settings.openToTrayOnStartup, + }) + return { applied: true, enabled: settings.runAtStartup, status: settings.runAtStartup ? "enabled" : "disabled" } + } catch (error) { + return { + applied: false, + enabled: false, + status: "error", + detail: errorMessage(error), + } + } + } + if (env.platform === "win32") { + try { + app.setLoginItemSettings({ + openAtLogin: settings.runAtStartup, + args: [AUTO_LAUNCH_ARG], + }) + return { applied: true, enabled: settings.runAtStartup, status: settings.runAtStartup ? "enabled" : "disabled" } + } catch (error) { + return { + applied: false, + enabled: false, + status: "error", + detail: errorMessage(error), + } + } + } + if (env.platform !== "linux") { + return { + applied: false, + enabled: false, + status: "unavailable", + detail: `Run at startup is not supported on ${env.platform}.`, + } + } + if (env.isFlatpak) { + return applyFlatpakAutostart(settings, env) + } + return applyXdgAutostart(settings, env) +} + +async function applyXdgAutostart( + settings: AppSettings, + env: AutostartEnvironment, +): Promise { + const file = xdgDesktopFilePath(env) + try { + if (settings.runAtStartup) { + await mkdir(autostartDir(env.homeDir), { recursive: true }) + await writeFile(file, xdgDesktopEntry(env.execPath), "utf-8") + } else { + await rm(file, { force: true }) + } + return { applied: true, enabled: settings.runAtStartup, status: settings.runAtStartup ? "enabled" : "disabled" } + } catch (error) { + return { + applied: false, + enabled: readAutostartEnabled(env) === true, + status: "error", + detail: errorMessage(error), + } + } +} + +// Flatpak autostart must go through the Background portal so the user sees +// the system consent prompt. The portal has no removal call, so disabling +// removes the portal-created desktop file directly; --filesystem=home in the +// manifest makes it visible inside the sandbox. Denial is a normal result +// and is reported truthfully instead of claiming the setting is on. +async function applyFlatpakAutostart( + settings: AppSettings, + env: AutostartEnvironment, +): Promise { + if (!settings.runAtStartup) { + try { + await rm(flatpakDesktopFilePath(env), { force: true }) + } catch (error) { + return { + applied: false, + enabled: readAutostartEnabled(env) === true, + status: "error", + detail: errorMessage(error), + } + } + return { applied: true, enabled: false, status: "disabled" } + } + try { + const response = await requestPortalBackground({ + reason: "Allow Devsy to start automatically after you sign in.", + autostart: true, + commandline: ["devsy-wrapper", AUTO_LAUNCH_ARG], + }) + if (response.autostart) { + return { applied: true, enabled: true, status: "enabled" } + } + return { + applied: false, + enabled: false, + status: "denied", + detail: + "The system did not grant background autostart permission. Run at startup stays off; you can allow it from your desktop settings and try again.", + } + } catch (error) { + return { + applied: false, + enabled: false, + status: "error", + detail: errorMessage(error), + } + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/desktop/src/main/index.ts b/desktop/src/main/index.ts index f516c25fa..dbd7b1b02 100644 --- a/desktop/src/main/index.ts +++ b/desktop/src/main/index.ts @@ -1,8 +1,18 @@ import { homedir } from "node:os" import { join } from "node:path" -import { app, BrowserWindow, session } from "electron" +import { app, BrowserWindow, Notification, session } from "electron" import { initAnalytics, shutdownAnalytics, trackEvent } from "./analytics.js" import { isAppQuitting, markAppQuitting } from "./app-lifecycle.js" +import { AppSettingsStore } from "./app-settings.js" +import { + applyAutostart, + detectAutostartEnvironment, + readAutostartEnabled, +} from "./autostart.js" +import { isAutomaticLoginLaunch, shouldSuppressInitialWindow } from "./launch-context.js" +import { isTrayHostAvailable } from "./tray-support.js" +import { SettingsService } from "./settings-service.js" +import { TrayNotifier } from "./tray-notifications.js" import { CliRunner } from "./cli.js" import { DaemonManager } from "./daemon-manager.js" import { registerIpcHandlers } from "./ipc.js" @@ -13,7 +23,11 @@ import { ProviderJobs } from "./provider-jobs.js" import { PtyManager } from "./pty.js" import { DaemonState } from "./state.js" import { AppTray } from "./tray.js" -import { initAutoUpdater, stopAutoUpdater } from "./updater.js" +import { + initAutoUpdater, + onUpdateStatusChanged, + stopAutoUpdater, +} from "./updater.js" import { Watcher } from "./watcher.js" import { WorkspaceJobs } from "./workspace-jobs.js" @@ -120,7 +134,7 @@ function createWindow(): void { }) } -app.whenReady().then(() => { +app.whenReady().then(async () => { initAnalytics() trackEvent("app_open") @@ -198,6 +212,47 @@ app.whenReady().then(() => { const providerJobs = new ProviderJobs() const workspaceJobs = new WorkspaceJobs() + const appSettingsStore = new AppSettingsStore( + join(app.getPath("userData"), "app-settings.json"), + ) + appSettingsStore.load() + const autostartEnv = detectAutostartEnvironment() + const settingsService = new SettingsService({ + store: appSettingsStore, + applyAutostart: (settings) => applyAutostart(settings, autostartEnv), + currentAutostartEnabled: () => readAutostartEnabled(autostartEnv), + onChanged: (result) => { + appTray?.rebuildMenu() + const win = mainWindow + if (win && !win.isDestroyed()) { + win.webContents.send("app-settings-changed", result) + } + }, + }) + + const notifier = new TrayNotifier({ + getLevel: () => appSettingsStore.get().trayNotifications, + isAppFocused: () => { + const win = mainWindow + return Boolean(win && !win.isDestroyed() && win.isFocused()) + }, + sink: (request) => { + if (!Notification.isSupported()) return + const notification = new Notification({ + title: request.title, + body: request.body, + }) + notification.on("click", request.onClick) + notification.show() + }, + openWorkspace: (id) => showDevsy(`/workspaces/${encodeURIComponent(id)}`), + openWorkspaceLogs: (id) => + showDevsy(`/workspaces/${encodeURIComponent(id)}?tab=logs`), + openUpdates: () => showDevsy("/settings"), + }) + workspaceJobs.onChange(() => notifier.onJobsChanged(workspaceJobs.snapshot())) + onUpdateStatusChanged((status) => notifier.onUpdateStatus(status)) + // Register IPC handlers const { tunnelProcesses, @@ -229,6 +284,7 @@ app.whenReady().then(() => { } }, workspaceSnapshot: () => watcher?.workspaceSnapshot(), + settingsService, }) // Start state watcher @@ -267,6 +323,16 @@ app.whenReady().then(() => { state, workspaceJobs, showDevsy, + getSettings: () => appSettingsStore.get(), + toggleRunAtStartup: () => + void settingsService.update({ + runAtStartup: !appSettingsStore.get().runAtStartup, + }), + toggleOpenToTray: () => + void settingsService.update({ + openToTrayOnStartup: !appSettingsStore.get().openToTrayOnStartup, + }), + startWorkspace: workspaceActions.start, stopWorkspace: workspaceActions.stop, refreshWorkspace: (id) => watcher ? watcher.refreshWorkspaceStatus(id) : Promise.resolve(), @@ -275,7 +341,20 @@ app.whenReady().then(() => { }) appTray.setup() - createWindow() + const loginItems = + process.platform === "darwin" || process.platform === "win32" + ? app.getLoginItemSettings() + : undefined + const automaticLaunch = isAutomaticLoginLaunch({ + argv: process.argv, + platform: process.platform, + wasOpenedAtLogin: loginItems?.wasOpenedAtLogin, + wasOpenedAsHidden: loginItems?.wasOpenedAsHidden, + }) + const trayHostAvailable = await isTrayHostAvailable() + if (!shouldSuppressInitialWindow(appSettingsStore.get(), automaticLaunch, trayHostAvailable)) { + createWindow() + } if (app.isPackaged) { initAutoUpdater(() => mainWindow) diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 16a6e2f56..7040d5476 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -22,6 +22,8 @@ import type { ProviderJobs, } from "./provider-jobs.js" import type { PtyManager } from "./pty.js" +import { sanitizeAppSettingsPatch } from "./app-settings.js" +import type { SettingsService } from "./settings-service.js" import type { DaemonState } from "./state.js" import { checkForUpdates, @@ -108,6 +110,7 @@ interface IpcDependencies { workspaceJobs: WorkspaceJobs workspaceSnapshot?: () => unknown onRendererReady?: (sender: Electron.WebContents) => void + settingsService?: SettingsService } /** Format a line in zap console format so log-parser.ts can parse it. */ @@ -262,7 +265,10 @@ export function registerIpcHandlers(deps: IpcDependencies): { tunnelProcesses: Map scheduleProviderUpdateCheck: () => void runInitialProviderUpdateCheck: () => void - workspaceActions: { stop: (workspaceId: string) => Promise } + workspaceActions: { + stop: (workspaceId: string) => Promise + start: (workspaceId: string) => Promise + } } { const { cli, state, logStore, pty, providerJobs, workspaceJobs, machineDiagnosticsStore, machineDiagnosticsManager, getMainWindow } = deps const tunnelProcesses = new Map< @@ -1091,25 +1097,20 @@ export function registerIpcHandlers(deps: IpcDependencies): { }, ) - ipcMain.handle( - "workspace_up", - async ( - _event, - args: { - source: string - workspaceId?: string - provider?: string - ide?: string - ideLaunch?: "auto" | "headless" | "skip" - debug?: boolean - workspaceFolder?: string - devcontainer?: string - prebuildRepository?: string - platform?: string - recovery?: boolean - commandId?: string - }, - ) => { + const runWorkspaceUp = (args: { + source: string + workspaceId?: string + provider?: string + ide?: string + ideLaunch?: "auto" | "headless" | "skip" + debug?: boolean + workspaceFolder?: string + devcontainer?: string + prebuildRepository?: string + platform?: string + recovery?: boolean + commandId?: string + }): { commandId: string; completion: Promise } => { trackEvent("workspace_create", { provider: args.provider, workspace_ref: hashWorkspaceRef(args.workspaceId ?? args.source), @@ -1138,7 +1139,7 @@ export function registerIpcHandlers(deps: IpcDependencies): { `[workspace-operation] ${cmdId} ${stage} ${Math.round(performance.now() - started)}ms`, ) timing("accepted") - void (async () => { + const completion = (async () => { const logPath = logStore.createLogFile( state.workspaceContext(wsId), wsId, @@ -1325,8 +1326,11 @@ export function registerIpcHandlers(deps: IpcDependencies): { redactCLIError(cliErrorOrFallback(error, "up_failed")).message, ) }) - return cmdId - }, + return { commandId: cmdId, completion } + } + + ipcMain.handle("workspace_up", (_event, args: Parameters[0]) => + runWorkspaceUp(args).commandId, ) async function reconcileDetachedTask( @@ -1834,6 +1838,19 @@ export function registerIpcHandlers(deps: IpcDependencies): { }, ) + ipcMain.handle("get_app_settings", () => { + if (!deps.settingsService) throw new Error("Settings service unavailable") + return deps.settingsService.status() + }) + + ipcMain.handle( + "set_app_settings", + async (_event, args: { patch?: Record }) => { + if (!deps.settingsService) throw new Error("Settings service unavailable") + return deps.settingsService.update(sanitizeAppSettingsPatch(args?.patch)) + }, + ) + // ── Analytics ── ipcMain.handle( "analytics_track", @@ -1878,6 +1895,9 @@ export function registerIpcHandlers(deps: IpcDependencies): { ) await completion }, + async start(workspaceId: string): Promise { + await runWorkspaceUp({ source: workspaceId, commandId: crypto.randomUUID() }).completion + }, }, } } diff --git a/desktop/src/main/launch-context.ts b/desktop/src/main/launch-context.ts new file mode 100644 index 000000000..76b6d3817 --- /dev/null +++ b/desktop/src/main/launch-context.ts @@ -0,0 +1,35 @@ +import type { AppSettings } from "./app-settings.js" + +// Passed by every automatic-launch mechanism (Windows login item args, XDG +// autostart Exec line, Flatpak Background portal commandline) so the app can +// distinguish an automatic login launch from an explicit user launch. macOS +// reports the same through login item settings instead of argv. +export const AUTO_LAUNCH_ARG = "--opened-at-login" + +export interface LaunchEnvironment { + argv: readonly string[] + platform: string + wasOpenedAtLogin?: boolean + wasOpenedAsHidden?: boolean +} + +export function isAutomaticLoginLaunch(env: LaunchEnvironment): boolean { + if (env.argv.includes(AUTO_LAUNCH_ARG)) return true + if (env.platform === "darwin") { + return env.wasOpenedAtLogin === true || env.wasOpenedAsHidden === true + } + return false +} + +export function shouldSuppressInitialWindow( + settings: AppSettings, + automaticLoginLaunch: boolean, + trayHostAvailable: boolean, +): boolean { + return ( + automaticLoginLaunch && + trayHostAvailable && + settings.runAtStartup && + settings.openToTrayOnStartup + ) +} diff --git a/desktop/src/main/portal-background.ts b/desktop/src/main/portal-background.ts new file mode 100644 index 000000000..88eba22db --- /dev/null +++ b/desktop/src/main/portal-background.ts @@ -0,0 +1,80 @@ +import { randomUUID } from "node:crypto" + +export interface PortalBackgroundResponse { + background: boolean + autostart: boolean +} + +export interface PortalBackgroundOptions { + reason: string + autostart: boolean + commandline?: string[] +} + +// The portal Response signal is delivered on a Request object whose path is +// only known after the method returns. Precomputing the path from the +// handle_token lets us subscribe before the call so an instant response +// cannot race past the listener. +export function portalRequestPath(senderUniqueName: string, token: string): string { + const sender = senderUniqueName.replace(/^:/, "").replace(/\./g, "_") + return `/org/freedesktop/portal/desktop/request/${sender}/${token}` +} + +export function parsePortalResponse( + code: number, + results: Record | undefined, +): PortalBackgroundResponse { + if (code !== 0) return { background: false, autostart: false } + return { + background: results?.background?.value === true, + autostart: results?.autostart?.value === true, + } +} + +const PORTAL_TIMEOUT_MS = 120_000 + +export async function requestPortalBackground( + options: PortalBackgroundOptions, +): Promise { + // Imported lazily so non-Flatpak platforms never load the D-Bus stack. + const { sessionBus, Variant } = await import("dbus-next") + const bus = sessionBus() + try { + const desktop = await bus.getProxyObject( + "org.freedesktop.portal.Desktop", + "/org/freedesktop/portal/desktop", + ) + const background = desktop.getInterface("org.freedesktop.portal.Background") + const token = `devsy${randomUUID().replace(/-/g, "")}` + const path = portalRequestPath(bus.name, token) + const requestObject = await bus.getProxyObject( + "org.freedesktop.portal.Desktop", + path, + ) + const request = requestObject.getInterface("org.freedesktop.portal.Request") + const response = new Promise((resolve) => { + const timer = setTimeout(() => { + resolve({ background: false, autostart: false }) + }, PORTAL_TIMEOUT_MS) + request.on( + "Response", + (code: number, results: Record) => { + clearTimeout(timer) + resolve(parsePortalResponse(code, results)) + }, + ) + }) + const methodOptions: Record> = { + handle_token: new Variant("s", token), + reason: new Variant("s", options.reason), + autostart: new Variant("b", options.autostart), + } + if (options.commandline) { + methodOptions.commandline = new Variant("as", options.commandline) + } + await background.RequestBackground("", methodOptions) + return await response + } finally { + bus.disconnect() + } +} diff --git a/desktop/src/main/settings-service.ts b/desktop/src/main/settings-service.ts new file mode 100644 index 000000000..13c044ab2 --- /dev/null +++ b/desktop/src/main/settings-service.ts @@ -0,0 +1,68 @@ +import { + type AppSettings, + type AppSettingsStore, + patchAppSettings, +} from "./app-settings.js" +import type { AutostartApplyResult } from "./autostart.js" + +export interface SettingsUpdateResult { + settings: AppSettings + startup: AutostartApplyResult +} + +export interface SettingsServiceDeps { + store: AppSettingsStore + applyAutostart: (settings: AppSettings) => Promise + currentAutostartEnabled: () => boolean | undefined + onChanged: (result: SettingsUpdateResult) => void +} + +export class SettingsService { + constructor(private deps: SettingsServiceDeps) {} + + get(): AppSettings { + return this.deps.store.get() + } + + status(): SettingsUpdateResult { + const enabled = this.deps.currentAutostartEnabled() + return { + settings: this.get(), + startup: { + applied: enabled !== undefined, + enabled: enabled === true, + status: + enabled === undefined + ? "unavailable" + : enabled + ? "enabled" + : "disabled", + }, + } + } + + async update(patch: Partial): Promise { + const current = this.deps.store.get() + const next = patchAppSettings(current, patch) + let startup: AutostartApplyResult = this.status().startup + const startupRelevant = + next.runAtStartup !== current.runAtStartup || + next.openToTrayOnStartup !== current.openToTrayOnStartup + if (startupRelevant) { + startup = await this.deps.applyAutostart(next) + if (next.runAtStartup && !startup.enabled) { + // Persist and broadcast the reverted state so Settings and the tray + // converge on Run at startup off; the denial detail stays in startup. + const reverted = { ...next, runAtStartup: false, openToTrayOnStartup: false } + this.deps.store.save(reverted) + const result = { settings: reverted, startup } + this.deps.onChanged(result) + return result + } + } + this.deps.store.save(next) + const result = { settings: next, startup } + this.deps.onChanged(result) + return result + } +} diff --git a/desktop/src/main/tray-notifications.ts b/desktop/src/main/tray-notifications.ts new file mode 100644 index 000000000..dde2ef80d --- /dev/null +++ b/desktop/src/main/tray-notifications.ts @@ -0,0 +1,168 @@ +import type { TrayNotificationLevel } from "./app-settings.js" +import type { WorkspaceJob } from "../shared/workspace-operation.js" +import type { UpdateStatus } from "./updater.js" + +export interface NotificationRequest { + title: string + body: string + onClick: () => void +} + +export type NotificationSink = (request: NotificationRequest) => void + +export interface TerminalOutcome { + workspaceId: string + commandId: string + activity: WorkspaceJob["activity"] + state: "succeeded" | "failed" +} + +const ACTIVITY_VERBS: Record = { + creating: "Create", + starting: "Start", + stopping: "Stop", + deleting: "Delete", + rebuilding: "Rebuild", + resetting: "Reset", +} + +export function collectTerminalOutcomes( + previous: Record, + current: Record, + alreadyNotified: ReadonlySet, +): TerminalOutcome[] { + const outcomes: TerminalOutcome[] = [] + for (const [workspaceId, job] of Object.entries(current)) { + if (job.state !== "succeeded" && job.state !== "failed") continue + if (alreadyNotified.has(job.commandId)) continue + const before = previous[workspaceId] + if ( + before?.commandId === job.commandId && + (before.state === "succeeded" || before.state === "failed") + ) + continue + outcomes.push({ + workspaceId, + commandId: job.commandId, + activity: job.activity, + state: job.state, + }) + } + return outcomes +} + +export function outcomeNotifies( + outcome: TerminalOutcome, + level: TrayNotificationLevel, +): boolean { + if (level === "off") return false + if (level === "failures") return outcome.state === "failed" + return true +} + +export function outcomeNotificationBody(outcome: TerminalOutcome): string { + const verb = ACTIVITY_VERBS[outcome.activity] + return outcome.state === "succeeded" + ? `${outcome.workspaceId}: ${verb} completed` + : `${outcome.workspaceId}: ${verb} failed` +} + +export function updateNotifies( + status: UpdateStatus, + level: TrayNotificationLevel, + lastNotifiedVersion: string | undefined, +): { notifies: boolean; version?: string; title: string; body: string } { + const none = { notifies: false, title: "", body: "" } + if (level === "off") return none + const version = status.availableVersion ?? "" + if (!version || version === lastNotifiedVersion) return none + if (status.state === "downloaded") { + if (level !== "all") return none + return { + notifies: true, + version, + title: "Devsy update ready", + body: `Version ${version} is ready to install.`, + } + } + if (status.state === "error" && status.code === "install-failed") { + return { + notifies: true, + version, + title: "Devsy update failed", + body: `Version ${version} could not be installed.`, + } + } + return none +} + +export interface TrayNotifierDeps { + getLevel: () => TrayNotificationLevel + isAppFocused: () => boolean + sink: NotificationSink + openWorkspace: (workspaceId: string) => void + openWorkspaceLogs: (workspaceId: string) => void + openUpdates: () => void +} + +const NOTIFIED_CAP = 200 + +export class TrayNotifier { + private previous: Record | null = null + private notified = new Set() + private lastUpdateVersion: string | undefined + + constructor(private deps: TrayNotifierDeps) {} + + onJobsChanged(current: Record): void { + // The first snapshot only seeds state: jobs recovered or discovered at + // launch show current state and never notify. + if (this.previous === null) { + this.previous = current + for (const job of Object.values(current)) { + if (job.state === "succeeded" || job.state === "failed") + this.markNotified(job.commandId) + } + return + } + const outcomes = collectTerminalOutcomes(this.previous, current, this.notified) + this.previous = current + for (const outcome of outcomes) { + this.markNotified(outcome.commandId) + if (!outcomeNotifies(outcome, this.deps.getLevel())) continue + if (this.deps.isAppFocused()) continue + this.deps.sink({ + title: "Devsy", + body: outcomeNotificationBody(outcome), + onClick: () => + outcome.state === "failed" + ? this.deps.openWorkspaceLogs(outcome.workspaceId) + : this.deps.openWorkspace(outcome.workspaceId), + }) + } + } + + onUpdateStatus(status: UpdateStatus): void { + const decision = updateNotifies( + status, + this.deps.getLevel(), + this.lastUpdateVersion, + ) + if (!decision.notifies || !decision.version) return + this.lastUpdateVersion = decision.version + if (this.deps.isAppFocused()) return + this.deps.sink({ + title: decision.title, + body: decision.body, + onClick: () => this.deps.openUpdates(), + }) + } + + private markNotified(commandId: string): void { + this.notified.add(commandId) + if (this.notified.size > NOTIFIED_CAP) { + const oldest = this.notified.values().next().value + if (oldest !== undefined) this.notified.delete(oldest) + } + } +} diff --git a/desktop/src/main/tray-support.ts b/desktop/src/main/tray-support.ts new file mode 100644 index 000000000..db982a7ef --- /dev/null +++ b/desktop/src/main/tray-support.ts @@ -0,0 +1,50 @@ +// Linux trays need a StatusNotifierWatcher on the session bus. GNOME without +// an AppIndicator/KSNI extension has none, so auto-launching to the tray +// there would hide the app's only window. +export interface TrayHostProbeDeps { + platform?: string + getNameOwner?: (name: string) => Promise + timeoutMs?: number +} + +const WATCHER_NAME = "org.kde.StatusNotifierWatcher" +const DEFAULT_TIMEOUT_MS = 1500 + +export async function isTrayHostAvailable( + deps: TrayHostProbeDeps = {}, +): Promise { + const platform = deps.platform ?? process.platform + if (platform !== "linux") return true + try { + const getNameOwner = deps.getNameOwner ?? (await sessionNameOwner()) + const owner = await Promise.race([ + getNameOwner(WATCHER_NAME), + new Promise((_, reject) => + setTimeout( + () => reject(new Error("tray host probe timed out")), + deps.timeoutMs ?? DEFAULT_TIMEOUT_MS, + ), + ), + ]) + return typeof owner === "string" && owner.length > 0 + } catch { + return false + } +} + +async function sessionNameOwner(): Promise<(name: string) => Promise> { + const { sessionBus } = await import("dbus-next") + const bus = sessionBus() + const dbus = await bus.getProxyObject( + "org.freedesktop.DBus", + "/org/freedesktop/DBus", + ) + const iface = dbus.getInterface("org.freedesktop.DBus") + return async (name) => { + try { + return (await iface.GetNameOwner(name)) as string + } finally { + bus.disconnect() + } + } +} diff --git a/desktop/src/main/tray.ts b/desktop/src/main/tray.ts index 67471dfb5..005fbbeed 100644 --- a/desktop/src/main/tray.ts +++ b/desktop/src/main/tray.ts @@ -7,6 +7,7 @@ import { import type { WorkspaceJobs } from "./workspace-jobs.js" import { join } from "node:path" import { app, Menu, nativeImage, nativeTheme, Tray } from "electron" +import type { AppSettings } from "./app-settings.js" import type { DaemonState, Workspace } from "./state.js" import { getLastStatus, @@ -14,7 +15,14 @@ import { onUpdateStatusChanged, type UpdateStatus, } from "./updater.js" -import { isActiveWorkspaceStatus } from "./workspace-status.js" +import { + isActiveWorkspaceStatus, + normalizeWorkspaceStatus, +} from "./workspace-status.js" + +// Native menus do not scroll well, so the tray shows only the most recently +// used workspaces and links into the app for the rest. +export const TRAY_WORKSPACE_LIMIT = 5 export function buildUpdateMenuItems( status: UpdateStatus, @@ -32,79 +40,182 @@ export function buildUpdateMenuItems( return [{ label, click: onInstall }, { type: "separator" }] } +export type TrayWorkspaceState = + | "running" + | "stopped" + | "failed" + | "busy" + | "unknown" + +export function trayWorkspaceState( + workspace: Workspace, + job?: WorkspaceJob, +): TrayWorkspaceState { + if (workspaceJobBusy(job)) return "busy" + if (job?.error || job?.state === "failed") return "failed" + const status = normalizeWorkspaceStatus(workspace.status ?? "")?.toLowerCase() + if (status === "running" || status === "busy") return "running" + if (status === "stopped") return "stopped" + if (status === "failed" || status === "error") return "failed" + return "unknown" +} + +const STATE_GLYPHS: Record = { + running: "●", + stopped: "○", + failed: "✖", + busy: "◐", + unknown: "◌", +} + +const STATE_TEXT: Record = { + running: "Running", + stopped: "Stopped", + failed: "Failed", + busy: "Busy", + unknown: "Unknown", +} + +export function countRunningWorkspaces( + workspaces: Workspace[], + jobs: Record, +): number { + return workspaces.filter( + (workspace) => + isActiveWorkspaceStatus(workspace.status) || + workspaceJobBusy(jobs[workspace.id]), + ).length +} + export interface TrayMenuModel { - activeWorkspaces: Workspace[] + workspaces: Workspace[] jobs?: Record pendingStops: ReadonlySet + pendingStarts: ReadonlySet updateStatus: UpdateStatus + settings: AppSettings } export interface TrayMenuActions { showDevsy: () => void showWorkspace: (id: string) => void + showWorkspaceLogs: (id: string) => void showAllWorkspaces: () => void + showSettings: () => void + startWorkspace: (id: string) => void stopWorkspace: (id: string) => void + toggleRunAtStartup: () => void + toggleOpenToTray: () => void installUpdate: () => void quit: () => void } +// Action meaning lives in text labels because native menu-item images are +// best-effort on Windows and several Linux panels; glyphs only mark state. export function buildTrayMenuTemplate( model: TrayMenuModel, actions: TrayMenuActions, ): Electron.MenuItemConstructorOptions[] { - const active = model.activeWorkspaces - const workspaceItems: Electron.MenuItemConstructorOptions[] = active - .slice(0, 10) - .map((workspace) => { - const job = model.jobs?.[workspace.id] + const jobs = model.jobs ?? {} + const running = countRunningWorkspaces(model.workspaces, jobs) + const header: Electron.MenuItemConstructorOptions[] = [ + { + label: + running === 0 + ? "Devsy — No running workspaces" + : `Devsy — ${running} running workspace${running === 1 ? "" : "s"}`, + enabled: false, + }, + { type: "separator" }, + ] + + const shown = model.workspaces.slice(0, TRAY_WORKSPACE_LIMIT) + const workspaceItems: Electron.MenuItemConstructorOptions[] = shown.map( + (workspace) => { + const job = jobs[workspace.id] + const state = trayWorkspaceState(workspace, job) const pending = - model.pendingStops.has(workspace.id) || workspaceJobBusy(job) - const busy = workspace.status?.trim().toLowerCase() === "busy" - const disabled = pending && !workspaceJobInterruptible(job) - const label = workspaceJobLabel(job) + model.pendingStops.has(workspace.id) || + model.pendingStarts.has(workspace.id) || + state === "busy" + const jobLabel = workspaceJobLabel(job) + const submenu: Electron.MenuItemConstructorOptions[] = [ + { + label: "Open in Devsy", + click: () => actions.showWorkspace(workspace.id), + }, + ] + if (state === "running") { + const stopping = + model.pendingStops.has(workspace.id) || + (workspaceJobBusy(job) && !workspaceJobInterruptible(job)) + submenu.push({ + label: stopping ? `${jobLabel ?? "Stopping"}…` : "Stop Workspace", + enabled: !stopping, + click: stopping + ? undefined + : () => actions.stopWorkspace(workspace.id), + }) + } else if (state === "stopped") { + submenu.push({ + label: model.pendingStarts.has(workspace.id) + ? "Starting…" + : "Start Workspace", + enabled: !model.pendingStarts.has(workspace.id), + click: model.pendingStarts.has(workspace.id) + ? undefined + : () => actions.startWorkspace(workspace.id), + }) + } + if (state === "failed" || state === "busy" || state === "unknown") { + submenu.push({ + label: "View Logs", + click: () => actions.showWorkspaceLogs(workspace.id), + }) + } return { - label: `${workspace.id}${label ? ` — ${label}` : busy && !pending ? " — Busy" : ""}`, - submenu: [ - { - label: "Open in Devsy", - click: () => actions.showWorkspace(workspace.id), - }, - { - label: disabled - ? `${workspaceJobLabel(job) ?? "Stopping"}…` - : "Stop Workspace", - enabled: !disabled, - click: disabled - ? undefined - : () => actions.stopWorkspace(workspace.id), - }, - ], + label: `${STATE_GLYPHS[state]} ${workspace.id} — ${jobLabel && (state === "busy" || state === "failed") ? jobLabel : STATE_TEXT[state]}`, + submenu, } - }) - - const activeSubmenu: Electron.MenuItemConstructorOptions[] = - workspaceItems.length > 0 - ? [ - ...workspaceItems, - ...(active.length > 10 ? [{ type: "separator" as const }] : []), - { - label: "Show All Workspaces…", - click: actions.showAllWorkspaces, - }, - ] - : [ - { label: "No Active Workspaces", enabled: false }, - { type: "separator" }, - { - label: "Open Workspaces in Devsy…", - click: actions.showAllWorkspaces, - }, - ] + }, + ) + if (workspaceItems.length === 0) { + workspaceItems.push({ label: "No Workspaces", enabled: false }) + } else if (model.workspaces.length > TRAY_WORKSPACE_LIMIT) { + workspaceItems.push( + { type: "separator" }, + { + label: `View All ${model.workspaces.length} Workspaces in Devsy`, + click: actions.showAllWorkspaces, + }, + ) + } return [ - { label: "Show Devsy", click: actions.showDevsy }, + ...header, + ...workspaceItems, { type: "separator" }, - { label: `Active Workspaces (${active.length})`, submenu: activeSubmenu }, + { label: "Open Devsy", click: actions.showDevsy }, + { + label: "Preferences", + submenu: [ + { + label: "Run at Startup", + type: "checkbox", + checked: model.settings.runAtStartup, + click: actions.toggleRunAtStartup, + }, + { + label: "Open to Tray on Startup", + type: "checkbox", + checked: model.settings.openToTrayOnStartup, + enabled: model.settings.runAtStartup, + click: actions.toggleOpenToTray, + }, + { type: "separator" }, + { label: "Open Settings…", click: actions.showSettings }, + ], + }, ...buildUpdateMenuItems(model.updateStatus, actions.installUpdate), { type: "separator" }, { label: "Quit Devsy", click: actions.quit }, @@ -115,6 +226,10 @@ interface TrayDeps { workspaceJobs?: WorkspaceJobs state: DaemonState showDevsy: (route?: string) => void + getSettings: () => AppSettings + toggleRunAtStartup: () => void + toggleOpenToTray: () => void + startWorkspace: (workspaceId: string) => Promise stopWorkspace: (workspaceId: string) => Promise refreshWorkspace: (workspaceId: string) => Promise refreshWorkspaces: () => Promise @@ -123,6 +238,7 @@ interface TrayDeps { export class AppTray { private tray: Tray | null = null private pendingStops = new Set() + private pendingStarts = new Set() private unsubscribeWorkspaceState: (() => void) | null = null private unsubscribeWorkspaceJobs: (() => void) | null = null private unsubscribeUpdateStatus: (() => void) | null = null @@ -135,7 +251,7 @@ export class AppTray { setup(): void { if (this.tray) return this.tray = new Tray(this.createTrayIcon()) - this.tray.setToolTip("Devsy — No active workspaces") + this.tray.setToolTip("Devsy — No running workspaces") this.unsubscribeWorkspaceState = this.deps.state.onWorkspacesChange(() => this.rebuildMenu(), ) @@ -161,53 +277,35 @@ export class AppTray { nativeTheme.off("updated", this.onThemeUpdated) } this.pendingStops.clear() + this.pendingStarts.clear() this.tray?.destroy() this.tray = null } - private createTrayIcon(): Electron.NativeImage { - const trayDir = join(__dirname, "../../resources/tray") - if (process.platform === "darwin") { - const icon = nativeImage.createFromPath( - join(trayDir, "icon-trayTemplate.png"), - ) - icon.setTemplateImage(true) - return icon - } - const variant = nativeTheme.shouldUseDarkColors ? "dark" : "light" - return nativeImage.createFromPath(join(trayDir, `icon-tray-${variant}.png`)) - } - - private rebuildMenu(): void { + rebuildMenu(): void { if (!this.tray) return const jobs = this.deps.workspaceJobs?.snapshot() ?? {} - const workspaces = this.deps.state.workspaceList() - const activeWorkspaces = workspaces.filter( - (workspace) => - isActiveWorkspaceStatus(workspace.status) || - workspaceJobBusy(jobs[workspace.id]), - ) - for (const [id, job] of Object.entries(jobs)) { - if ( - workspaceJobBusy(job) && - !workspaces.some((workspace) => workspace.id === id) - ) - activeWorkspaces.push({ id }) - } - const template = buildTrayMenuTemplate( { - activeWorkspaces, + workspaces: this.deps.state.workspaceList(), pendingStops: this.pendingStops, - jobs: this.deps.workspaceJobs?.snapshot(), + pendingStarts: this.pendingStarts, + jobs, updateStatus: getLastStatus(), + settings: this.deps.getSettings(), }, { showDevsy: () => this.deps.showDevsy(), showWorkspace: (id) => this.deps.showDevsy(`/workspaces/${encodeURIComponent(id)}`), + showWorkspaceLogs: (id) => + this.deps.showDevsy(`/workspaces/${encodeURIComponent(id)}?tab=logs`), showAllWorkspaces: () => this.deps.showDevsy("/workspaces"), + showSettings: () => this.deps.showDevsy("/settings"), + startWorkspace: (id) => void this.startFromTray(id), stopWorkspace: (id) => void this.stopFromTray(id), + toggleRunAtStartup: () => this.deps.toggleRunAtStartup(), + toggleOpenToTray: () => this.deps.toggleOpenToTray(), installUpdate: () => void installUpdate().catch((error) => console.warn("[tray] failed to install update:", error), @@ -216,12 +314,32 @@ export class AppTray { }, ) this.tray.setContextMenu(Menu.buildFromTemplate(template)) - const count = activeWorkspaces.length + const running = countRunningWorkspaces( + this.deps.state.workspaceList(), + jobs, + ) this.tray.setToolTip( - `Devsy — ${count} active workspace${count === 1 ? "" : "s"}`, + running === 0 + ? "Devsy — No running workspaces" + : `Devsy — ${running} running workspace${running === 1 ? "" : "s"}`, ) } + private async startFromTray(workspaceId: string): Promise { + if (this.pendingStarts.has(workspaceId)) return + this.pendingStarts.add(workspaceId) + this.rebuildMenu() + try { + await this.deps.startWorkspace(workspaceId) + } catch (error) { + console.warn(`[tray] failed to start workspace ${workspaceId}:`, error) + } finally { + await this.refreshAfterAction(workspaceId) + this.pendingStarts.delete(workspaceId) + this.rebuildMenu() + } + } + private async stopFromTray(workspaceId: string): Promise { if (this.pendingStops.has(workspaceId)) return this.pendingStops.add(workspaceId) @@ -231,21 +349,35 @@ export class AppTray { } catch (error) { console.warn(`[tray] failed to stop workspace ${workspaceId}:`, error) } finally { - try { - await this.deps.refreshWorkspace(workspaceId) - } catch (error) { - console.warn( - `[tray] failed to refresh workspace ${workspaceId}:`, - error, - ) - } - try { - await this.deps.refreshWorkspaces() - } catch (error) { - console.warn("[tray] failed to refresh workspaces:", error) - } + await this.refreshAfterAction(workspaceId) this.pendingStops.delete(workspaceId) this.rebuildMenu() } } + + private async refreshAfterAction(workspaceId: string): Promise { + try { + await this.deps.refreshWorkspace(workspaceId) + } catch (error) { + console.warn(`[tray] failed to refresh workspace ${workspaceId}:`, error) + } + try { + await this.deps.refreshWorkspaces() + } catch (error) { + console.warn("[tray] failed to refresh workspaces:", error) + } + } + + private createTrayIcon(): Electron.NativeImage { + const trayDir = join(__dirname, "../../resources/tray") + if (process.platform === "darwin") { + const icon = nativeImage.createFromPath( + join(trayDir, "icon-trayTemplate.png"), + ) + icon.setTemplateImage(true) + return icon + } + const variant = nativeTheme.shouldUseDarkColors ? "dark" : "light" + return nativeImage.createFromPath(join(trayDir, `icon-tray-${variant}.png`)) + } } diff --git a/desktop/src/renderer/src/App.svelte b/desktop/src/renderer/src/App.svelte index f23e58901..e43ade120 100644 --- a/desktop/src/renderer/src/App.svelte +++ b/desktop/src/renderer/src/App.svelte @@ -16,7 +16,9 @@ import { initContexts, destroyContexts } from "$lib/stores/contexts.js" import { initSecrets } from "$lib/stores/secrets.js" import { initEnv } from "$lib/stores/env.js" import { + initDesktopSettingsListener, initSettings, + syncDesktopSettingsFromMain, syncAutoUpdateFromMain, autoUpdate, } from "$lib/stores/settings.js" @@ -73,6 +75,7 @@ const routes = { } let destroySettings: (() => void) | undefined +let unsubDesktopSettings: (() => void) | undefined let updateDialogOpen = $state(false) let unsubscribeToasts: (() => void) | null = null @@ -175,6 +178,10 @@ onMount(async () => { await initUpdateStore() await syncAutoUpdateFromMain() + await syncDesktopSettingsFromMain() + const desktopSettingsUnlisten = await initDesktopSettingsListener() + if (destroyed) desktopSettingsUnlisten() + else unsubDesktopSettings = desktopSettingsUnlisten unsubscribeToasts = initUpdateToasts(() => { let value = true autoUpdate.subscribe((v) => (value = v))() @@ -185,6 +192,7 @@ onMount(async () => { onDestroy(() => { destroyed = true + unsubDesktopSettings?.() unsubscribeToasts?.() disposeUpdateStore() stopSessionTracking?.() diff --git a/desktop/src/renderer/src/lib/ipc/commands.ts b/desktop/src/renderer/src/lib/ipc/commands.ts index d735341f1..5ddc27e7f 100644 --- a/desktop/src/renderer/src/lib/ipc/commands.ts +++ b/desktop/src/renderer/src/lib/ipc/commands.ts @@ -441,3 +441,21 @@ export function analyticsTrack( ): void { invoke("analytics_track", { name, properties }).catch(() => {}) } + +// Desktop app settings (owned by the main process) +export async function getAppSettings(): Promise< + import("$shared/app-settings.js").AppSettingsState +> { + return invoke( + "get_app_settings", + ) +} + +export async function setAppSettings( + patch: Partial, +): Promise { + return invoke( + "set_app_settings", + { patch }, + ) +} diff --git a/desktop/src/renderer/src/lib/ipc/events.ts b/desktop/src/renderer/src/lib/ipc/events.ts index b56bd440c..26d3d0802 100644 --- a/desktop/src/renderer/src/lib/ipc/events.ts +++ b/desktop/src/renderer/src/lib/ipc/events.ts @@ -92,6 +92,7 @@ export const EVENT_NAMES = { COMMAND_PROGRESS: "command-progress", WORKSPACE_STATUS: "workspace-status", UPDATE_STATUS: "update-status", + APP_SETTINGS_CHANGED: "app-settings-changed", } as const interface WorkspacesPayload { @@ -182,3 +183,14 @@ export function onUpdateStatus( callback(event.payload) }) } + +export function onAppSettingsChanged( + callback: (state: import("$shared/app-settings.js").AppSettingsState) => void, +): Promise { + return listen( + EVENT_NAMES.APP_SETTINGS_CHANGED, + (event) => { + callback(event.payload) + }, + ) +} diff --git a/desktop/src/renderer/src/lib/ipc/mock.ts b/desktop/src/renderer/src/lib/ipc/mock.ts index aebe97087..1bf0771f0 100644 --- a/desktop/src/renderer/src/lib/ipc/mock.ts +++ b/desktop/src/renderer/src/lib/ipc/mock.ts @@ -107,6 +107,27 @@ const MACHINES: Machine[] = [ }, ] +let MOCK_APP_SETTINGS = { + settings: { + runAtStartup: false, + openToTrayOnStartup: false, + trayNotifications: "failures", + }, + startup: { applied: true, enabled: false, status: "disabled" }, +} as { + settings: { + runAtStartup: boolean + openToTrayOnStartup: boolean + trayNotifications: "off" | "failures" | "all" + } + startup: { + applied: boolean + enabled: boolean + status: "enabled" | "disabled" | "denied" | "unavailable" | "error" + detail?: string + } +} + const CONTEXTS: Context[] = [{ name: "default" }, { name: "staging" }] const SSH_KEYS: SshKeyInfo[] = [ @@ -283,6 +304,30 @@ const COMMANDS: Record = { image_inspect_platforms: () => ["linux/amd64", "linux/arm64"], + // App settings (startup + tray notifications) + get_app_settings: () => MOCK_APP_SETTINGS, + set_app_settings: (args) => { + const patch = (args?.patch ?? {}) as Partial + MOCK_APP_SETTINGS = { + settings: { + ...MOCK_APP_SETTINGS.settings, + ...patch, + openToTrayOnStartup: + (patch.runAtStartup ?? MOCK_APP_SETTINGS.settings.runAtStartup) === false + ? false + : (patch.openToTrayOnStartup ?? MOCK_APP_SETTINGS.settings.openToTrayOnStartup), + }, + startup: { + applied: true, + enabled: patch.runAtStartup ?? MOCK_APP_SETTINGS.settings.runAtStartup, + status: (patch.runAtStartup ?? MOCK_APP_SETTINGS.settings.runAtStartup) + ? ("enabled" as const) + : ("disabled" as const), + }, + } + return MOCK_APP_SETTINGS + }, + // Release channel get_release_channel: () => "stable", set_release_channel: () => undefined, diff --git a/desktop/src/renderer/src/lib/stores/settings.ts b/desktop/src/renderer/src/lib/stores/settings.ts index bce06b10d..52626c3ac 100644 --- a/desktop/src/renderer/src/lib/stores/settings.ts +++ b/desktop/src/renderer/src/lib/stores/settings.ts @@ -1,5 +1,17 @@ import { writable } from "svelte/store" -import { getAutoDownload, setAutoDownload } from "$lib/ipc/commands.js" +import { + getAppSettings, + getAutoDownload, + setAppSettings, + setAutoDownload, +} from "$lib/ipc/commands.js" +import { onAppSettingsChanged } from "$lib/ipc/events.js" +import type { + AppSettings, + AppSettingsState, + StartupStatus, + TrayNotificationLevel, +} from "$shared/app-settings.js" const browser = typeof window !== "undefined" @@ -347,6 +359,55 @@ export function parseContextOptions( } } +// Main owns these settings; a renderer write in flight wins over inbound updates. +export const runAtStartup = writable(false) +export const openToTrayOnStartup = writable(false) +export const trayNotifications = writable("failures") +export const startupStatus = writable(null) + +let desktopSettingsWriteInFlight = false + +function applyAppSettingsState(state: AppSettingsState): void { + runAtStartup.set(state.settings.runAtStartup) + openToTrayOnStartup.set(state.settings.openToTrayOnStartup) + trayNotifications.set(state.settings.trayNotifications) + startupStatus.set(state.startup) +} + +export async function syncDesktopSettingsFromMain(): Promise { + try { + const state = await getAppSettings() + if (desktopSettingsWriteInFlight) return + applyAppSettingsState(state) + } catch (err) { + console.warn("[settings] getAppSettings failed:", err) + } +} + +export async function updateDesktopSettings( + patch: Partial, +): Promise { + desktopSettingsWriteInFlight = true + try { + applyAppSettingsState(await setAppSettings(patch)) + } catch (err) { + console.warn("[settings] setAppSettings failed:", err) + try { + applyAppSettingsState(await getAppSettings()) + } catch {} + } finally { + desktopSettingsWriteInFlight = false + } +} + +export async function initDesktopSettingsListener(): Promise<() => void> { + const unlisten = await onAppSettingsChanged((state) => { + if (desktopSettingsWriteInFlight) return + applyAppSettingsState(state) + }) + return unlisten +} + // ── Init ──────────────────────────────────────────────────────────── export function initSettings() { diff --git a/desktop/src/renderer/src/pages/SettingsPage.svelte b/desktop/src/renderer/src/pages/SettingsPage.svelte index e8d016acb..8bf4616de 100644 --- a/desktop/src/renderer/src/pages/SettingsPage.svelte +++ b/desktop/src/renderer/src/pages/SettingsPage.svelte @@ -19,6 +19,12 @@ import { localOptions as localOptionsStore, loadLocalOptions, saveLocalOption, + runAtStartup, + openToTrayOnStartup, + trayNotifications, + startupStatus, + syncDesktopSettingsFromMain, + updateDesktopSettings, } from "$lib/stores/settings.js" import type { Theme, @@ -26,6 +32,7 @@ import type { LocalOptions, OnBuildFailure, } from "$lib/stores/settings.js" +import type { TrayNotificationLevel } from "$shared/app-settings.js" import * as Select from "$lib/components/ui/select/index.js" import UpdatesPanel from "$lib/components/update/UpdatesPanel.svelte" import { Skeleton } from "$lib/components/ui/skeleton/index.js" @@ -121,10 +128,17 @@ const shortcuts = [ { keys: "Escape", action: "Close dialogs and palette" }, ] +const NOTIFICATION_OPTIONS: { value: TrayNotificationLevel; label: string }[] = [ + { value: "off", label: "Off" }, + { value: "failures", label: "Failures only" }, + { value: "all", label: "All terminal outcomes" }, +] + onMount(() => { local = loadLocalOptions() localOptionsStore.set(local) loading = false + void syncDesktopSettingsFromMain() }) function saveLocal(key: keyof LocalOptions, value: string | boolean) { @@ -273,6 +287,68 @@ function toggleLocal(key: keyof LocalOptions) { {/if} + +
+

Startup

+
+
+
+ +

Launch Devsy automatically after you sign in

+
+ updateDesktopSettings({ runAtStartup: v })} + disabled={loading} + /> +
+ + {#if $startupStatus?.status === "denied" || $startupStatus?.status === "error"} +

{$startupStatus.detail}

+ {/if} + +
+
+ +

Start in the system tray without opening a window. Applies only when Devsy starts automatically; clicking Devsy yourself always opens the window.

+
+ updateDesktopSettings({ openToTrayOnStartup: v })} + disabled={loading || !$runAtStartup} + /> +
+
+
+ +
+

Notifications

+
+
+
+ +

System notifications when workspace start and stop operations finish

+
+ { + if (v) updateDesktopSettings({ trayNotifications: v as TrayNotificationLevel }) + }} + > + + {NOTIFICATION_OPTIONS.find((o) => o.value === $trayNotifications)?.label ?? "Failures only"} + + + {#each NOTIFICATION_OPTIONS as o (o.value)} + + {/each} + + +
+
+
+

Appearance

diff --git a/desktop/src/renderer/src/pages/SettingsPage.test.ts b/desktop/src/renderer/src/pages/SettingsPage.test.ts index aae0ce7c5..a1a2a161e 100644 --- a/desktop/src/renderer/src/pages/SettingsPage.test.ts +++ b/desktop/src/renderer/src/pages/SettingsPage.test.ts @@ -12,13 +12,36 @@ describe("SettingsPage layout", () => { document.body.innerHTML = "" }) + it("renders the startup toggles with the dependent open-to-tray switch disabled", async () => { + render(SettingsPage) + + const section = document.querySelector("#startup") + expect(section).toBeTruthy() + const switches = section!.querySelectorAll('[role="switch"]') + expect(switches).toHaveLength(2) + const [runAtStartup, openToTray] = switches + expect(runAtStartup.hasAttribute("disabled")).toBe(false) + // The mock main process defaults to runAtStartup off, so the dependent + // toggle must be disabled. + await vi.waitFor(() => { + expect(openToTray.hasAttribute("disabled")).toBe(true) + }) + }) + + it("renders the notification level select", () => { + render(SettingsPage) + + expect(screen.getByText("Workspace Notifications")).toBeTruthy() + expect(screen.getByText("Failures only")).toBeTruthy() + }) + it("renders all settings sections as one page without section navigation links", () => { render(SettingsPage) expect(screen.queryByRole("navigation", { name: "Settings sections" })).toBeNull() expect(document.querySelectorAll('a[href^="#"]')).toHaveLength(0) - for (const name of ["General", "Appearance", "Updates", "Advanced"]) { + for (const name of ["General", "Startup", "Notifications", "Appearance", "Updates", "Advanced"]) { expect(screen.getByRole("heading", { name, level: 2 })).toBeTruthy() } }) diff --git a/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte b/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte index d4c585ec5..745864079 100644 --- a/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte +++ b/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte @@ -297,6 +297,10 @@ onMount(async () => { void reconcileRecoveryFromStatus() const qs = new URLSearchParams($querystring ?? "") + const tab = qs.get("tab") + if (tab === "logs" || tab === "terminal" || tab === "overview") { + activeTab = tab + } const action = qs.get("action") if (action === "open-ide" || action === "start") { // Clear query param so refresh doesn't re-trigger diff --git a/desktop/src/shared/app-settings.ts b/desktop/src/shared/app-settings.ts new file mode 100644 index 000000000..bbc9642c5 --- /dev/null +++ b/desktop/src/shared/app-settings.ts @@ -0,0 +1,19 @@ +export type TrayNotificationLevel = "off" | "failures" | "all" + +export interface AppSettings { + runAtStartup: boolean + openToTrayOnStartup: boolean + trayNotifications: TrayNotificationLevel +} + +export interface StartupStatus { + applied: boolean + enabled: boolean + status: "enabled" | "disabled" | "denied" | "unavailable" | "error" + detail?: string +} + +export interface AppSettingsState { + settings: AppSettings + startup: StartupStatus +} From 86b7c98385bad46fb5ce96eccd8859c8fbb68d07 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 17:45:43 -0600 Subject: [PATCH 02/43] fix(desktop): restore autostart when settings persistence fails Signed-off-by: Samuel K --- desktop/src/main/settings-service.ts | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/desktop/src/main/settings-service.ts b/desktop/src/main/settings-service.ts index 13c044ab2..1ac215c6f 100644 --- a/desktop/src/main/settings-service.ts +++ b/desktop/src/main/settings-service.ts @@ -5,6 +5,10 @@ import { } from "./app-settings.js" import type { AutostartApplyResult } from "./autostart.js" +function describe(err: unknown): string { + return err instanceof Error ? err.message : String(err) +} + export interface SettingsUpdateResult { settings: AppSettings startup: AutostartApplyResult @@ -60,7 +64,22 @@ export class SettingsService { return result } } - this.deps.store.save(next) + try { + this.deps.store.save(next) + } catch (err) { + if (startupRelevant) { + // The OS login item already moved to the new value; restore it so a + // failed write never leaves the platform disagreeing with the store. + try { + await this.deps.applyAutostart(current) + } catch (compensationErr) { + throw new Error( + `failed to persist settings: ${describe(err)}; autostart rollback also failed: ${describe(compensationErr)}`, + ) + } + } + throw err + } const result = { settings: next, startup } this.deps.onChanged(result) return result From b32228e942a5acb1243ab25a294ccc326115b84f Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 17:46:11 -0600 Subject: [PATCH 03/43] test(desktop): cover autostart rollback on settings write failure Signed-off-by: Samuel K --- .../main/__tests__/settings-service.test.ts | 45 ++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/desktop/src/main/__tests__/settings-service.test.ts b/desktop/src/main/__tests__/settings-service.test.ts index e4a89ca7a..6586912df 100644 --- a/desktop/src/main/__tests__/settings-service.test.ts +++ b/desktop/src/main/__tests__/settings-service.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync } from "node:fs" +import { mkdtempSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" @@ -74,6 +74,49 @@ describe("SettingsService", () => { expect(store.get().runAtStartup).toBe(false) }) + it("restores the OS login item when persisting new settings fails", async () => { + const blocker = join(dir, "blocked") + writeFileSync(blocker, "x") + const badStore = new AppSettingsStore(join(blocker, "app-settings.json")) + badStore.load() + const applied: boolean[] = [] + const onChanged = vi.fn() + const svc = new SettingsService({ + store: badStore, + applyAutostart: async (s) => { + applied.push(s.runAtStartup) + return s.runAtStartup ? enabled : disabled + }, + currentAutostartEnabled: () => false, + onChanged, + }) + await expect(svc.update({ runAtStartup: true })).rejects.toThrow() + expect(applied).toEqual([true, false]) + expect(badStore.get().runAtStartup).toBe(false) + expect(onChanged).not.toHaveBeenCalled() + }) + + it("reports both failures when the autostart rollback also fails", async () => { + const blocker = join(dir, "blocked") + writeFileSync(blocker, "x") + const badStore = new AppSettingsStore(join(blocker, "app-settings.json")) + badStore.load() + let calls = 0 + const svc = new SettingsService({ + store: badStore, + applyAutostart: async () => { + calls += 1 + if (calls > 1) throw new Error("rollback boom") + return enabled + }, + currentAutostartEnabled: () => false, + onChanged: () => {}, + }) + await expect(svc.update({ runAtStartup: true })).rejects.toThrow( + /autostart rollback also failed: rollback boom/, + ) + }) + it("reapplies autostart when the dependent toggle changes", async () => { const calls: boolean[] = [] const { svc } = service(async (s) => { From 7c00886ab582ff34b0967c379683257579a4fc8d Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 17:46:42 -0600 Subject: [PATCH 04/43] chore(desktop): drop replaceable comment in ipc commands Signed-off-by: Samuel K --- desktop/src/renderer/src/lib/ipc/commands.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/desktop/src/renderer/src/lib/ipc/commands.ts b/desktop/src/renderer/src/lib/ipc/commands.ts index 5ddc27e7f..ddb6c924b 100644 --- a/desktop/src/renderer/src/lib/ipc/commands.ts +++ b/desktop/src/renderer/src/lib/ipc/commands.ts @@ -442,7 +442,6 @@ export function analyticsTrack( invoke("analytics_track", { name, properties }).catch(() => {}) } -// Desktop app settings (owned by the main process) export async function getAppSettings(): Promise< import("$shared/app-settings.js").AppSettingsState > { From f57b7f84bd6c2a3258d9ff3809e98bb216f69e07 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 17:47:10 -0600 Subject: [PATCH 05/43] chore(desktop): drop replaceable comment in ipc mock Signed-off-by: Samuel K --- desktop/src/renderer/src/lib/ipc/mock.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/desktop/src/renderer/src/lib/ipc/mock.ts b/desktop/src/renderer/src/lib/ipc/mock.ts index 1bf0771f0..140a54d50 100644 --- a/desktop/src/renderer/src/lib/ipc/mock.ts +++ b/desktop/src/renderer/src/lib/ipc/mock.ts @@ -304,7 +304,6 @@ const COMMANDS: Record = { image_inspect_platforms: () => ["linux/amd64", "linux/arm64"], - // App settings (startup + tray notifications) get_app_settings: () => MOCK_APP_SETTINGS, set_app_settings: (args) => { const patch = (args?.patch ?? {}) as Partial From eb27b0ecf012f9e95d30bffab5599022b7a5e48d Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 17:47:39 -0600 Subject: [PATCH 06/43] test(desktop): drop redundant mock default comment Signed-off-by: Samuel K --- desktop/src/renderer/src/pages/SettingsPage.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/desktop/src/renderer/src/pages/SettingsPage.test.ts b/desktop/src/renderer/src/pages/SettingsPage.test.ts index a1a2a161e..9aad8c1a7 100644 --- a/desktop/src/renderer/src/pages/SettingsPage.test.ts +++ b/desktop/src/renderer/src/pages/SettingsPage.test.ts @@ -21,8 +21,6 @@ describe("SettingsPage layout", () => { expect(switches).toHaveLength(2) const [runAtStartup, openToTray] = switches expect(runAtStartup.hasAttribute("disabled")).toBe(false) - // The mock main process defaults to runAtStartup off, so the dependent - // toggle must be disabled. await vi.waitFor(() => { expect(openToTray.hasAttribute("disabled")).toBe(true) }) From ee9da1d620b543e216f0cf3c4c080e792ec1db1d Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 18:22:06 -0600 Subject: [PATCH 07/43] fix(desktop): verify autostart rollback restores the previous setting Signed-off-by: Samuel K --- desktop/src/main/settings-service.ts | 41 +++++++++++++++++++++------- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/desktop/src/main/settings-service.ts b/desktop/src/main/settings-service.ts index 1ac215c6f..c76124344 100644 --- a/desktop/src/main/settings-service.ts +++ b/desktop/src/main/settings-service.ts @@ -58,30 +58,51 @@ export class SettingsService { // Persist and broadcast the reverted state so Settings and the tray // converge on Run at startup off; the denial detail stays in startup. const reverted = { ...next, runAtStartup: false, openToTrayOnStartup: false } - this.deps.store.save(reverted) + await this.persistWithAutostartRollback(reverted, current) const result = { settings: reverted, startup } this.deps.onChanged(result) return result } } + await this.persistWithAutostartRollback(next, current, startupRelevant) + const result = { settings: next, startup } + this.deps.onChanged(result) + return result + } + + private async persistWithAutostartRollback( + target: AppSettings, + current: AppSettings, + startupRelevant = true, + ): Promise { try { - this.deps.store.save(next) + this.deps.store.save(target) } catch (err) { if (startupRelevant) { - // The OS login item already moved to the new value; restore it so a - // failed write never leaves the platform disagreeing with the store. + // The platform login item may already reflect the failed change, so + // reapply the previous settings to keep it agreeing with the store. + let rollback: AutostartApplyResult | undefined + let rollbackError: unknown try { - await this.deps.applyAutostart(current) - } catch (compensationErr) { + rollback = await this.deps.applyAutostart(current) + } catch (err2) { + rollbackError = err2 + } + const restored = + rollbackError === undefined && + rollback?.applied === true && + rollback.enabled === current.runAtStartup + if (!restored) { + const reason = + rollbackError !== undefined + ? describe(rollbackError) + : `${rollback?.status}${rollback?.detail ? ` (${rollback.detail})` : ""}` throw new Error( - `failed to persist settings: ${describe(err)}; autostart rollback also failed: ${describe(compensationErr)}`, + `failed to persist settings: ${describe(err)}; autostart rollback also failed: ${reason}`, ) } } throw err } - const result = { settings: next, startup } - this.deps.onChanged(result) - return result } } From 5d82d1454c587ef18a853386a0380699e51b7696 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 18:22:35 -0600 Subject: [PATCH 08/43] test(desktop): cover rollback result checks and reverted persistence Signed-off-by: Samuel K --- .../main/__tests__/settings-service.test.ts | 52 +++++++++++++++++-- 1 file changed, 48 insertions(+), 4 deletions(-) diff --git a/desktop/src/main/__tests__/settings-service.test.ts b/desktop/src/main/__tests__/settings-service.test.ts index 6586912df..36310219e 100644 --- a/desktop/src/main/__tests__/settings-service.test.ts +++ b/desktop/src/main/__tests__/settings-service.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" @@ -102,19 +102,63 @@ describe("SettingsService", () => { const badStore = new AppSettingsStore(join(blocker, "app-settings.json")) badStore.load() let calls = 0 + const onChanged = vi.fn() const svc = new SettingsService({ store: badStore, applyAutostart: async () => { calls += 1 - if (calls > 1) throw new Error("rollback boom") + if (calls > 1) + return { + applied: false, + enabled: true, + status: "error", + detail: "rollback boom", + } return enabled }, currentAutostartEnabled: () => false, - onChanged: () => {}, + onChanged, }) await expect(svc.update({ runAtStartup: true })).rejects.toThrow( - /autostart rollback also failed: rollback boom/, + /autostart rollback also failed: error \(rollback boom\)/, ) + expect(onChanged).not.toHaveBeenCalled() + }) + + it("restores the previous autostart when reverted persistence fails", async () => { + const sub = join(dir, "sub") + mkdirSync(sub) + const flakyStore = new AppSettingsStore(join(sub, "app-settings.json")) + flakyStore.load() + flakyStore.save({ ...DEFAULT_APP_SETTINGS, runAtStartup: true }) + rmSync(sub, { recursive: true, force: true }) + writeFileSync(sub, "x") + const appliedTray: boolean[] = [] + const onChanged = vi.fn() + const svc = new SettingsService({ + store: flakyStore, + applyAutostart: async (s) => { + appliedTray.push(s.openToTrayOnStartup) + if (appliedTray.length === 1) + return { + applied: false, + enabled: false, + status: "denied", + detail: "denied by the system", + } + return s.runAtStartup ? enabled : disabled + }, + currentAutostartEnabled: () => true, + onChanged, + }) + await expect(svc.update({ openToTrayOnStartup: true })).rejects.toThrow() + expect(appliedTray).toEqual([true, false]) + expect(flakyStore.get()).toEqual({ + runAtStartup: true, + openToTrayOnStartup: false, + trayNotifications: "failures", + }) + expect(onChanged).not.toHaveBeenCalled() }) it("reapplies autostart when the dependent toggle changes", async () => { From 5e72bc2a0630ba911a1cf1124c2164e8d818dbb3 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 22:51:28 -0600 Subject: [PATCH 09/43] fix(desktop): resolve deep review defects --- desktop/src/main/__tests__/autostart.test.ts | 61 +++++++++++++ .../main/__tests__/portal-background.test.ts | 91 +++++++++++++++++++ .../main/__tests__/settings-service.test.ts | 49 ++++++++++ .../main/__tests__/tray-notifications.test.ts | 17 +++- .../src/main/__tests__/workspace-jobs.test.ts | 18 ++++ desktop/src/main/autostart.ts | 28 ++++-- desktop/src/main/index.ts | 19 ++-- desktop/src/main/portal-background.ts | 60 ++++++------ desktop/src/main/settings-service.ts | 23 ++++- desktop/src/main/tray-notifications.ts | 19 ++-- desktop/src/main/tray-support.ts | 39 ++++---- desktop/src/main/workspace-jobs.ts | 4 +- desktop/src/renderer/src/App.svelte | 2 +- .../src/renderer/src/lib/stores/settings.ts | 45 +++++---- .../src/pages/WorkspaceDetailPage.svelte | 6 ++ 15 files changed, 388 insertions(+), 93 deletions(-) create mode 100644 desktop/src/main/__tests__/autostart.test.ts create mode 100644 desktop/src/main/__tests__/portal-background.test.ts diff --git a/desktop/src/main/__tests__/autostart.test.ts b/desktop/src/main/__tests__/autostart.test.ts new file mode 100644 index 000000000..529ea7f27 --- /dev/null +++ b/desktop/src/main/__tests__/autostart.test.ts @@ -0,0 +1,61 @@ +import { mkdtempSync, readFileSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { afterEach, describe, expect, it, vi } from "vitest" + +const { getLoginItemSettings } = vi.hoisted(() => ({ + getLoginItemSettings: vi.fn(() => ({ openAtLogin: false })), +})) +vi.mock("electron", () => ({ + app: { + getPath: () => "/tmp", + isPackaged: true, + getLoginItemSettings, + }, +})) + +import { applyAutostart, readAutostartEnabled } from "../autostart.js" + +describe("Linux autostart", () => { + let dir: string | undefined + + afterEach(() => { + if (dir) rmSync(dir, { recursive: true, force: true }) + dir = undefined + getLoginItemSettings.mockClear() + }) + + it("uses APPIMAGE and XDG_CONFIG_HOME for the desktop entry", async () => { + dir = mkdtempSync(join(tmpdir(), "devsy-autostart-")) + await applyAutostart( + { runAtStartup: true, openToTrayOnStartup: true, trayNotifications: "all" }, + { + platform: "linux", + isFlatpak: false, + execPath: "/tmp/mount/devsy", + appImagePath: "/opt/Devsy.AppImage", + homeDir: "/home/test", + xdgConfigHome: dir, + packaged: true, + }, + ) + expect(readFileSync(join(dir, "autostart", "devsy.desktop"), "utf8")).toContain( + 'Exec="/opt/Devsy.AppImage" "--opened-at-login"', + ) + }) + + it("passes the autostart argument when reading Windows login status", () => { + expect( + readAutostartEnabled({ + platform: "win32", + isFlatpak: false, + execPath: "devsy.exe", + homeDir: "/home/test", + packaged: true, + }), + ).toBe(false) + expect(getLoginItemSettings).toHaveBeenCalledWith({ + args: ["--opened-at-login"], + }) + }) +}) diff --git a/desktop/src/main/__tests__/portal-background.test.ts b/desktop/src/main/__tests__/portal-background.test.ts new file mode 100644 index 000000000..d4472415f --- /dev/null +++ b/desktop/src/main/__tests__/portal-background.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it, vi } from "vitest" + +const { bus, requestBackground } = vi.hoisted(() => { + const listeners = new Set<(message: unknown) => void>() + const value = { + on(_event: string, listener: (message: unknown) => void) { + listeners.add(listener) + return value + }, + removeListener(_event: string, listener: (message: unknown) => void) { + listeners.delete(listener) + return value + }, + emit(_event: string, message: unknown) { + for (const listener of listeners) listener(message) + }, + listenerCount: (_event: string) => listeners.size, + getProxyObject: vi.fn(), + disconnect: vi.fn(), + } as { + on: (event: string, listener: (message: unknown) => void) => typeof value + removeListener: (event: string, listener: (message: unknown) => void) => typeof value + emit: (event: string, message: unknown) => void + listenerCount: (event: string) => number + getProxyObject: ReturnType + disconnect: ReturnType + } + return { bus: value, requestBackground: vi.fn() } +}) + +vi.mock("dbus-next", () => ({ + sessionBus: () => bus, + Variant: class { + constructor( + public signature: string, + public value: unknown, + ) {} + }, +})) + +import { parsePortalResponse, requestPortalBackground } from "../portal-background.js" + +describe("portal background requests", () => { + it("parses denial and cancellation responses as disabled", () => { + expect(parsePortalResponse(1, undefined)).toEqual({ + background: false, + autostart: false, + }) + }) + + it("subscribes before RequestBackground and accepts its returned path", async () => { + const requestPath = "/org/freedesktop/portal/desktop/request/actual" + bus.getProxyObject.mockImplementation(async (_name: string, path: string) => { + if (path.endsWith("/desktop")) { + return { + getInterface: () => ({ RequestBackground: requestBackground }), + } + } + throw new Error(`unexpected introspection: ${path}`) + }) + requestBackground.mockImplementationOnce(async () => { + queueMicrotask(() => + bus.emit("message", { + type: 4, + interface: "org.freedesktop.portal.Request", + member: "Response", + path: requestPath, + body: [0, { background: { value: true }, autostart: { value: true } }], + }), + ) + return requestPath + }) + + await expect( + requestPortalBackground({ reason: "test", autostart: true }), + ).resolves.toEqual({ background: true, autostart: true }) + expect(bus.listenerCount("message")).toBe(0) + expect(bus.disconnect).toHaveBeenCalled() + }) + + it("rejects a failed method call and still cleans up the bus", async () => { + bus.getProxyObject.mockImplementationOnce(async () => ({ + getInterface: () => ({ RequestBackground: vi.fn().mockRejectedValue(new Error("denied")) }), + })) + await expect( + requestPortalBackground({ reason: "test", autostart: true }), + ).rejects.toThrow("denied") + expect(bus.listenerCount("message")).toBe(0) + expect(bus.disconnect).toHaveBeenCalled() + }) +}) diff --git a/desktop/src/main/__tests__/settings-service.test.ts b/desktop/src/main/__tests__/settings-service.test.ts index 36310219e..05d9cb3e8 100644 --- a/desktop/src/main/__tests__/settings-service.test.ts +++ b/desktop/src/main/__tests__/settings-service.test.ts @@ -192,4 +192,53 @@ describe("SettingsService", () => { }) expect(result.startup.status).toBe("disabled") }) + + it("preserves settings when disabling autostart fails", async () => { + let calls = 0 + const { svc } = service(async () => { + calls++ + return calls === 1 + ? enabled + : { + applied: false, + enabled: true, + status: "error" as const, + detail: "remove failed", + } + }) + await svc.update({ runAtStartup: true, openToTrayOnStartup: true }) + const result = await svc.update({ runAtStartup: false }) + expect(result.settings).toEqual({ + runAtStartup: true, + openToTrayOnStartup: true, + trayNotifications: "failures", + }) + expect(result.startup).toMatchObject({ applied: false, enabled: true }) + expect(store.get().runAtStartup).toBe(true) + }) + + it("serializes transactions and continues after a rejected update", async () => { + let release!: () => void + let first = true + const applied: boolean[] = [] + const { svc } = service(async (settings) => { + applied.push(settings.runAtStartup) + if (first) { + first = false + await new Promise((resolve) => (release = resolve)) + throw new Error("first failed") + } + return settings.runAtStartup ? enabled : disabled + }) + const firstUpdate = svc.update({ runAtStartup: true }) + const secondUpdate = svc.update({ trayNotifications: "all" }) + await new Promise((resolve) => setTimeout(resolve, 0)) + release() + await expect(firstUpdate).rejects.toThrow("first failed") + await expect(secondUpdate).resolves.toMatchObject({ + settings: { trayNotifications: "all" }, + }) + expect(applied).toEqual([true]) + expect(store.get().trayNotifications).toBe("all") + }) }) diff --git a/desktop/src/main/__tests__/tray-notifications.test.ts b/desktop/src/main/__tests__/tray-notifications.test.ts index 113ce2c1d..a77745d10 100644 --- a/desktop/src/main/__tests__/tray-notifications.test.ts +++ b/desktop/src/main/__tests__/tray-notifications.test.ts @@ -115,7 +115,22 @@ describe("updateNotifies", () => { error: "x", } expect(updateNotifies(failed, "failures", undefined).notifies).toBe(true) - expect(updateNotifies(failed, "failures", "1.1.0").notifies).toBe(false) + expect(updateNotifies(failed, "failures", "install-failed:1.1.0").notifies).toBe(false) + }) + + it("allows an install failure after the update-ready notification", () => { + const failed: UpdateStatus = { + state: "error", + currentVersion: "1.0.0", + availableVersion: "1.1.0", + code: "install-failed", + error: "x", + } + const ready = updateNotifies(downloaded, "all", undefined) + expect(updateNotifies(failed, "failures", ready.key).notifies).toBe(true) + expect( + updateNotifies(failed, "failures", "install-failed:1.1.0").notifies, + ).toBe(false) }) it("ignores other update states", () => { diff --git a/desktop/src/main/__tests__/workspace-jobs.test.ts b/desktop/src/main/__tests__/workspace-jobs.test.ts index 4546e21ae..8d422d497 100644 --- a/desktop/src/main/__tests__/workspace-jobs.test.ts +++ b/desktop/src/main/__tests__/workspace-jobs.test.ts @@ -90,6 +90,24 @@ describe("WorkspaceJobs", () => { expect(() => jobs.start("ws")).not.toThrow() }) + it("records a failed command even when refresh also fails", async () => { + const jobs = new WorkspaceJobs() + const refresh = vi + .fn() + .mockRejectedValueOnce(new Error("offline")) + .mockResolvedValueOnce(undefined) + jobs.setRefresh(refresh) + const generation = jobs.start("ws", "stopping", "stop") + await jobs.finish("ws", generation, "command denied") + expect(jobs.get("ws")).toMatchObject({ + state: "failed", + error: "command denied", + refreshError: "offline", + }) + await jobs.retryRefresh("ws") + expect(jobs.get("ws")).toMatchObject({ state: "failed", error: "command denied" }) + }) + it("invalidates polls on acceptance and completion", async () => { const jobs = new WorkspaceJobs() const before = jobs.generation("ws") diff --git a/desktop/src/main/autostart.ts b/desktop/src/main/autostart.ts index d5a1e049f..d38904ae8 100644 --- a/desktop/src/main/autostart.ts +++ b/desktop/src/main/autostart.ts @@ -20,6 +20,8 @@ export interface AutostartEnvironment { execPath: string homeDir: string packaged: boolean + appImagePath?: string + xdgConfigHome?: string } export function detectAutostartEnvironment(): AutostartEnvironment { @@ -30,19 +32,25 @@ export function detectAutostartEnvironment(): AutostartEnvironment { execPath: process.execPath, homeDir: app.getPath("home"), packaged: app.isPackaged, + appImagePath: process.env.APPIMAGE, + xdgConfigHome: process.env.XDG_CONFIG_HOME, } } -function autostartDir(homeDir: string): string { - return join(homeDir, ".config", "autostart") +function autostartDir(env: AutostartEnvironment): string { + return join(env.xdgConfigHome || join(env.homeDir, ".config"), "autostart") } function xdgDesktopFilePath(env: AutostartEnvironment): string { - return join(autostartDir(env.homeDir), "devsy.desktop") + return join(autostartDir(env), "devsy.desktop") } function flatpakDesktopFilePath(env: AutostartEnvironment): string { - return join(autostartDir(env.homeDir), `${env.flatpakId}.desktop`) + return join(autostartDir(env), `${env.flatpakId}.desktop`) +} + +function desktopExecArg(value: string): string { + return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"` } function xdgDesktopEntry(execPath: string): string { @@ -50,7 +58,7 @@ function xdgDesktopEntry(execPath: string): string { "[Desktop Entry]", "Type=Application", "Name=Devsy", - `Exec="${execPath}" ${AUTO_LAUNCH_ARG}`, + `Exec=${desktopExecArg(execPath)} ${desktopExecArg(AUTO_LAUNCH_ARG)}`, "X-GNOME-Autostart-enabled=true", "", ].join("\n") @@ -60,7 +68,7 @@ export function readAutostartEnabled( env: AutostartEnvironment, ): boolean | undefined { if (env.platform === "darwin" || env.platform === "win32") { - return app.getLoginItemSettings().openAtLogin + return app.getLoginItemSettings({ args: [AUTO_LAUNCH_ARG] }).openAtLogin } try { if (env.isFlatpak) { @@ -130,8 +138,12 @@ async function applyXdgAutostart( const file = xdgDesktopFilePath(env) try { if (settings.runAtStartup) { - await mkdir(autostartDir(env.homeDir), { recursive: true }) - await writeFile(file, xdgDesktopEntry(env.execPath), "utf-8") + await mkdir(autostartDir(env), { recursive: true }) + await writeFile( + file, + xdgDesktopEntry(env.appImagePath || env.execPath), + "utf-8", + ) } else { await rm(file, { force: true }) } diff --git a/desktop/src/main/index.ts b/desktop/src/main/index.ts index dbd7b1b02..82bb71cc8 100644 --- a/desktop/src/main/index.ts +++ b/desktop/src/main/index.ts @@ -34,7 +34,7 @@ import { WorkspaceJobs } from "./workspace-jobs.js" const PROTOCOL = "devsy" let mainWindow: BrowserWindow | null = null -let pendingDeepLink: string | null = null +const pendingDeepLinks: string[] = [] let pendingRoute: string | null = null let rendererReady = false let appTray: AppTray | null = null @@ -46,9 +46,10 @@ function handleDeepLink(url: string): void { if (mainWindow.isMinimized()) mainWindow.restore() mainWindow.show() mainWindow.focus() - mainWindow.webContents.send("deep-link", url) + if (rendererReady) mainWindow.webContents.send("deep-link", url) + else pendingDeepLinks.push(url) } else { - pendingDeepLink = url + pendingDeepLinks.push(url) } } @@ -127,10 +128,6 @@ function createWindow(): void { mainWindow.once("ready-to-show", () => { mainWindow?.show() - if (pendingDeepLink) { - mainWindow?.webContents.send("deep-link", pendingDeepLink) - pendingDeepLink = null - } }) } @@ -145,7 +142,7 @@ app.whenReady().then(async () => { const startupUrl = process.argv.find((arg) => arg.startsWith(`${PROTOCOL}://`), ) - if (startupUrl) pendingDeepLink = startupUrl + if (startupUrl) pendingDeepLinks.push(startupUrl) // Apply Content Security Policy to all web responses. session.defaultSession.webRequest.onHeadersReceived((details, callback) => { @@ -282,6 +279,7 @@ app.whenReady().then(async () => { sender.send("navigate", pendingRoute) pendingRoute = null } + for (const url of pendingDeepLinks.splice(0)) sender.send("deep-link", url) }, workspaceSnapshot: () => watcher?.workspaceSnapshot(), settingsService, @@ -361,9 +359,8 @@ app.whenReady().then(async () => { } app.on("activate", () => { - if (BrowserWindow.getAllWindows().length === 0) { - createWindow() - } + if (BrowserWindow.getAllWindows().length === 0) createWindow() + else showDevsy() }) }) diff --git a/desktop/src/main/portal-background.ts b/desktop/src/main/portal-background.ts index 88eba22db..470100aa0 100644 --- a/desktop/src/main/portal-background.ts +++ b/desktop/src/main/portal-background.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto" +import type { Message } from "dbus-next" export interface PortalBackgroundResponse { background: boolean @@ -11,15 +12,6 @@ export interface PortalBackgroundOptions { commandline?: string[] } -// The portal Response signal is delivered on a Request object whose path is -// only known after the method returns. Precomputing the path from the -// handle_token lets us subscribe before the call so an instant response -// cannot race past the listener. -export function portalRequestPath(senderUniqueName: string, token: string): string { - const sender = senderUniqueName.replace(/^:/, "").replace(/\./g, "_") - return `/org/freedesktop/portal/desktop/request/${sender}/${token}` -} - export function parsePortalResponse( code: number, results: Record | undefined, @@ -46,24 +38,31 @@ export async function requestPortalBackground( ) const background = desktop.getInterface("org.freedesktop.portal.Background") const token = `devsy${randomUUID().replace(/-/g, "")}` - const path = portalRequestPath(bus.name, token) - const requestObject = await bus.getProxyObject( - "org.freedesktop.portal.Desktop", - path, - ) - const request = requestObject.getInterface("org.freedesktop.portal.Request") + let requestPath: string | undefined + const pendingResponses = new Map() + let resolveResponse: ((value: PortalBackgroundResponse) => void) | undefined const response = new Promise((resolve) => { - const timer = setTimeout(() => { - resolve({ background: false, autostart: false }) - }, PORTAL_TIMEOUT_MS) - request.on( - "Response", - (code: number, results: Record) => { - clearTimeout(timer) - resolve(parsePortalResponse(code, results)) - }, - ) + resolveResponse = resolve }) + const onMessage = (message: Message) => { + if ( + message.type !== 4 || + message.interface !== "org.freedesktop.portal.Request" || + message.member !== "Response" || + !message.path || (requestPath !== undefined && message.path !== requestPath) + ) return + const [code, results] = message.body + const parsed = parsePortalResponse( + Number(code), + results as Record | undefined, + ) + if (requestPath === undefined) pendingResponses.set(message.path, parsed) + else resolveResponse?.(parsed) + } + bus.on("message", onMessage) + const timer = setTimeout(() => { + resolveResponse?.({ background: false, autostart: false }) + }, PORTAL_TIMEOUT_MS) const methodOptions: Record> = { handle_token: new Variant("s", token), reason: new Variant("s", options.reason), @@ -72,8 +71,15 @@ export async function requestPortalBackground( if (options.commandline) { methodOptions.commandline = new Variant("as", options.commandline) } - await background.RequestBackground("", methodOptions) - return await response + try { + requestPath = (await background.RequestBackground("", methodOptions)) as string + const pending = pendingResponses.get(requestPath) + if (pending) resolveResponse?.(pending) + return await response + } finally { + clearTimeout(timer) + bus.removeListener("message", onMessage) + } } finally { bus.disconnect() } diff --git a/desktop/src/main/settings-service.ts b/desktop/src/main/settings-service.ts index c76124344..745244ea3 100644 --- a/desktop/src/main/settings-service.ts +++ b/desktop/src/main/settings-service.ts @@ -22,6 +22,8 @@ export interface SettingsServiceDeps { } export class SettingsService { + private queue: Promise = Promise.resolve() + constructor(private deps: SettingsServiceDeps) {} get(): AppSettings { @@ -46,6 +48,14 @@ export class SettingsService { } async update(patch: Partial): Promise { + const update = this.queue.catch(() => {}).then(() => this.applyUpdate(patch)) + this.queue = update.catch(() => {}) + return update + } + + private async applyUpdate( + patch: Partial, + ): Promise { const current = this.deps.store.get() const next = patchAppSettings(current, patch) let startup: AutostartApplyResult = this.status().startup @@ -57,12 +67,23 @@ export class SettingsService { if (next.runAtStartup && !startup.enabled) { // Persist and broadcast the reverted state so Settings and the tray // converge on Run at startup off; the denial detail stays in startup. - const reverted = { ...next, runAtStartup: false, openToTrayOnStartup: false } + const reverted = { + ...next, + runAtStartup: false, + openToTrayOnStartup: false, + } await this.persistWithAutostartRollback(reverted, current) const result = { settings: reverted, startup } this.deps.onChanged(result) return result } + if (!startup.applied) { + // A failed removal leaves the OS login item enabled. Preserve both + // settings so the persisted state does not claim the removal applied. + const result = { settings: current, startup } + this.deps.onChanged(result) + return result + } } await this.persistWithAutostartRollback(next, current, startupRelevant) const result = { settings: next, startup } diff --git a/desktop/src/main/tray-notifications.ts b/desktop/src/main/tray-notifications.ts index dde2ef80d..9cf2bf5c5 100644 --- a/desktop/src/main/tray-notifications.ts +++ b/desktop/src/main/tray-notifications.ts @@ -71,24 +71,31 @@ export function updateNotifies( status: UpdateStatus, level: TrayNotificationLevel, lastNotifiedVersion: string | undefined, -): { notifies: boolean; version?: string; title: string; body: string } { +): { notifies: boolean; version?: string; key?: string; title: string; body: string } { const none = { notifies: false, title: "", body: "" } if (level === "off") return none - const version = status.availableVersion ?? "" - if (!version || version === lastNotifiedVersion) return none + if (!("availableVersion" in status)) return none + const version = status.availableVersion + if (!version) return none if (status.state === "downloaded") { if (level !== "all") return none + const key = `downloaded:${version}` + if (lastNotifiedVersion === version || lastNotifiedVersion === key) return none return { notifies: true, version, + key, title: "Devsy update ready", body: `Version ${version} is ready to install.`, } } if (status.state === "error" && status.code === "install-failed") { + const key = `install-failed:${version}` + if (lastNotifiedVersion === key) return none return { notifies: true, version, + key, title: "Devsy update failed", body: `Version ${version} could not be installed.`, } @@ -110,7 +117,7 @@ const NOTIFIED_CAP = 200 export class TrayNotifier { private previous: Record | null = null private notified = new Set() - private lastUpdateVersion: string | undefined + private lastUpdateNotification: string | undefined constructor(private deps: TrayNotifierDeps) {} @@ -146,10 +153,10 @@ export class TrayNotifier { const decision = updateNotifies( status, this.deps.getLevel(), - this.lastUpdateVersion, + this.lastUpdateNotification, ) if (!decision.notifies || !decision.version) return - this.lastUpdateVersion = decision.version + this.lastUpdateNotification = decision.key ?? decision.version if (this.deps.isAppFocused()) return this.deps.sink({ title: decision.title, diff --git a/desktop/src/main/tray-support.ts b/desktop/src/main/tray-support.ts index db982a7ef..2e9a0556b 100644 --- a/desktop/src/main/tray-support.ts +++ b/desktop/src/main/tray-support.ts @@ -15,36 +15,39 @@ export async function isTrayHostAvailable( ): Promise { const platform = deps.platform ?? process.platform if (platform !== "linux") return true + let timer: ReturnType | undefined try { - const getNameOwner = deps.getNameOwner ?? (await sessionNameOwner()) + const operation = deps.getNameOwner + ? deps.getNameOwner(WATCHER_NAME) + : sessionNameOwner(WATCHER_NAME) const owner = await Promise.race([ - getNameOwner(WATCHER_NAME), - new Promise((_, reject) => - setTimeout( + operation, + new Promise((_, reject) => { + timer = setTimeout( () => reject(new Error("tray host probe timed out")), deps.timeoutMs ?? DEFAULT_TIMEOUT_MS, - ), - ), + ) + }), ]) return typeof owner === "string" && owner.length > 0 } catch { return false + } finally { + if (timer) clearTimeout(timer) } } -async function sessionNameOwner(): Promise<(name: string) => Promise> { +async function sessionNameOwner(name: string): Promise { const { sessionBus } = await import("dbus-next") const bus = sessionBus() - const dbus = await bus.getProxyObject( - "org.freedesktop.DBus", - "/org/freedesktop/DBus", - ) - const iface = dbus.getInterface("org.freedesktop.DBus") - return async (name) => { - try { - return (await iface.GetNameOwner(name)) as string - } finally { - bus.disconnect() - } + try { + const dbus = await bus.getProxyObject( + "org.freedesktop.DBus", + "/org/freedesktop/DBus", + ) + const iface = dbus.getInterface("org.freedesktop.DBus") + return (await iface.GetNameOwner(name)) as string + } finally { + bus.disconnect() } } diff --git a/desktop/src/main/workspace-jobs.ts b/desktop/src/main/workspace-jobs.ts index 522e01e5b..2a93994e4 100644 --- a/desktop/src/main/workspace-jobs.ts +++ b/desktop/src/main/workspace-jobs.ts @@ -97,7 +97,8 @@ export class WorkspaceJobs { const job = this.jobs.get(id) if ( !job || - job.state !== "reconciling" || + (job.state !== "reconciling" && + !(job.state === "failed" && job.refreshError)) || this.refreshing.has(job.commandId) ) return @@ -115,6 +116,7 @@ export class WorkspaceJobs { if (this.jobs.get(id) !== job) return this.jobs.set(id, { ...job, + state: job.error ? "failed" : "reconciling", refreshError: error instanceof Error ? error.message : String(error), }) } finally { diff --git a/desktop/src/renderer/src/App.svelte b/desktop/src/renderer/src/App.svelte index e43ade120..e64012a0e 100644 --- a/desktop/src/renderer/src/App.svelte +++ b/desktop/src/renderer/src/App.svelte @@ -178,8 +178,8 @@ onMount(async () => { await initUpdateStore() await syncAutoUpdateFromMain() - await syncDesktopSettingsFromMain() const desktopSettingsUnlisten = await initDesktopSettingsListener() + await syncDesktopSettingsFromMain() if (destroyed) desktopSettingsUnlisten() else unsubDesktopSettings = desktopSettingsUnlisten unsubscribeToasts = initUpdateToasts(() => { diff --git a/desktop/src/renderer/src/lib/stores/settings.ts b/desktop/src/renderer/src/lib/stores/settings.ts index 52626c3ac..b58b2d7f6 100644 --- a/desktop/src/renderer/src/lib/stores/settings.ts +++ b/desktop/src/renderer/src/lib/stores/settings.ts @@ -365,7 +365,8 @@ export const openToTrayOnStartup = writable(false) export const trayNotifications = writable("failures") export const startupStatus = writable(null) -let desktopSettingsWriteInFlight = false +let desktopSettingsQueue: Promise = Promise.resolve() +let desktopSettingsRevision = 0 function applyAppSettingsState(state: AppSettingsState): void { runAtStartup.set(state.settings.runAtStartup) @@ -375,34 +376,40 @@ function applyAppSettingsState(state: AppSettingsState): void { } export async function syncDesktopSettingsFromMain(): Promise { - try { - const state = await getAppSettings() - if (desktopSettingsWriteInFlight) return - applyAppSettingsState(state) - } catch (err) { - console.warn("[settings] getAppSettings failed:", err) - } + const revision = desktopSettingsRevision + const sync = desktopSettingsQueue.catch(() => {}).then(async () => { + try { + const state = await getAppSettings() + if (revision === desktopSettingsRevision) applyAppSettingsState(state) + } catch (err) { + console.warn("[settings] getAppSettings failed:", err) + } + }) + desktopSettingsQueue = sync.catch(() => {}) + await sync } export async function updateDesktopSettings( patch: Partial, ): Promise { - desktopSettingsWriteInFlight = true - try { - applyAppSettingsState(await setAppSettings(patch)) - } catch (err) { - console.warn("[settings] setAppSettings failed:", err) + const update = desktopSettingsQueue.catch(() => {}).then(async () => { try { - applyAppSettingsState(await getAppSettings()) - } catch {} - } finally { - desktopSettingsWriteInFlight = false - } + const state = await setAppSettings(patch) + applyAppSettingsState(state) + } catch (err) { + console.warn("[settings] setAppSettings failed:", err) + try { + applyAppSettingsState(await getAppSettings()) + } catch {} + } + }) + desktopSettingsQueue = update.catch(() => {}) + await update } export async function initDesktopSettingsListener(): Promise<() => void> { const unlisten = await onAppSettingsChanged((state) => { - if (desktopSettingsWriteInFlight) return + desktopSettingsRevision++ applyAppSettingsState(state) }) return unlisten diff --git a/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte b/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte index 745864079..e6dc97b85 100644 --- a/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte +++ b/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte @@ -133,6 +133,12 @@ let isBusy = $derived.by(() => { const BUILD_OPS = new Set(["Start", "Open IDE", "Recovery", "Rebuild", "Reset"]) let activeTab = $state("overview") +$effect(() => { + const tab = new URLSearchParams($querystring ?? "").get("tab") + if (tab === "logs" || tab === "terminal" || tab === "overview") { + activeTab = tab + } +}) let outputLines = $state([]) let commandId = $state(null) let operationLabel = $state("") From 56ff2fe73f5e6e5f85b978087f6e9ff5547b4756 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Tue, 22 Sep 2026 01:21:10 -0600 Subject: [PATCH 10/43] fix(desktop): preserve current settings on every failed platform apply Signed-off-by: Samuel K --- desktop/src/main/settings-service.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/desktop/src/main/settings-service.ts b/desktop/src/main/settings-service.ts index 745244ea3..738df5413 100644 --- a/desktop/src/main/settings-service.ts +++ b/desktop/src/main/settings-service.ts @@ -64,6 +64,13 @@ export class SettingsService { next.openToTrayOnStartup !== current.openToTrayOnStartup if (startupRelevant) { startup = await this.deps.applyAutostart(next) + if (!startup.applied) { + // A failed platform application changes nothing: keep and broadcast + // the current settings; the error detail stays in startup. + const result = { settings: current, startup } + this.deps.onChanged(result) + return result + } if (next.runAtStartup && !startup.enabled) { // Persist and broadcast the reverted state so Settings and the tray // converge on Run at startup off; the denial detail stays in startup. @@ -77,13 +84,6 @@ export class SettingsService { this.deps.onChanged(result) return result } - if (!startup.applied) { - // A failed removal leaves the OS login item enabled. Preserve both - // settings so the persisted state does not claim the removal applied. - const result = { settings: current, startup } - this.deps.onChanged(result) - return result - } } await this.persistWithAutostartRollback(next, current, startupRelevant) const result = { settings: next, startup } From 027aae78ccfd2f5b37e746ecd030b34d3642f19f Mon Sep 17 00:00:00 2001 From: Samuel K Date: Tue, 22 Sep 2026 01:21:40 -0600 Subject: [PATCH 11/43] test(desktop): cover dependent-toggle platform failure preservation Signed-off-by: Samuel K --- .../main/__tests__/settings-service.test.ts | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/desktop/src/main/__tests__/settings-service.test.ts b/desktop/src/main/__tests__/settings-service.test.ts index 05d9cb3e8..70f20d1d7 100644 --- a/desktop/src/main/__tests__/settings-service.test.ts +++ b/desktop/src/main/__tests__/settings-service.test.ts @@ -141,7 +141,7 @@ describe("SettingsService", () => { appliedTray.push(s.openToTrayOnStartup) if (appliedTray.length === 1) return { - applied: false, + applied: true, enabled: false, status: "denied", detail: "denied by the system", @@ -193,6 +193,42 @@ describe("SettingsService", () => { expect(result.startup.status).toBe("disabled") }) + it("preserves current settings when a dependent-toggle application fails", async () => { + store.save({ ...DEFAULT_APP_SETTINGS, runAtStartup: true }) + const onChanged = vi.fn() + const svc = new SettingsService({ + store, + applyAutostart: async () => ({ + applied: false, + enabled: false, + status: "error", + detail: "boom", + }), + currentAutostartEnabled: () => true, + onChanged, + }) + const result = await svc.update({ openToTrayOnStartup: true }) + expect(result.settings).toEqual({ + runAtStartup: true, + openToTrayOnStartup: false, + trayNotifications: "failures", + }) + expect(result.startup.status).toBe("error") + expect(store.get()).toEqual({ + runAtStartup: true, + openToTrayOnStartup: false, + trayNotifications: "failures", + }) + expect(onChanged).toHaveBeenCalledTimes(1) + expect(onChanged).toHaveBeenCalledWith({ + settings: expect.objectContaining({ + runAtStartup: true, + openToTrayOnStartup: false, + }), + startup: expect.objectContaining({ status: "error" }), + }) + }) + it("preserves settings when disabling autostart fails", async () => { let calls = 0 const { svc } = service(async () => { From 74e917f384f5279c2170e843ce1c1abcef99eae2 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Tue, 22 Sep 2026 02:49:01 -0600 Subject: [PATCH 12/43] fix(desktop): derive the running count from tray workspace state Signed-off-by: Samuel K --- desktop/src/main/tray.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/desktop/src/main/tray.ts b/desktop/src/main/tray.ts index 005fbbeed..1ffca2e62 100644 --- a/desktop/src/main/tray.ts +++ b/desktop/src/main/tray.ts @@ -15,10 +15,7 @@ import { onUpdateStatusChanged, type UpdateStatus, } from "./updater.js" -import { - isActiveWorkspaceStatus, - normalizeWorkspaceStatus, -} from "./workspace-status.js" +import { normalizeWorkspaceStatus } from "./workspace-status.js" // Native menus do not scroll well, so the tray shows only the most recently // used workspaces and links into the app for the rest. @@ -80,11 +77,10 @@ export function countRunningWorkspaces( workspaces: Workspace[], jobs: Record, ): number { - return workspaces.filter( - (workspace) => - isActiveWorkspaceStatus(workspace.status) || - workspaceJobBusy(jobs[workspace.id]), - ).length + return workspaces.filter((workspace) => { + const state = trayWorkspaceState(workspace, jobs[workspace.id]) + return state === "running" || state === "busy" + }).length } export interface TrayMenuModel { From c26fa2320d1499e94c491634c944e9a923cd26a7 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Tue, 22 Sep 2026 02:49:40 -0600 Subject: [PATCH 13/43] test(desktop): cover running count state derivation Signed-off-by: Samuel K --- desktop/src/main/__tests__/tray.test.ts | 28 ++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/desktop/src/main/__tests__/tray.test.ts b/desktop/src/main/__tests__/tray.test.ts index 9885902b8..671f91c92 100644 --- a/desktop/src/main/__tests__/tray.test.ts +++ b/desktop/src/main/__tests__/tray.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it, vi } from "vitest" -import { buildTrayMenuTemplate, buildUpdateMenuItems } from "../tray.js" +import { + buildTrayMenuTemplate, + buildUpdateMenuItems, + countRunningWorkspaces, +} from "../tray.js" +import type { WorkspaceJob } from "../../shared/workspace-operation.js" vi.mock("electron", () => ({})) vi.mock("../updater.js", () => ({ @@ -178,6 +183,27 @@ describe("buildTrayMenuTemplate", () => { }) }) + it("counts JSON-encoded statuses and jobs the same as tray rows", () => { + const job = (partial: Partial): WorkspaceJob => ({ + commandId: "cmd-1", + activity: "starting", + state: "running", + phase: "Preparing", + ...partial, + }) + expect( + countRunningWorkspaces( + [ + { id: "a", status: '{"state":"running"}' }, + { id: "b", status: "running" }, + { id: "c", status: "stopped" }, + { id: "d", status: "running" }, + ], + { c: job({}), d: job({ state: "failed", error: "x" }) }, + ), + ).toBe(3) + }) + it("limits the list to five most recently used workspaces with an overflow link", () => { const workspaces = Array.from({ length: 7 }, (_, i) => ({ id: `ws-${i}`, From f79cbe6c5e31149844937ad4d9fedca0761797c5 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Tue, 22 Sep 2026 02:50:11 -0600 Subject: [PATCH 14/43] test(desktop): fail the tray smoke check without a watcher Signed-off-by: Samuel K --- desktop/scripts/tray-smoke-xfce.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/desktop/scripts/tray-smoke-xfce.sh b/desktop/scripts/tray-smoke-xfce.sh index 1c58e6588..05167135f 100644 --- a/desktop/scripts/tray-smoke-xfce.sh +++ b/desktop/scripts/tray-smoke-xfce.sh @@ -105,8 +105,8 @@ if [ -z "$APP_OK" ]; then exit 1 fi if [ -z "$WATCHER" ]; then - echo "NOTE: xfce4-panel did not claim org.kde.StatusNotifierWatcher;" - echo "passing on app-side StatusNotifierItem registration alone." + echo "FAIL: xfce4-panel did not claim org.kde.StatusNotifierWatcher within 30s" >&2 + exit 1 fi echo "tray smoke check passed" INNER From e3915ee953a3f61302236d0bcd8187111597fe92 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Tue, 22 Sep 2026 03:29:58 -0600 Subject: [PATCH 15/43] fix(desktop): count actually running workspaces in the tray header Signed-off-by: Samuel K --- desktop/src/main/tray.ts | 285 +++++++++++++++++++-------------------- 1 file changed, 142 insertions(+), 143 deletions(-) diff --git a/desktop/src/main/tray.ts b/desktop/src/main/tray.ts index 1ffca2e62..1e91cf431 100644 --- a/desktop/src/main/tray.ts +++ b/desktop/src/main/tray.ts @@ -3,58 +3,59 @@ import { workspaceJobInterruptible, workspaceJobLabel, type WorkspaceJob, -} from "../shared/workspace-operation.js" -import type { WorkspaceJobs } from "./workspace-jobs.js" -import { join } from "node:path" -import { app, Menu, nativeImage, nativeTheme, Tray } from "electron" -import type { AppSettings } from "./app-settings.js" -import type { DaemonState, Workspace } from "./state.js" +} from "../shared/workspace-operation.js"; +import type { WorkspaceJobs } from "./workspace-jobs.js"; +import { join } from "node:path"; +import { app, Menu, nativeImage, nativeTheme, Tray } from "electron"; +import type { AppSettings } from "./app-settings.js"; +import type { DaemonState, Workspace } from "./state.js"; import { getLastStatus, installUpdate, onUpdateStatusChanged, type UpdateStatus, -} from "./updater.js" -import { normalizeWorkspaceStatus } from "./workspace-status.js" +} from "./updater.js"; +import { + isActiveWorkspaceStatus, + normalizeWorkspaceStatus, +} from "./workspace-status.js"; // Native menus do not scroll well, so the tray shows only the most recently // used workspaces and links into the app for the rest. -export const TRAY_WORKSPACE_LIMIT = 5 +export const TRAY_WORKSPACE_LIMIT = 5; export function buildUpdateMenuItems( status: UpdateStatus, onInstall: () => void, ): Electron.MenuItemConstructorOptions[] { const installationFailed = - status.state === "error" && status.code === "install-failed" - if (status.state !== "downloaded" && !installationFailed) return [] - const version = status.availableVersion ?? "" + status.state === "error" && status.code === "install-failed"; + if (status.state !== "downloaded" && !installationFailed) return []; + const version = status.availableVersion ?? ""; const label = installationFailed ? `Retry Install Update v${version}` : version ? `Update to ${version}` - : "Restart" - return [{ label, click: onInstall }, { type: "separator" }] + : "Restart"; + return [{ label, click: onInstall }, { type: "separator" }]; } export type TrayWorkspaceState = - | "running" - | "stopped" - | "failed" - | "busy" - | "unknown" + "running" | "stopped" | "failed" | "busy" | "unknown"; export function trayWorkspaceState( workspace: Workspace, job?: WorkspaceJob, ): TrayWorkspaceState { - if (workspaceJobBusy(job)) return "busy" - if (job?.error || job?.state === "failed") return "failed" - const status = normalizeWorkspaceStatus(workspace.status ?? "")?.toLowerCase() - if (status === "running" || status === "busy") return "running" - if (status === "stopped") return "stopped" - if (status === "failed" || status === "error") return "failed" - return "unknown" + if (workspaceJobBusy(job)) return "busy"; + if (job?.error || job?.state === "failed") return "failed"; + const status = normalizeWorkspaceStatus( + workspace.status ?? "", + )?.toLowerCase(); + if (status === "running" || status === "busy") return "running"; + if (status === "stopped") return "stopped"; + if (status === "failed" || status === "error") return "failed"; + return "unknown"; } const STATE_GLYPHS: Record = { @@ -63,7 +64,7 @@ const STATE_GLYPHS: Record = { failed: "✖", busy: "◐", unknown: "◌", -} +}; const STATE_TEXT: Record = { running: "Running", @@ -71,39 +72,38 @@ const STATE_TEXT: Record = { failed: "Failed", busy: "Busy", unknown: "Unknown", -} +}; -export function countRunningWorkspaces( - workspaces: Workspace[], - jobs: Record, -): number { - return workspaces.filter((workspace) => { - const state = trayWorkspaceState(workspace, jobs[workspace.id]) - return state === "running" || state === "busy" - }).length +// The header counts workspaces that are actually running, so it derives from +// the normalized workspace status rather than the tray row state: a retained +// failed job marks the row failed without making the workspace stop running. +export function countRunningWorkspaces(workspaces: Workspace[]): number { + return workspaces.filter((workspace) => + isActiveWorkspaceStatus(normalizeWorkspaceStatus(workspace.status ?? "")), + ).length; } export interface TrayMenuModel { - workspaces: Workspace[] - jobs?: Record - pendingStops: ReadonlySet - pendingStarts: ReadonlySet - updateStatus: UpdateStatus - settings: AppSettings + workspaces: Workspace[]; + jobs?: Record; + pendingStops: ReadonlySet; + pendingStarts: ReadonlySet; + updateStatus: UpdateStatus; + settings: AppSettings; } export interface TrayMenuActions { - showDevsy: () => void - showWorkspace: (id: string) => void - showWorkspaceLogs: (id: string) => void - showAllWorkspaces: () => void - showSettings: () => void - startWorkspace: (id: string) => void - stopWorkspace: (id: string) => void - toggleRunAtStartup: () => void - toggleOpenToTray: () => void - installUpdate: () => void - quit: () => void + showDevsy: () => void; + showWorkspace: (id: string) => void; + showWorkspaceLogs: (id: string) => void; + showAllWorkspaces: () => void; + showSettings: () => void; + startWorkspace: (id: string) => void; + stopWorkspace: (id: string) => void; + toggleRunAtStartup: () => void; + toggleOpenToTray: () => void; + installUpdate: () => void; + quit: () => void; } // Action meaning lives in text labels because native menu-item images are @@ -112,8 +112,8 @@ export function buildTrayMenuTemplate( model: TrayMenuModel, actions: TrayMenuActions, ): Electron.MenuItemConstructorOptions[] { - const jobs = model.jobs ?? {} - const running = countRunningWorkspaces(model.workspaces, jobs) + const jobs = model.jobs ?? {}; + const running = countRunningWorkspaces(model.workspaces); const header: Electron.MenuItemConstructorOptions[] = [ { label: @@ -123,35 +123,35 @@ export function buildTrayMenuTemplate( enabled: false, }, { type: "separator" }, - ] + ]; - const shown = model.workspaces.slice(0, TRAY_WORKSPACE_LIMIT) + const shown = model.workspaces.slice(0, TRAY_WORKSPACE_LIMIT); const workspaceItems: Electron.MenuItemConstructorOptions[] = shown.map( (workspace) => { - const job = jobs[workspace.id] - const state = trayWorkspaceState(workspace, job) + const job = jobs[workspace.id]; + const state = trayWorkspaceState(workspace, job); const pending = model.pendingStops.has(workspace.id) || model.pendingStarts.has(workspace.id) || - state === "busy" - const jobLabel = workspaceJobLabel(job) + state === "busy"; + const jobLabel = workspaceJobLabel(job); const submenu: Electron.MenuItemConstructorOptions[] = [ { label: "Open in Devsy", click: () => actions.showWorkspace(workspace.id), }, - ] + ]; if (state === "running") { const stopping = model.pendingStops.has(workspace.id) || - (workspaceJobBusy(job) && !workspaceJobInterruptible(job)) + (workspaceJobBusy(job) && !workspaceJobInterruptible(job)); submenu.push({ label: stopping ? `${jobLabel ?? "Stopping"}…` : "Stop Workspace", enabled: !stopping, click: stopping ? undefined : () => actions.stopWorkspace(workspace.id), - }) + }); } else if (state === "stopped") { submenu.push({ label: model.pendingStarts.has(workspace.id) @@ -161,22 +161,22 @@ export function buildTrayMenuTemplate( click: model.pendingStarts.has(workspace.id) ? undefined : () => actions.startWorkspace(workspace.id), - }) + }); } if (state === "failed" || state === "busy" || state === "unknown") { submenu.push({ label: "View Logs", click: () => actions.showWorkspaceLogs(workspace.id), - }) + }); } return { label: `${STATE_GLYPHS[state]} ${workspace.id} — ${jobLabel && (state === "busy" || state === "failed") ? jobLabel : STATE_TEXT[state]}`, submenu, - } + }; }, - ) + ); if (workspaceItems.length === 0) { - workspaceItems.push({ label: "No Workspaces", enabled: false }) + workspaceItems.push({ label: "No Workspaces", enabled: false }); } else if (model.workspaces.length > TRAY_WORKSPACE_LIMIT) { workspaceItems.push( { type: "separator" }, @@ -184,7 +184,7 @@ export function buildTrayMenuTemplate( label: `View All ${model.workspaces.length} Workspaces in Devsy`, click: actions.showAllWorkspaces, }, - ) + ); } return [ @@ -215,72 +215,72 @@ export function buildTrayMenuTemplate( ...buildUpdateMenuItems(model.updateStatus, actions.installUpdate), { type: "separator" }, { label: "Quit Devsy", click: actions.quit }, - ] + ]; } interface TrayDeps { - workspaceJobs?: WorkspaceJobs - state: DaemonState - showDevsy: (route?: string) => void - getSettings: () => AppSettings - toggleRunAtStartup: () => void - toggleOpenToTray: () => void - startWorkspace: (workspaceId: string) => Promise - stopWorkspace: (workspaceId: string) => Promise - refreshWorkspace: (workspaceId: string) => Promise - refreshWorkspaces: () => Promise + workspaceJobs?: WorkspaceJobs; + state: DaemonState; + showDevsy: (route?: string) => void; + getSettings: () => AppSettings; + toggleRunAtStartup: () => void; + toggleOpenToTray: () => void; + startWorkspace: (workspaceId: string) => Promise; + stopWorkspace: (workspaceId: string) => Promise; + refreshWorkspace: (workspaceId: string) => Promise; + refreshWorkspaces: () => Promise; } export class AppTray { - private tray: Tray | null = null - private pendingStops = new Set() - private pendingStarts = new Set() - private unsubscribeWorkspaceState: (() => void) | null = null - private unsubscribeWorkspaceJobs: (() => void) | null = null - private unsubscribeUpdateStatus: (() => void) | null = null + private tray: Tray | null = null; + private pendingStops = new Set(); + private pendingStarts = new Set(); + private unsubscribeWorkspaceState: (() => void) | null = null; + private unsubscribeWorkspaceJobs: (() => void) | null = null; + private unsubscribeUpdateStatus: (() => void) | null = null; private readonly onThemeUpdated = (): void => { - this.tray?.setImage(this.createTrayIcon()) - } + this.tray?.setImage(this.createTrayIcon()); + }; constructor(private deps: TrayDeps) {} setup(): void { - if (this.tray) return - this.tray = new Tray(this.createTrayIcon()) - this.tray.setToolTip("Devsy — No running workspaces") + if (this.tray) return; + this.tray = new Tray(this.createTrayIcon()); + this.tray.setToolTip("Devsy — No running workspaces"); this.unsubscribeWorkspaceState = this.deps.state.onWorkspacesChange(() => this.rebuildMenu(), - ) + ); this.unsubscribeWorkspaceJobs = - this.deps.workspaceJobs?.onChange(() => this.rebuildMenu()) ?? null + this.deps.workspaceJobs?.onChange(() => this.rebuildMenu()) ?? null; this.unsubscribeUpdateStatus = onUpdateStatusChanged(() => this.rebuildMenu(), - ) + ); if (process.platform !== "darwin") { - nativeTheme.on("updated", this.onThemeUpdated) + nativeTheme.on("updated", this.onThemeUpdated); } - this.rebuildMenu() + this.rebuildMenu(); } destroy(): void { - this.unsubscribeWorkspaceJobs?.() - this.unsubscribeWorkspaceJobs = null - this.unsubscribeWorkspaceState?.() - this.unsubscribeWorkspaceState = null - this.unsubscribeUpdateStatus?.() - this.unsubscribeUpdateStatus = null + this.unsubscribeWorkspaceJobs?.(); + this.unsubscribeWorkspaceJobs = null; + this.unsubscribeWorkspaceState?.(); + this.unsubscribeWorkspaceState = null; + this.unsubscribeUpdateStatus?.(); + this.unsubscribeUpdateStatus = null; if (process.platform !== "darwin") { - nativeTheme.off("updated", this.onThemeUpdated) + nativeTheme.off("updated", this.onThemeUpdated); } - this.pendingStops.clear() - this.pendingStarts.clear() - this.tray?.destroy() - this.tray = null + this.pendingStops.clear(); + this.pendingStarts.clear(); + this.tray?.destroy(); + this.tray = null; } rebuildMenu(): void { - if (!this.tray) return - const jobs = this.deps.workspaceJobs?.snapshot() ?? {} + if (!this.tray) return; + const jobs = this.deps.workspaceJobs?.snapshot() ?? {}; const template = buildTrayMenuTemplate( { workspaces: this.deps.state.workspaceList(), @@ -308,72 +308,71 @@ export class AppTray { ), quit: () => app.quit(), }, - ) - this.tray.setContextMenu(Menu.buildFromTemplate(template)) - const running = countRunningWorkspaces( - this.deps.state.workspaceList(), - jobs, - ) + ); + this.tray.setContextMenu(Menu.buildFromTemplate(template)); + const running = countRunningWorkspaces(this.deps.state.workspaceList()); this.tray.setToolTip( running === 0 ? "Devsy — No running workspaces" : `Devsy — ${running} running workspace${running === 1 ? "" : "s"}`, - ) + ); } private async startFromTray(workspaceId: string): Promise { - if (this.pendingStarts.has(workspaceId)) return - this.pendingStarts.add(workspaceId) - this.rebuildMenu() + if (this.pendingStarts.has(workspaceId)) return; + this.pendingStarts.add(workspaceId); + this.rebuildMenu(); try { - await this.deps.startWorkspace(workspaceId) + await this.deps.startWorkspace(workspaceId); } catch (error) { - console.warn(`[tray] failed to start workspace ${workspaceId}:`, error) + console.warn(`[tray] failed to start workspace ${workspaceId}:`, error); } finally { - await this.refreshAfterAction(workspaceId) - this.pendingStarts.delete(workspaceId) - this.rebuildMenu() + await this.refreshAfterAction(workspaceId); + this.pendingStarts.delete(workspaceId); + this.rebuildMenu(); } } private async stopFromTray(workspaceId: string): Promise { - if (this.pendingStops.has(workspaceId)) return - this.pendingStops.add(workspaceId) - this.rebuildMenu() + if (this.pendingStops.has(workspaceId)) return; + this.pendingStops.add(workspaceId); + this.rebuildMenu(); try { - await this.deps.stopWorkspace(workspaceId) + await this.deps.stopWorkspace(workspaceId); } catch (error) { - console.warn(`[tray] failed to stop workspace ${workspaceId}:`, error) + console.warn(`[tray] failed to stop workspace ${workspaceId}:`, error); } finally { - await this.refreshAfterAction(workspaceId) - this.pendingStops.delete(workspaceId) - this.rebuildMenu() + await this.refreshAfterAction(workspaceId); + this.pendingStops.delete(workspaceId); + this.rebuildMenu(); } } private async refreshAfterAction(workspaceId: string): Promise { try { - await this.deps.refreshWorkspace(workspaceId) + await this.deps.refreshWorkspace(workspaceId); } catch (error) { - console.warn(`[tray] failed to refresh workspace ${workspaceId}:`, error) + console.warn(`[tray] failed to refresh workspace ${workspaceId}:`, error); } try { - await this.deps.refreshWorkspaces() + await this.deps.refreshWorkspaces(); } catch (error) { - console.warn("[tray] failed to refresh workspaces:", error) + console.warn("[tray] failed to refresh workspaces:", error); } } private createTrayIcon(): Electron.NativeImage { - const trayDir = join(__dirname, "../../resources/tray") + const trayDir = join(__dirname, "../../resources/tray"); if (process.platform === "darwin") { const icon = nativeImage.createFromPath( join(trayDir, "icon-trayTemplate.png"), - ) - icon.setTemplateImage(true) - return icon + ); + icon.setTemplateImage(true); + return icon; } - const variant = nativeTheme.shouldUseDarkColors ? "dark" : "light" - return nativeImage.createFromPath(join(trayDir, `icon-tray-${variant}.png`)) + const variant = nativeTheme.shouldUseDarkColors ? "dark" : "light"; + return nativeImage.createFromPath( + join(trayDir, `icon-tray-${variant}.png`), + ); } } From ef41683df0248ab4d7d9b8d90e78e4846c5509fd Mon Sep 17 00:00:00 2001 From: Samuel K Date: Tue, 22 Sep 2026 03:30:28 -0600 Subject: [PATCH 16/43] test(desktop): cover running count ground truth Signed-off-by: Samuel K --- desktop/src/main/__tests__/tray.test.ts | 370 ++++++++++++------------ 1 file changed, 192 insertions(+), 178 deletions(-) diff --git a/desktop/src/main/__tests__/tray.test.ts b/desktop/src/main/__tests__/tray.test.ts index 671f91c92..998b41d8a 100644 --- a/desktop/src/main/__tests__/tray.test.ts +++ b/desktop/src/main/__tests__/tray.test.ts @@ -1,17 +1,17 @@ -import { describe, expect, it, vi } from "vitest" +import { describe, expect, it, vi } from "vitest"; import { buildTrayMenuTemplate, buildUpdateMenuItems, countRunningWorkspaces, -} from "../tray.js" -import type { WorkspaceJob } from "../../shared/workspace-operation.js" +} from "../tray.js"; +import type { WorkspaceJob } from "../../shared/workspace-operation.js"; -vi.mock("electron", () => ({})) +vi.mock("electron", () => ({})); vi.mock("../updater.js", () => ({ getLastStatus: () => ({ state: "idle" }), installUpdate: vi.fn(), onUpdateStatusChanged: vi.fn(() => () => {}), -})) +})); describe("buildUpdateMenuItems", () => { it("returns nothing when no update is downloaded", () => { @@ -20,13 +20,13 @@ describe("buildUpdateMenuItems", () => { { state: "idle", currentVersion: "1.0.0" }, () => {}, ), - ).toEqual([]) + ).toEqual([]); expect( buildUpdateMenuItems( { state: "checking", currentVersion: "1.0.0" }, () => {}, ), - ).toEqual([]) + ).toEqual([]); expect( buildUpdateMenuItems( { @@ -36,7 +36,7 @@ describe("buildUpdateMenuItems", () => { }, () => {}, ), - ).toEqual([]) + ).toEqual([]); expect( buildUpdateMenuItems( { @@ -52,19 +52,19 @@ describe("buildUpdateMenuItems", () => { }, () => {}, ), - ).toEqual([]) + ).toEqual([]); expect( buildUpdateMenuItems( { state: "not-available", currentVersion: "1.0.0" }, () => {}, ), - ).toEqual([]) + ).toEqual([]); expect( buildUpdateMenuItems( { state: "up-to-date", currentVersion: "1.0.0" }, () => {}, ), - ).toEqual([]) + ).toEqual([]); expect( buildUpdateMenuItems( { @@ -75,11 +75,11 @@ describe("buildUpdateMenuItems", () => { }, () => {}, ), - ).toEqual([]) - }) + ).toEqual([]); + }); it("adds Update item + separator when downloaded", () => { - const onInstall = vi.fn() + const onInstall = vi.fn(); const items = buildUpdateMenuItems( { state: "downloaded", @@ -87,26 +87,26 @@ describe("buildUpdateMenuItems", () => { availableVersion: "9.9.9", }, onInstall, - ) - expect(items).toHaveLength(2) - expect(items[0]).toMatchObject({ label: "Update to 9.9.9" }) - expect(items[1]).toEqual({ type: "separator" }) + ); + expect(items).toHaveLength(2); + expect(items[0]).toMatchObject({ label: "Update to 9.9.9" }); + expect(items[1]).toEqual({ type: "separator" }); - const click = (items[0] as { click?: () => void }).click - click?.() - expect(onInstall).toHaveBeenCalledTimes(1) - }) + const click = (items[0] as { click?: () => void }).click; + click?.(); + expect(onInstall).toHaveBeenCalledTimes(1); + }); it("handles missing version gracefully", () => { const items = buildUpdateMenuItems( { state: "downloaded", currentVersion: "1.0.0", availableVersion: "" }, () => {}, - ) - expect(items[0]).toMatchObject({ label: "Restart" }) - }) + ); + expect(items[0]).toMatchObject({ label: "Restart" }); + }); it("offers retry after installation fails", () => { - const onInstall = vi.fn() + const onInstall = vi.fn(); const items = buildUpdateMenuItems( { state: "error", @@ -116,19 +116,19 @@ describe("buildUpdateMenuItems", () => { error: "install failed", }, onInstall, - ) - expect(items[0]).toMatchObject({ label: "Retry Install Update v9.9.9" }) - ;(items[0] as { click?: () => void }).click?.() - expect(onInstall).toHaveBeenCalledTimes(1) - }) -}) + ); + expect(items[0]).toMatchObject({ label: "Retry Install Update v9.9.9" }); + (items[0] as { click?: () => void }).click?.(); + expect(onInstall).toHaveBeenCalledTimes(1); + }); +}); describe("buildTrayMenuTemplate", () => { const settings = { runAtStartup: true, openToTrayOnStartup: false, trayNotifications: "failures" as const, - } + }; function makeActions() { return { @@ -143,10 +143,12 @@ describe("buildTrayMenuTemplate", () => { toggleOpenToTray: vi.fn(), installUpdate: vi.fn(), quit: vi.fn(), - } + }; } - function model(partial: Partial[0]>) { + function model( + partial: Partial[0]>, + ) { return { workspaces: [], pendingStops: new Set(), @@ -154,17 +156,19 @@ describe("buildTrayMenuTemplate", () => { updateStatus: { state: "idle" as const, currentVersion: "1.0.0" }, settings, ...partial, - } + }; } - function labels(items: Electron.MenuItemConstructorOptions[]): (string | undefined)[] { - return items.map((item) => item.label) + function labels( + items: Electron.MenuItemConstructorOptions[], + ): (string | undefined)[] { + return items.map((item) => item.label); } it("shows an empty state when there are no workspaces", () => { - const items = buildTrayMenuTemplate(model({}), makeActions()) - expect(labels(items)).toContain("No Workspaces") - }) + const items = buildTrayMenuTemplate(model({}), makeActions()); + expect(labels(items)).toContain("No Workspaces"); + }); it("shows the running count in the header", () => { const items = buildTrayMenuTemplate( @@ -176,91 +180,79 @@ describe("buildTrayMenuTemplate", () => { ], }), makeActions(), - ) + ); expect(items[0]).toMatchObject({ label: "Devsy — 2 running workspaces", enabled: false, - }) - }) + }); + }); - it("counts JSON-encoded statuses and jobs the same as tray rows", () => { - const job = (partial: Partial): WorkspaceJob => ({ - commandId: "cmd-1", - activity: "starting", - state: "running", - phase: "Preparing", - ...partial, - }) + it("counts actually running workspaces from normalized statuses", () => { expect( - countRunningWorkspaces( - [ - { id: "a", status: '{"state":"running"}' }, - { id: "b", status: "running" }, - { id: "c", status: "stopped" }, - { id: "d", status: "running" }, - ], - { c: job({}), d: job({ state: "failed", error: "x" }) }, - ), - ).toBe(3) - }) + countRunningWorkspaces([ + { id: "a", status: '{"state":"running"}' }, + { id: "b", status: "running" }, + { id: "c", status: "stopped" }, + { id: "d", status: "running" }, + ]), + ).toBe(3); + }); it("limits the list to five most recently used workspaces with an overflow link", () => { const workspaces = Array.from({ length: 7 }, (_, i) => ({ id: `ws-${i}`, status: "stopped", - })) - const actions = makeActions() - const items = buildTrayMenuTemplate(model({ workspaces }), actions) - const rows = items.filter((item) => item.label?.startsWith("○")) - expect(rows).toHaveLength(5) + })); + const actions = makeActions(); + const items = buildTrayMenuTemplate(model({ workspaces }), actions); + const rows = items.filter((item) => item.label?.startsWith("○")); + expect(rows).toHaveLength(5); const overflow = items.find((item) => item.label?.includes("View All 7 Workspaces in Devsy"), - ) - expect(overflow).toBeDefined() - ;(overflow as { click?: () => void }).click?.() - expect(actions.showAllWorkspaces).toHaveBeenCalledTimes(1) - }) + ); + expect(overflow).toBeDefined(); + (overflow as { click?: () => void }).click?.(); + expect(actions.showAllWorkspaces).toHaveBeenCalledTimes(1); + }); it("does not add an overflow link at or under the limit", () => { const workspaces = Array.from({ length: 5 }, (_, i) => ({ id: `ws-${i}`, status: "stopped", - })) - const items = buildTrayMenuTemplate(model({ workspaces }), makeActions()) - expect( - items.some((item) => item.label?.includes("View All")), - ).toBe(false) - }) + })); + const items = buildTrayMenuTemplate(model({ workspaces }), makeActions()); + expect(items.some((item) => item.label?.includes("View All"))).toBe(false); + }); it("exposes Stop for running workspaces with text labels and state glyphs", () => { - const actions = makeActions() + const actions = makeActions(); const items = buildTrayMenuTemplate( model({ workspaces: [{ id: "api", status: "running" }] }), actions, - ) - const row = items.find((item) => item.label === "● api — Running") - expect(row).toBeDefined() - const submenu = row?.submenu as Electron.MenuItemConstructorOptions[] - const stop = submenu.find((item) => item.label === "Stop Workspace") - expect(stop).toBeDefined() - expect(stop?.enabled).not.toBe(false) - ;(stop as { click?: () => void }).click?.() - expect(actions.stopWorkspace).toHaveBeenCalledWith("api") - }) + ); + const row = items.find((item) => item.label === "● api — Running"); + expect(row).toBeDefined(); + const submenu = row?.submenu as Electron.MenuItemConstructorOptions[]; + const stop = submenu.find((item) => item.label === "Stop Workspace"); + expect(stop).toBeDefined(); + expect(stop?.enabled).not.toBe(false); + (stop as { click?: () => void }).click?.(); + expect(actions.stopWorkspace).toHaveBeenCalledWith("api"); + }); it("exposes Start for stopped workspaces", () => { - const actions = makeActions() + const actions = makeActions(); const items = buildTrayMenuTemplate( model({ workspaces: [{ id: "api", status: "stopped" }] }), actions, - ) - const row = items.find((item) => item.label === "○ api — Stopped") - const submenu = row?.submenu as Electron.MenuItemConstructorOptions[] - const start = submenu.find((item) => item.label === "Start Workspace") - expect(start).toBeDefined() - ;(start as { click?: () => void }).click?.() - expect(actions.startWorkspace).toHaveBeenCalledWith("api") - }) + ); + const row = items.find((item) => item.label === "○ api — Stopped"); + const submenu = row?.submenu as Electron.MenuItemConstructorOptions[]; + const start = submenu.find((item) => item.label === "Start Workspace"); + expect(start).toBeDefined(); + (start as { click?: () => void }).click?.(); + expect(actions.startWorkspace).toHaveBeenCalledWith("api"); + }); it("offers View Logs for failed, busy, and unknown rows instead of lifecycle actions", () => { const items = buildTrayMenuTemplate( @@ -287,16 +279,26 @@ describe("buildTrayMenuTemplate", () => { }, }), makeActions(), - ) + ); const row = (name: string) => items.find((item) => item.label?.includes(name)) - ?.submenu as Electron.MenuItemConstructorOptions[] - expect(row("failed-ws").some((item) => item.label === "View Logs")).toBe(true) - expect(row("failed-ws").some((item) => item.label === "Stop Workspace")).toBe(false) - expect(row("busy-ws").some((item) => item.label === "View Logs")).toBe(true) - expect(row("busy-ws").some((item) => item.label === "Start Workspace")).toBe(false) - expect(row("unknown-ws").some((item) => item.label === "View Logs")).toBe(true) - }) + ?.submenu as Electron.MenuItemConstructorOptions[]; + expect(row("failed-ws").some((item) => item.label === "View Logs")).toBe( + true, + ); + expect( + row("failed-ws").some((item) => item.label === "Stop Workspace"), + ).toBe(false); + expect(row("busy-ws").some((item) => item.label === "View Logs")).toBe( + true, + ); + expect( + row("busy-ws").some((item) => item.label === "Start Workspace"), + ).toBe(false); + expect(row("unknown-ws").some((item) => item.label === "View Logs")).toBe( + true, + ); + }); it("marks failed rows with a failed glyph and text", () => { const items = buildTrayMenuTemplate( @@ -313,9 +315,11 @@ describe("buildTrayMenuTemplate", () => { }, }), makeActions(), - ) - expect(items.some((item) => item.label === "✖ api — Stop failed")).toBe(true) - }) + ); + expect(items.some((item) => item.label === "✖ api — Stop failed")).toBe( + true, + ); + }); it("disables Stop while a stop is pending", () => { const items = buildTrayMenuTemplate( @@ -324,12 +328,12 @@ describe("buildTrayMenuTemplate", () => { pendingStops: new Set(["api"]), }), makeActions(), - ) - const row = items.find((item) => item.label?.includes("api")) - const submenu = row?.submenu as Electron.MenuItemConstructorOptions[] - const stop = submenu.find((item) => item.label?.includes("Stop")) - expect(stop?.enabled).toBe(false) - }) + ); + const row = items.find((item) => item.label?.includes("api")); + const submenu = row?.submenu as Electron.MenuItemConstructorOptions[]; + const stop = submenu.find((item) => item.label?.includes("Stop")); + expect(stop?.enabled).toBe(false); + }); it("disables Start while a start is pending", () => { const items = buildTrayMenuTemplate( @@ -338,16 +342,16 @@ describe("buildTrayMenuTemplate", () => { pendingStarts: new Set(["api"]), }), makeActions(), - ) - const row = items.find((item) => item.label === "○ api — Stopped") - const submenu = row?.submenu as Electron.MenuItemConstructorOptions[] - const start = submenu.find((item) => item.label?.includes("Start")) - expect(start?.enabled).toBe(false) - expect(start?.click).toBeUndefined() - }) + ); + const row = items.find((item) => item.label === "○ api — Stopped"); + const submenu = row?.submenu as Electron.MenuItemConstructorOptions[]; + const start = submenu.find((item) => item.label?.includes("Start")); + expect(start?.enabled).toBe(false); + expect(start?.click).toBeUndefined(); + }); it("mirrors the startup toggles as native checkboxes", () => { - const actions = makeActions() + const actions = makeActions(); const items = buildTrayMenuTemplate( model({ settings: { @@ -357,24 +361,26 @@ describe("buildTrayMenuTemplate", () => { }, }), actions, - ) - const prefs = items.find((item) => item.label === "Preferences") - const submenu = prefs?.submenu as Electron.MenuItemConstructorOptions[] - const runAtStartup = submenu.find((item) => item.label === "Run at Startup") + ); + const prefs = items.find((item) => item.label === "Preferences"); + const submenu = prefs?.submenu as Electron.MenuItemConstructorOptions[]; + const runAtStartup = submenu.find( + (item) => item.label === "Run at Startup", + ); const openToTray = submenu.find( (item) => item.label === "Open to Tray on Startup", - ) - expect(runAtStartup).toMatchObject({ type: "checkbox", checked: true }) + ); + expect(runAtStartup).toMatchObject({ type: "checkbox", checked: true }); expect(openToTray).toMatchObject({ type: "checkbox", checked: false, enabled: true, - }) - ;(runAtStartup as { click?: () => void }).click?.() - expect(actions.toggleRunAtStartup).toHaveBeenCalledTimes(1) - ;(openToTray as { click?: () => void }).click?.() - expect(actions.toggleOpenToTray).toHaveBeenCalledTimes(1) - }) + }); + (runAtStartup as { click?: () => void }).click?.(); + expect(actions.toggleRunAtStartup).toHaveBeenCalledTimes(1); + (openToTray as { click?: () => void }).click?.(); + expect(actions.toggleOpenToTray).toHaveBeenCalledTimes(1); + }); it("disables the dependent toggle when run at startup is off", () => { const items = buildTrayMenuTemplate( @@ -386,23 +392,25 @@ describe("buildTrayMenuTemplate", () => { }, }), makeActions(), - ) - const prefs = items.find((item) => item.label === "Preferences") - const submenu = prefs?.submenu as Electron.MenuItemConstructorOptions[] + ); + const prefs = items.find((item) => item.label === "Preferences"); + const submenu = prefs?.submenu as Electron.MenuItemConstructorOptions[]; expect( submenu.find((item) => item.label === "Open to Tray on Startup")?.enabled, - ).toBe(false) - }) + ).toBe(false); + }); it("links into DevSy settings from the preferences submenu", () => { - const actions = makeActions() - const items = buildTrayMenuTemplate(model({}), actions) - const prefs = items.find((item) => item.label === "Preferences") - const submenu = prefs?.submenu as Electron.MenuItemConstructorOptions[] - const openSettings = submenu.find((item) => item.label === "Open Settings…") - ;(openSettings as { click?: () => void }).click?.() - expect(actions.showSettings).toHaveBeenCalledTimes(1) - }) + const actions = makeActions(); + const items = buildTrayMenuTemplate(model({}), actions); + const prefs = items.find((item) => item.label === "Preferences"); + const submenu = prefs?.submenu as Electron.MenuItemConstructorOptions[]; + const openSettings = submenu.find( + (item) => item.label === "Open Settings…", + ); + (openSettings as { click?: () => void }).click?.(); + expect(actions.showSettings).toHaveBeenCalledTimes(1); + }); it("keeps update items and Quit", () => { const items = buildTrayMenuTemplate( @@ -414,37 +422,43 @@ describe("buildTrayMenuTemplate", () => { }, }), makeActions(), - ) - expect(labels(items)).toContain("Update to 9.9.9") - expect(labels(items)).toContain("Quit Devsy") - }) -}) + ); + expect(labels(items)).toContain("Update to 9.9.9"); + expect(labels(items)).toContain("Quit Devsy"); + }); +}); describe("trayWorkspaceState", () => { it("maps statuses and jobs to row states", async () => { - const { trayWorkspaceState } = await import("../tray.js") - expect(trayWorkspaceState({ id: "a", status: "running" })).toBe("running") - expect(trayWorkspaceState({ id: "a", status: "stopped" })).toBe("stopped") - expect( - trayWorkspaceState({ id: "a", status: '{"state":"stopped"}' }), - ).toBe("stopped") - expect(trayWorkspaceState({ id: "a" })).toBe("unknown") + const { trayWorkspaceState } = await import("../tray.js"); + expect(trayWorkspaceState({ id: "a", status: "running" })).toBe("running"); + expect(trayWorkspaceState({ id: "a", status: "stopped" })).toBe("stopped"); + expect(trayWorkspaceState({ id: "a", status: '{"state":"stopped"}' })).toBe( + "stopped", + ); + expect(trayWorkspaceState({ id: "a" })).toBe("unknown"); expect( - trayWorkspaceState({ id: "a", status: "stopped" }, { - commandId: "c", - activity: "starting", - state: "running", - phase: "Building", - }), - ).toBe("busy") + trayWorkspaceState( + { id: "a", status: "stopped" }, + { + commandId: "c", + activity: "starting", + state: "running", + phase: "Building", + }, + ), + ).toBe("busy"); expect( - trayWorkspaceState({ id: "a", status: "stopped" }, { - commandId: "c", - activity: "starting", - state: "failed", - phase: "", - error: "x", - }), - ).toBe("failed") - }) -}) + trayWorkspaceState( + { id: "a", status: "stopped" }, + { + commandId: "c", + activity: "starting", + state: "failed", + phase: "", + error: "x", + }, + ), + ).toBe("failed"); + }); +}); From 77b713e36cff5dc0292bc39bb17b598f0db1fa9e Mon Sep 17 00:00:00 2001 From: Samuel K Date: Tue, 22 Sep 2026 04:13:58 -0600 Subject: [PATCH 17/43] style(desktop): restore biome formatting in the tray module Signed-off-by: Samuel K --- desktop/src/main/tray.ts | 270 +++++++++++++++++++-------------------- 1 file changed, 133 insertions(+), 137 deletions(-) diff --git a/desktop/src/main/tray.ts b/desktop/src/main/tray.ts index 1e91cf431..442822fc2 100644 --- a/desktop/src/main/tray.ts +++ b/desktop/src/main/tray.ts @@ -1,61 +1,63 @@ +import { join } from "node:path" +import { app, Menu, nativeImage, nativeTheme, Tray } from "electron" import { + type WorkspaceJob, workspaceJobBusy, workspaceJobInterruptible, workspaceJobLabel, - type WorkspaceJob, -} from "../shared/workspace-operation.js"; -import type { WorkspaceJobs } from "./workspace-jobs.js"; -import { join } from "node:path"; -import { app, Menu, nativeImage, nativeTheme, Tray } from "electron"; -import type { AppSettings } from "./app-settings.js"; -import type { DaemonState, Workspace } from "./state.js"; +} from "../shared/workspace-operation.js" +import type { AppSettings } from "./app-settings.js" +import type { DaemonState, Workspace } from "./state.js" import { getLastStatus, installUpdate, onUpdateStatusChanged, type UpdateStatus, -} from "./updater.js"; +} from "./updater.js" +import type { WorkspaceJobs } from "./workspace-jobs.js" import { isActiveWorkspaceStatus, normalizeWorkspaceStatus, -} from "./workspace-status.js"; +} from "./workspace-status.js" // Native menus do not scroll well, so the tray shows only the most recently // used workspaces and links into the app for the rest. -export const TRAY_WORKSPACE_LIMIT = 5; +export const TRAY_WORKSPACE_LIMIT = 5 export function buildUpdateMenuItems( status: UpdateStatus, onInstall: () => void, ): Electron.MenuItemConstructorOptions[] { const installationFailed = - status.state === "error" && status.code === "install-failed"; - if (status.state !== "downloaded" && !installationFailed) return []; - const version = status.availableVersion ?? ""; + status.state === "error" && status.code === "install-failed" + if (status.state !== "downloaded" && !installationFailed) return [] + const version = status.availableVersion ?? "" const label = installationFailed ? `Retry Install Update v${version}` : version ? `Update to ${version}` - : "Restart"; - return [{ label, click: onInstall }, { type: "separator" }]; + : "Restart" + return [{ label, click: onInstall }, { type: "separator" }] } export type TrayWorkspaceState = - "running" | "stopped" | "failed" | "busy" | "unknown"; + | "running" + | "stopped" + | "failed" + | "busy" + | "unknown" export function trayWorkspaceState( workspace: Workspace, job?: WorkspaceJob, ): TrayWorkspaceState { - if (workspaceJobBusy(job)) return "busy"; - if (job?.error || job?.state === "failed") return "failed"; - const status = normalizeWorkspaceStatus( - workspace.status ?? "", - )?.toLowerCase(); - if (status === "running" || status === "busy") return "running"; - if (status === "stopped") return "stopped"; - if (status === "failed" || status === "error") return "failed"; - return "unknown"; + if (workspaceJobBusy(job)) return "busy" + if (job?.error || job?.state === "failed") return "failed" + const status = normalizeWorkspaceStatus(workspace.status ?? "")?.toLowerCase() + if (status === "running" || status === "busy") return "running" + if (status === "stopped") return "stopped" + if (status === "failed" || status === "error") return "failed" + return "unknown" } const STATE_GLYPHS: Record = { @@ -64,7 +66,7 @@ const STATE_GLYPHS: Record = { failed: "✖", busy: "◐", unknown: "◌", -}; +} const STATE_TEXT: Record = { running: "Running", @@ -72,7 +74,7 @@ const STATE_TEXT: Record = { failed: "Failed", busy: "Busy", unknown: "Unknown", -}; +} // The header counts workspaces that are actually running, so it derives from // the normalized workspace status rather than the tray row state: a retained @@ -80,30 +82,30 @@ const STATE_TEXT: Record = { export function countRunningWorkspaces(workspaces: Workspace[]): number { return workspaces.filter((workspace) => isActiveWorkspaceStatus(normalizeWorkspaceStatus(workspace.status ?? "")), - ).length; + ).length } export interface TrayMenuModel { - workspaces: Workspace[]; - jobs?: Record; - pendingStops: ReadonlySet; - pendingStarts: ReadonlySet; - updateStatus: UpdateStatus; - settings: AppSettings; + workspaces: Workspace[] + jobs?: Record + pendingStops: ReadonlySet + pendingStarts: ReadonlySet + updateStatus: UpdateStatus + settings: AppSettings } export interface TrayMenuActions { - showDevsy: () => void; - showWorkspace: (id: string) => void; - showWorkspaceLogs: (id: string) => void; - showAllWorkspaces: () => void; - showSettings: () => void; - startWorkspace: (id: string) => void; - stopWorkspace: (id: string) => void; - toggleRunAtStartup: () => void; - toggleOpenToTray: () => void; - installUpdate: () => void; - quit: () => void; + showDevsy: () => void + showWorkspace: (id: string) => void + showWorkspaceLogs: (id: string) => void + showAllWorkspaces: () => void + showSettings: () => void + startWorkspace: (id: string) => void + stopWorkspace: (id: string) => void + toggleRunAtStartup: () => void + toggleOpenToTray: () => void + installUpdate: () => void + quit: () => void } // Action meaning lives in text labels because native menu-item images are @@ -112,8 +114,8 @@ export function buildTrayMenuTemplate( model: TrayMenuModel, actions: TrayMenuActions, ): Electron.MenuItemConstructorOptions[] { - const jobs = model.jobs ?? {}; - const running = countRunningWorkspaces(model.workspaces); + const jobs = model.jobs ?? {} + const running = countRunningWorkspaces(model.workspaces) const header: Electron.MenuItemConstructorOptions[] = [ { label: @@ -123,35 +125,31 @@ export function buildTrayMenuTemplate( enabled: false, }, { type: "separator" }, - ]; + ] - const shown = model.workspaces.slice(0, TRAY_WORKSPACE_LIMIT); + const shown = model.workspaces.slice(0, TRAY_WORKSPACE_LIMIT) const workspaceItems: Electron.MenuItemConstructorOptions[] = shown.map( (workspace) => { - const job = jobs[workspace.id]; - const state = trayWorkspaceState(workspace, job); - const pending = - model.pendingStops.has(workspace.id) || - model.pendingStarts.has(workspace.id) || - state === "busy"; - const jobLabel = workspaceJobLabel(job); + const job = jobs[workspace.id] + const state = trayWorkspaceState(workspace, job) + const jobLabel = workspaceJobLabel(job) const submenu: Electron.MenuItemConstructorOptions[] = [ { label: "Open in Devsy", click: () => actions.showWorkspace(workspace.id), }, - ]; + ] if (state === "running") { const stopping = model.pendingStops.has(workspace.id) || - (workspaceJobBusy(job) && !workspaceJobInterruptible(job)); + (workspaceJobBusy(job) && !workspaceJobInterruptible(job)) submenu.push({ label: stopping ? `${jobLabel ?? "Stopping"}…` : "Stop Workspace", enabled: !stopping, click: stopping ? undefined : () => actions.stopWorkspace(workspace.id), - }); + }) } else if (state === "stopped") { submenu.push({ label: model.pendingStarts.has(workspace.id) @@ -161,22 +159,22 @@ export function buildTrayMenuTemplate( click: model.pendingStarts.has(workspace.id) ? undefined : () => actions.startWorkspace(workspace.id), - }); + }) } if (state === "failed" || state === "busy" || state === "unknown") { submenu.push({ label: "View Logs", click: () => actions.showWorkspaceLogs(workspace.id), - }); + }) } return { label: `${STATE_GLYPHS[state]} ${workspace.id} — ${jobLabel && (state === "busy" || state === "failed") ? jobLabel : STATE_TEXT[state]}`, submenu, - }; + } }, - ); + ) if (workspaceItems.length === 0) { - workspaceItems.push({ label: "No Workspaces", enabled: false }); + workspaceItems.push({ label: "No Workspaces", enabled: false }) } else if (model.workspaces.length > TRAY_WORKSPACE_LIMIT) { workspaceItems.push( { type: "separator" }, @@ -184,7 +182,7 @@ export function buildTrayMenuTemplate( label: `View All ${model.workspaces.length} Workspaces in Devsy`, click: actions.showAllWorkspaces, }, - ); + ) } return [ @@ -215,72 +213,72 @@ export function buildTrayMenuTemplate( ...buildUpdateMenuItems(model.updateStatus, actions.installUpdate), { type: "separator" }, { label: "Quit Devsy", click: actions.quit }, - ]; + ] } interface TrayDeps { - workspaceJobs?: WorkspaceJobs; - state: DaemonState; - showDevsy: (route?: string) => void; - getSettings: () => AppSettings; - toggleRunAtStartup: () => void; - toggleOpenToTray: () => void; - startWorkspace: (workspaceId: string) => Promise; - stopWorkspace: (workspaceId: string) => Promise; - refreshWorkspace: (workspaceId: string) => Promise; - refreshWorkspaces: () => Promise; + workspaceJobs?: WorkspaceJobs + state: DaemonState + showDevsy: (route?: string) => void + getSettings: () => AppSettings + toggleRunAtStartup: () => void + toggleOpenToTray: () => void + startWorkspace: (workspaceId: string) => Promise + stopWorkspace: (workspaceId: string) => Promise + refreshWorkspace: (workspaceId: string) => Promise + refreshWorkspaces: () => Promise } export class AppTray { - private tray: Tray | null = null; - private pendingStops = new Set(); - private pendingStarts = new Set(); - private unsubscribeWorkspaceState: (() => void) | null = null; - private unsubscribeWorkspaceJobs: (() => void) | null = null; - private unsubscribeUpdateStatus: (() => void) | null = null; + private tray: Tray | null = null + private pendingStops = new Set() + private pendingStarts = new Set() + private unsubscribeWorkspaceState: (() => void) | null = null + private unsubscribeWorkspaceJobs: (() => void) | null = null + private unsubscribeUpdateStatus: (() => void) | null = null private readonly onThemeUpdated = (): void => { - this.tray?.setImage(this.createTrayIcon()); - }; + this.tray?.setImage(this.createTrayIcon()) + } constructor(private deps: TrayDeps) {} setup(): void { - if (this.tray) return; - this.tray = new Tray(this.createTrayIcon()); - this.tray.setToolTip("Devsy — No running workspaces"); + if (this.tray) return + this.tray = new Tray(this.createTrayIcon()) + this.tray.setToolTip("Devsy — No running workspaces") this.unsubscribeWorkspaceState = this.deps.state.onWorkspacesChange(() => this.rebuildMenu(), - ); + ) this.unsubscribeWorkspaceJobs = - this.deps.workspaceJobs?.onChange(() => this.rebuildMenu()) ?? null; + this.deps.workspaceJobs?.onChange(() => this.rebuildMenu()) ?? null this.unsubscribeUpdateStatus = onUpdateStatusChanged(() => this.rebuildMenu(), - ); + ) if (process.platform !== "darwin") { - nativeTheme.on("updated", this.onThemeUpdated); + nativeTheme.on("updated", this.onThemeUpdated) } - this.rebuildMenu(); + this.rebuildMenu() } destroy(): void { - this.unsubscribeWorkspaceJobs?.(); - this.unsubscribeWorkspaceJobs = null; - this.unsubscribeWorkspaceState?.(); - this.unsubscribeWorkspaceState = null; - this.unsubscribeUpdateStatus?.(); - this.unsubscribeUpdateStatus = null; + this.unsubscribeWorkspaceJobs?.() + this.unsubscribeWorkspaceJobs = null + this.unsubscribeWorkspaceState?.() + this.unsubscribeWorkspaceState = null + this.unsubscribeUpdateStatus?.() + this.unsubscribeUpdateStatus = null if (process.platform !== "darwin") { - nativeTheme.off("updated", this.onThemeUpdated); + nativeTheme.off("updated", this.onThemeUpdated) } - this.pendingStops.clear(); - this.pendingStarts.clear(); - this.tray?.destroy(); - this.tray = null; + this.pendingStops.clear() + this.pendingStarts.clear() + this.tray?.destroy() + this.tray = null } rebuildMenu(): void { - if (!this.tray) return; - const jobs = this.deps.workspaceJobs?.snapshot() ?? {}; + if (!this.tray) return + const jobs = this.deps.workspaceJobs?.snapshot() ?? {} const template = buildTrayMenuTemplate( { workspaces: this.deps.state.workspaceList(), @@ -308,71 +306,69 @@ export class AppTray { ), quit: () => app.quit(), }, - ); - this.tray.setContextMenu(Menu.buildFromTemplate(template)); - const running = countRunningWorkspaces(this.deps.state.workspaceList()); + ) + this.tray.setContextMenu(Menu.buildFromTemplate(template)) + const running = countRunningWorkspaces(this.deps.state.workspaceList()) this.tray.setToolTip( running === 0 ? "Devsy — No running workspaces" : `Devsy — ${running} running workspace${running === 1 ? "" : "s"}`, - ); + ) } private async startFromTray(workspaceId: string): Promise { - if (this.pendingStarts.has(workspaceId)) return; - this.pendingStarts.add(workspaceId); - this.rebuildMenu(); + if (this.pendingStarts.has(workspaceId)) return + this.pendingStarts.add(workspaceId) + this.rebuildMenu() try { - await this.deps.startWorkspace(workspaceId); + await this.deps.startWorkspace(workspaceId) } catch (error) { - console.warn(`[tray] failed to start workspace ${workspaceId}:`, error); + console.warn(`[tray] failed to start workspace ${workspaceId}:`, error) } finally { - await this.refreshAfterAction(workspaceId); - this.pendingStarts.delete(workspaceId); - this.rebuildMenu(); + await this.refreshAfterAction(workspaceId) + this.pendingStarts.delete(workspaceId) + this.rebuildMenu() } } private async stopFromTray(workspaceId: string): Promise { - if (this.pendingStops.has(workspaceId)) return; - this.pendingStops.add(workspaceId); - this.rebuildMenu(); + if (this.pendingStops.has(workspaceId)) return + this.pendingStops.add(workspaceId) + this.rebuildMenu() try { - await this.deps.stopWorkspace(workspaceId); + await this.deps.stopWorkspace(workspaceId) } catch (error) { - console.warn(`[tray] failed to stop workspace ${workspaceId}:`, error); + console.warn(`[tray] failed to stop workspace ${workspaceId}:`, error) } finally { - await this.refreshAfterAction(workspaceId); - this.pendingStops.delete(workspaceId); - this.rebuildMenu(); + await this.refreshAfterAction(workspaceId) + this.pendingStops.delete(workspaceId) + this.rebuildMenu() } } private async refreshAfterAction(workspaceId: string): Promise { try { - await this.deps.refreshWorkspace(workspaceId); + await this.deps.refreshWorkspace(workspaceId) } catch (error) { - console.warn(`[tray] failed to refresh workspace ${workspaceId}:`, error); + console.warn(`[tray] failed to refresh workspace ${workspaceId}:`, error) } try { - await this.deps.refreshWorkspaces(); + await this.deps.refreshWorkspaces() } catch (error) { - console.warn("[tray] failed to refresh workspaces:", error); + console.warn("[tray] failed to refresh workspaces:", error) } } private createTrayIcon(): Electron.NativeImage { - const trayDir = join(__dirname, "../../resources/tray"); + const trayDir = join(__dirname, "../../resources/tray") if (process.platform === "darwin") { const icon = nativeImage.createFromPath( join(trayDir, "icon-trayTemplate.png"), - ); - icon.setTemplateImage(true); - return icon; + ) + icon.setTemplateImage(true) + return icon } - const variant = nativeTheme.shouldUseDarkColors ? "dark" : "light"; - return nativeImage.createFromPath( - join(trayDir, `icon-tray-${variant}.png`), - ); + const variant = nativeTheme.shouldUseDarkColors ? "dark" : "light" + return nativeImage.createFromPath(join(trayDir, `icon-tray-${variant}.png`)) } } From 75531538fc669f6e1e5bfeb97c0226f5b380a1d6 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Tue, 22 Sep 2026 04:14:33 -0600 Subject: [PATCH 18/43] style(desktop): restore biome formatting in the tray tests Signed-off-by: Samuel K --- desktop/src/main/__tests__/tray.test.ts | 289 ++++++++++++------------ 1 file changed, 141 insertions(+), 148 deletions(-) diff --git a/desktop/src/main/__tests__/tray.test.ts b/desktop/src/main/__tests__/tray.test.ts index 998b41d8a..61002669b 100644 --- a/desktop/src/main/__tests__/tray.test.ts +++ b/desktop/src/main/__tests__/tray.test.ts @@ -1,17 +1,16 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest" import { buildTrayMenuTemplate, buildUpdateMenuItems, countRunningWorkspaces, -} from "../tray.js"; -import type { WorkspaceJob } from "../../shared/workspace-operation.js"; +} from "../tray.js" -vi.mock("electron", () => ({})); +vi.mock("electron", () => ({})) vi.mock("../updater.js", () => ({ getLastStatus: () => ({ state: "idle" }), installUpdate: vi.fn(), onUpdateStatusChanged: vi.fn(() => () => {}), -})); +})) describe("buildUpdateMenuItems", () => { it("returns nothing when no update is downloaded", () => { @@ -20,13 +19,13 @@ describe("buildUpdateMenuItems", () => { { state: "idle", currentVersion: "1.0.0" }, () => {}, ), - ).toEqual([]); + ).toEqual([]) expect( buildUpdateMenuItems( { state: "checking", currentVersion: "1.0.0" }, () => {}, ), - ).toEqual([]); + ).toEqual([]) expect( buildUpdateMenuItems( { @@ -36,7 +35,7 @@ describe("buildUpdateMenuItems", () => { }, () => {}, ), - ).toEqual([]); + ).toEqual([]) expect( buildUpdateMenuItems( { @@ -52,19 +51,19 @@ describe("buildUpdateMenuItems", () => { }, () => {}, ), - ).toEqual([]); + ).toEqual([]) expect( buildUpdateMenuItems( { state: "not-available", currentVersion: "1.0.0" }, () => {}, ), - ).toEqual([]); + ).toEqual([]) expect( buildUpdateMenuItems( { state: "up-to-date", currentVersion: "1.0.0" }, () => {}, ), - ).toEqual([]); + ).toEqual([]) expect( buildUpdateMenuItems( { @@ -75,11 +74,11 @@ describe("buildUpdateMenuItems", () => { }, () => {}, ), - ).toEqual([]); - }); + ).toEqual([]) + }) it("adds Update item + separator when downloaded", () => { - const onInstall = vi.fn(); + const onInstall = vi.fn() const items = buildUpdateMenuItems( { state: "downloaded", @@ -87,26 +86,26 @@ describe("buildUpdateMenuItems", () => { availableVersion: "9.9.9", }, onInstall, - ); - expect(items).toHaveLength(2); - expect(items[0]).toMatchObject({ label: "Update to 9.9.9" }); - expect(items[1]).toEqual({ type: "separator" }); + ) + expect(items).toHaveLength(2) + expect(items[0]).toMatchObject({ label: "Update to 9.9.9" }) + expect(items[1]).toEqual({ type: "separator" }) - const click = (items[0] as { click?: () => void }).click; - click?.(); - expect(onInstall).toHaveBeenCalledTimes(1); - }); + const click = (items[0] as { click?: () => void }).click + click?.() + expect(onInstall).toHaveBeenCalledTimes(1) + }) it("handles missing version gracefully", () => { const items = buildUpdateMenuItems( { state: "downloaded", currentVersion: "1.0.0", availableVersion: "" }, () => {}, - ); - expect(items[0]).toMatchObject({ label: "Restart" }); - }); + ) + expect(items[0]).toMatchObject({ label: "Restart" }) + }) it("offers retry after installation fails", () => { - const onInstall = vi.fn(); + const onInstall = vi.fn() const items = buildUpdateMenuItems( { state: "error", @@ -116,19 +115,19 @@ describe("buildUpdateMenuItems", () => { error: "install failed", }, onInstall, - ); - expect(items[0]).toMatchObject({ label: "Retry Install Update v9.9.9" }); - (items[0] as { click?: () => void }).click?.(); - expect(onInstall).toHaveBeenCalledTimes(1); - }); -}); + ) + expect(items[0]).toMatchObject({ label: "Retry Install Update v9.9.9" }) + ;(items[0] as { click?: () => void }).click?.() + expect(onInstall).toHaveBeenCalledTimes(1) + }) +}) describe("buildTrayMenuTemplate", () => { const settings = { runAtStartup: true, openToTrayOnStartup: false, trayNotifications: "failures" as const, - }; + } function makeActions() { return { @@ -143,7 +142,7 @@ describe("buildTrayMenuTemplate", () => { toggleOpenToTray: vi.fn(), installUpdate: vi.fn(), quit: vi.fn(), - }; + } } function model( @@ -156,19 +155,19 @@ describe("buildTrayMenuTemplate", () => { updateStatus: { state: "idle" as const, currentVersion: "1.0.0" }, settings, ...partial, - }; + } } function labels( items: Electron.MenuItemConstructorOptions[], ): (string | undefined)[] { - return items.map((item) => item.label); + return items.map((item) => item.label) } it("shows an empty state when there are no workspaces", () => { - const items = buildTrayMenuTemplate(model({}), makeActions()); - expect(labels(items)).toContain("No Workspaces"); - }); + const items = buildTrayMenuTemplate(model({}), makeActions()) + expect(labels(items)).toContain("No Workspaces") + }) it("shows the running count in the header", () => { const items = buildTrayMenuTemplate( @@ -180,12 +179,12 @@ describe("buildTrayMenuTemplate", () => { ], }), makeActions(), - ); + ) expect(items[0]).toMatchObject({ label: "Devsy — 2 running workspaces", enabled: false, - }); - }); + }) + }) it("counts actually running workspaces from normalized statuses", () => { expect( @@ -195,64 +194,64 @@ describe("buildTrayMenuTemplate", () => { { id: "c", status: "stopped" }, { id: "d", status: "running" }, ]), - ).toBe(3); - }); + ).toBe(3) + }) it("limits the list to five most recently used workspaces with an overflow link", () => { const workspaces = Array.from({ length: 7 }, (_, i) => ({ id: `ws-${i}`, status: "stopped", - })); - const actions = makeActions(); - const items = buildTrayMenuTemplate(model({ workspaces }), actions); - const rows = items.filter((item) => item.label?.startsWith("○")); - expect(rows).toHaveLength(5); + })) + const actions = makeActions() + const items = buildTrayMenuTemplate(model({ workspaces }), actions) + const rows = items.filter((item) => item.label?.startsWith("○")) + expect(rows).toHaveLength(5) const overflow = items.find((item) => item.label?.includes("View All 7 Workspaces in Devsy"), - ); - expect(overflow).toBeDefined(); - (overflow as { click?: () => void }).click?.(); - expect(actions.showAllWorkspaces).toHaveBeenCalledTimes(1); - }); + ) + expect(overflow).toBeDefined() + ;(overflow as { click?: () => void }).click?.() + expect(actions.showAllWorkspaces).toHaveBeenCalledTimes(1) + }) it("does not add an overflow link at or under the limit", () => { const workspaces = Array.from({ length: 5 }, (_, i) => ({ id: `ws-${i}`, status: "stopped", - })); - const items = buildTrayMenuTemplate(model({ workspaces }), makeActions()); - expect(items.some((item) => item.label?.includes("View All"))).toBe(false); - }); + })) + const items = buildTrayMenuTemplate(model({ workspaces }), makeActions()) + expect(items.some((item) => item.label?.includes("View All"))).toBe(false) + }) it("exposes Stop for running workspaces with text labels and state glyphs", () => { - const actions = makeActions(); + const actions = makeActions() const items = buildTrayMenuTemplate( model({ workspaces: [{ id: "api", status: "running" }] }), actions, - ); - const row = items.find((item) => item.label === "● api — Running"); - expect(row).toBeDefined(); - const submenu = row?.submenu as Electron.MenuItemConstructorOptions[]; - const stop = submenu.find((item) => item.label === "Stop Workspace"); - expect(stop).toBeDefined(); - expect(stop?.enabled).not.toBe(false); - (stop as { click?: () => void }).click?.(); - expect(actions.stopWorkspace).toHaveBeenCalledWith("api"); - }); + ) + const row = items.find((item) => item.label === "● api — Running") + expect(row).toBeDefined() + const submenu = row?.submenu as Electron.MenuItemConstructorOptions[] + const stop = submenu.find((item) => item.label === "Stop Workspace") + expect(stop).toBeDefined() + expect(stop?.enabled).not.toBe(false) + ;(stop as { click?: () => void }).click?.() + expect(actions.stopWorkspace).toHaveBeenCalledWith("api") + }) it("exposes Start for stopped workspaces", () => { - const actions = makeActions(); + const actions = makeActions() const items = buildTrayMenuTemplate( model({ workspaces: [{ id: "api", status: "stopped" }] }), actions, - ); - const row = items.find((item) => item.label === "○ api — Stopped"); - const submenu = row?.submenu as Electron.MenuItemConstructorOptions[]; - const start = submenu.find((item) => item.label === "Start Workspace"); - expect(start).toBeDefined(); - (start as { click?: () => void }).click?.(); - expect(actions.startWorkspace).toHaveBeenCalledWith("api"); - }); + ) + const row = items.find((item) => item.label === "○ api — Stopped") + const submenu = row?.submenu as Electron.MenuItemConstructorOptions[] + const start = submenu.find((item) => item.label === "Start Workspace") + expect(start).toBeDefined() + ;(start as { click?: () => void }).click?.() + expect(actions.startWorkspace).toHaveBeenCalledWith("api") + }) it("offers View Logs for failed, busy, and unknown rows instead of lifecycle actions", () => { const items = buildTrayMenuTemplate( @@ -279,26 +278,24 @@ describe("buildTrayMenuTemplate", () => { }, }), makeActions(), - ); + ) const row = (name: string) => items.find((item) => item.label?.includes(name)) - ?.submenu as Electron.MenuItemConstructorOptions[]; + ?.submenu as Electron.MenuItemConstructorOptions[] expect(row("failed-ws").some((item) => item.label === "View Logs")).toBe( true, - ); + ) expect( row("failed-ws").some((item) => item.label === "Stop Workspace"), - ).toBe(false); - expect(row("busy-ws").some((item) => item.label === "View Logs")).toBe( - true, - ); + ).toBe(false) + expect(row("busy-ws").some((item) => item.label === "View Logs")).toBe(true) expect( row("busy-ws").some((item) => item.label === "Start Workspace"), - ).toBe(false); + ).toBe(false) expect(row("unknown-ws").some((item) => item.label === "View Logs")).toBe( true, - ); - }); + ) + }) it("marks failed rows with a failed glyph and text", () => { const items = buildTrayMenuTemplate( @@ -315,11 +312,11 @@ describe("buildTrayMenuTemplate", () => { }, }), makeActions(), - ); + ) expect(items.some((item) => item.label === "✖ api — Stop failed")).toBe( true, - ); - }); + ) + }) it("disables Stop while a stop is pending", () => { const items = buildTrayMenuTemplate( @@ -328,12 +325,12 @@ describe("buildTrayMenuTemplate", () => { pendingStops: new Set(["api"]), }), makeActions(), - ); - const row = items.find((item) => item.label?.includes("api")); - const submenu = row?.submenu as Electron.MenuItemConstructorOptions[]; - const stop = submenu.find((item) => item.label?.includes("Stop")); - expect(stop?.enabled).toBe(false); - }); + ) + const row = items.find((item) => item.label?.includes("api")) + const submenu = row?.submenu as Electron.MenuItemConstructorOptions[] + const stop = submenu.find((item) => item.label?.includes("Stop")) + expect(stop?.enabled).toBe(false) + }) it("disables Start while a start is pending", () => { const items = buildTrayMenuTemplate( @@ -342,16 +339,16 @@ describe("buildTrayMenuTemplate", () => { pendingStarts: new Set(["api"]), }), makeActions(), - ); - const row = items.find((item) => item.label === "○ api — Stopped"); - const submenu = row?.submenu as Electron.MenuItemConstructorOptions[]; - const start = submenu.find((item) => item.label?.includes("Start")); - expect(start?.enabled).toBe(false); - expect(start?.click).toBeUndefined(); - }); + ) + const row = items.find((item) => item.label === "○ api — Stopped") + const submenu = row?.submenu as Electron.MenuItemConstructorOptions[] + const start = submenu.find((item) => item.label?.includes("Start")) + expect(start?.enabled).toBe(false) + expect(start?.click).toBeUndefined() + }) it("mirrors the startup toggles as native checkboxes", () => { - const actions = makeActions(); + const actions = makeActions() const items = buildTrayMenuTemplate( model({ settings: { @@ -361,26 +358,24 @@ describe("buildTrayMenuTemplate", () => { }, }), actions, - ); - const prefs = items.find((item) => item.label === "Preferences"); - const submenu = prefs?.submenu as Electron.MenuItemConstructorOptions[]; - const runAtStartup = submenu.find( - (item) => item.label === "Run at Startup", - ); + ) + const prefs = items.find((item) => item.label === "Preferences") + const submenu = prefs?.submenu as Electron.MenuItemConstructorOptions[] + const runAtStartup = submenu.find((item) => item.label === "Run at Startup") const openToTray = submenu.find( (item) => item.label === "Open to Tray on Startup", - ); - expect(runAtStartup).toMatchObject({ type: "checkbox", checked: true }); + ) + expect(runAtStartup).toMatchObject({ type: "checkbox", checked: true }) expect(openToTray).toMatchObject({ type: "checkbox", checked: false, enabled: true, - }); - (runAtStartup as { click?: () => void }).click?.(); - expect(actions.toggleRunAtStartup).toHaveBeenCalledTimes(1); - (openToTray as { click?: () => void }).click?.(); - expect(actions.toggleOpenToTray).toHaveBeenCalledTimes(1); - }); + }) + ;(runAtStartup as { click?: () => void }).click?.() + expect(actions.toggleRunAtStartup).toHaveBeenCalledTimes(1) + ;(openToTray as { click?: () => void }).click?.() + expect(actions.toggleOpenToTray).toHaveBeenCalledTimes(1) + }) it("disables the dependent toggle when run at startup is off", () => { const items = buildTrayMenuTemplate( @@ -392,25 +387,23 @@ describe("buildTrayMenuTemplate", () => { }, }), makeActions(), - ); - const prefs = items.find((item) => item.label === "Preferences"); - const submenu = prefs?.submenu as Electron.MenuItemConstructorOptions[]; + ) + const prefs = items.find((item) => item.label === "Preferences") + const submenu = prefs?.submenu as Electron.MenuItemConstructorOptions[] expect( submenu.find((item) => item.label === "Open to Tray on Startup")?.enabled, - ).toBe(false); - }); + ).toBe(false) + }) it("links into DevSy settings from the preferences submenu", () => { - const actions = makeActions(); - const items = buildTrayMenuTemplate(model({}), actions); - const prefs = items.find((item) => item.label === "Preferences"); - const submenu = prefs?.submenu as Electron.MenuItemConstructorOptions[]; - const openSettings = submenu.find( - (item) => item.label === "Open Settings…", - ); - (openSettings as { click?: () => void }).click?.(); - expect(actions.showSettings).toHaveBeenCalledTimes(1); - }); + const actions = makeActions() + const items = buildTrayMenuTemplate(model({}), actions) + const prefs = items.find((item) => item.label === "Preferences") + const submenu = prefs?.submenu as Electron.MenuItemConstructorOptions[] + const openSettings = submenu.find((item) => item.label === "Open Settings…") + ;(openSettings as { click?: () => void }).click?.() + expect(actions.showSettings).toHaveBeenCalledTimes(1) + }) it("keeps update items and Quit", () => { const items = buildTrayMenuTemplate( @@ -422,21 +415,21 @@ describe("buildTrayMenuTemplate", () => { }, }), makeActions(), - ); - expect(labels(items)).toContain("Update to 9.9.9"); - expect(labels(items)).toContain("Quit Devsy"); - }); -}); + ) + expect(labels(items)).toContain("Update to 9.9.9") + expect(labels(items)).toContain("Quit Devsy") + }) +}) describe("trayWorkspaceState", () => { it("maps statuses and jobs to row states", async () => { - const { trayWorkspaceState } = await import("../tray.js"); - expect(trayWorkspaceState({ id: "a", status: "running" })).toBe("running"); - expect(trayWorkspaceState({ id: "a", status: "stopped" })).toBe("stopped"); + const { trayWorkspaceState } = await import("../tray.js") + expect(trayWorkspaceState({ id: "a", status: "running" })).toBe("running") + expect(trayWorkspaceState({ id: "a", status: "stopped" })).toBe("stopped") expect(trayWorkspaceState({ id: "a", status: '{"state":"stopped"}' })).toBe( "stopped", - ); - expect(trayWorkspaceState({ id: "a" })).toBe("unknown"); + ) + expect(trayWorkspaceState({ id: "a" })).toBe("unknown") expect( trayWorkspaceState( { id: "a", status: "stopped" }, @@ -447,7 +440,7 @@ describe("trayWorkspaceState", () => { phase: "Building", }, ), - ).toBe("busy"); + ).toBe("busy") expect( trayWorkspaceState( { id: "a", status: "stopped" }, @@ -459,6 +452,6 @@ describe("trayWorkspaceState", () => { error: "x", }, ), - ).toBe("failed"); - }); -}); + ).toBe("failed") + }) +}) From 8ac6886c560cc0cb42724f75413e2300b25cf544 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Tue, 22 Sep 2026 23:36:49 -0600 Subject: [PATCH 19/43] fix(desktop): keep tray start and stop available after a failed job Signed-off-by: Samuel K --- desktop/src/main/tray.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/desktop/src/main/tray.ts b/desktop/src/main/tray.ts index 442822fc2..376ee5a5f 100644 --- a/desktop/src/main/tray.ts +++ b/desktop/src/main/tray.ts @@ -53,6 +53,10 @@ export function trayWorkspaceState( ): TrayWorkspaceState { if (workspaceJobBusy(job)) return "busy" if (job?.error || job?.state === "failed") return "failed" + return workspaceStatusState(workspace) +} + +function workspaceStatusState(workspace: Workspace): TrayWorkspaceState { const status = normalizeWorkspaceStatus(workspace.status ?? "")?.toLowerCase() if (status === "running" || status === "busy") return "running" if (status === "stopped") return "stopped" @@ -132,6 +136,10 @@ export function buildTrayMenuTemplate( (workspace) => { const job = jobs[workspace.id] const state = trayWorkspaceState(workspace, job) + // A failed start can leave the workspace stopped and a failed stop can + // leave it running, so lifecycle actions follow the workspace status. + const actionState = + state === "failed" ? workspaceStatusState(workspace) : state const jobLabel = workspaceJobLabel(job) const submenu: Electron.MenuItemConstructorOptions[] = [ { @@ -139,7 +147,7 @@ export function buildTrayMenuTemplate( click: () => actions.showWorkspace(workspace.id), }, ] - if (state === "running") { + if (actionState === "running") { const stopping = model.pendingStops.has(workspace.id) || (workspaceJobBusy(job) && !workspaceJobInterruptible(job)) @@ -150,7 +158,7 @@ export function buildTrayMenuTemplate( ? undefined : () => actions.stopWorkspace(workspace.id), }) - } else if (state === "stopped") { + } else if (actionState === "stopped") { submenu.push({ label: model.pendingStarts.has(workspace.id) ? "Starting…" From 3b5653c37400eec80ba7a86cf57d1de7b43b9d29 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Tue, 22 Sep 2026 23:37:24 -0600 Subject: [PATCH 20/43] test(desktop): cover tray lifecycle actions after a failed job Signed-off-by: Samuel K --- desktop/src/main/__tests__/tray.test.ts | 58 ++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/desktop/src/main/__tests__/tray.test.ts b/desktop/src/main/__tests__/tray.test.ts index 61002669b..28d4e47aa 100644 --- a/desktop/src/main/__tests__/tray.test.ts +++ b/desktop/src/main/__tests__/tray.test.ts @@ -253,7 +253,7 @@ describe("buildTrayMenuTemplate", () => { expect(actions.startWorkspace).toHaveBeenCalledWith("api") }) - it("offers View Logs for failed, busy, and unknown rows instead of lifecycle actions", () => { + it("offers View Logs for failed, busy, and unknown rows", () => { const items = buildTrayMenuTemplate( model({ workspaces: [ @@ -288,6 +288,9 @@ describe("buildTrayMenuTemplate", () => { expect( row("failed-ws").some((item) => item.label === "Stop Workspace"), ).toBe(false) + expect( + row("failed-ws").some((item) => item.label === "Start Workspace"), + ).toBe(true) expect(row("busy-ws").some((item) => item.label === "View Logs")).toBe(true) expect( row("busy-ws").some((item) => item.label === "Start Workspace"), @@ -297,6 +300,59 @@ describe("buildTrayMenuTemplate", () => { ) }) + it("keeps lifecycle actions available after a failed job", () => { + const actions = makeActions() + const items = buildTrayMenuTemplate( + model({ + workspaces: [ + { id: "stop-failed", status: "running" }, + { id: "start-failed", status: "stopped" }, + ], + jobs: { + "stop-failed": { + commandId: "c1", + activity: "stopping", + state: "failed", + phase: "See workspace logs for details", + error: "boom", + }, + "start-failed": { + commandId: "c2", + activity: "starting", + state: "failed", + phase: "See workspace logs for details", + error: "boom", + }, + }, + }), + actions, + ) + const row = (name: string) => + items.find((item) => item.label?.includes(name)) + const submenu = (name: string) => + row(name)?.submenu as Electron.MenuItemConstructorOptions[] + + expect(row("stop-failed")?.label).toBe("✖ stop-failed — Stop failed") + const stop = submenu("stop-failed").find( + (item) => item.label === "Stop Workspace", + ) + expect(stop?.enabled).not.toBe(false) + ;(stop as { click?: () => void }).click?.() + expect(actions.stopWorkspace).toHaveBeenCalledWith("stop-failed") + expect( + submenu("stop-failed").some((item) => item.label === "View Logs"), + ).toBe(true) + + const start = submenu("start-failed").find( + (item) => item.label === "Start Workspace", + ) + ;(start as { click?: () => void }).click?.() + expect(actions.startWorkspace).toHaveBeenCalledWith("start-failed") + expect( + submenu("start-failed").some((item) => item.label === "View Logs"), + ).toBe(true) + }) + it("marks failed rows with a failed glyph and text", () => { const items = buildTrayMenuTemplate( model({ From 7ad7d8f4108472c2b9223cb169a309c16036f245 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 23 Sep 2026 00:22:56 -0600 Subject: [PATCH 21/43] style(desktop): apply biome formatting to app settings Signed-off-by: Samuel K --- desktop/src/main/app-settings.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/desktop/src/main/app-settings.ts b/desktop/src/main/app-settings.ts index 0079ad7a4..2a4b452c3 100644 --- a/desktop/src/main/app-settings.ts +++ b/desktop/src/main/app-settings.ts @@ -1,5 +1,8 @@ import { readFileSync, renameSync, writeFileSync } from "node:fs" -import type { AppSettings, TrayNotificationLevel } from "../shared/app-settings.js" +import type { + AppSettings, + TrayNotificationLevel, +} from "../shared/app-settings.js" export type { AppSettings, TrayNotificationLevel } @@ -12,9 +15,10 @@ export const DEFAULT_APP_SETTINGS: AppSettings = { const LEVELS: readonly TrayNotificationLevel[] = ["off", "failures", "all"] export function normalizeAppSettings(raw: unknown): AppSettings { - const input = ( - typeof raw === "object" && raw !== null ? raw : {} - ) as Record + const input = (typeof raw === "object" && raw !== null ? raw : {}) as Record< + string, + unknown + > const runAtStartup = input.runAtStartup === true const openToTrayOnStartup = runAtStartup && input.openToTrayOnStartup === true const trayNotifications = LEVELS.includes( From 253cb107ff9fbc11bfd39305b096cdbb11f4d8d7 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 23 Sep 2026 00:23:13 -0600 Subject: [PATCH 22/43] style(desktop): apply biome formatting and lint fix to autostart Signed-off-by: Samuel K --- desktop/src/main/autostart.ts | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/desktop/src/main/autostart.ts b/desktop/src/main/autostart.ts index d38904ae8..11eb7d47d 100644 --- a/desktop/src/main/autostart.ts +++ b/desktop/src/main/autostart.ts @@ -73,7 +73,7 @@ export function readAutostartEnabled( try { if (env.isFlatpak) { const file = flatpakDesktopFilePath(env) - return existsSync(file) ? true : false + return existsSync(file) } return existsSync(xdgDesktopFilePath(env)) } catch { @@ -91,7 +91,11 @@ export async function applyAutostart( openAtLogin: settings.runAtStartup, openAsHidden: settings.openToTrayOnStartup, }) - return { applied: true, enabled: settings.runAtStartup, status: settings.runAtStartup ? "enabled" : "disabled" } + return { + applied: true, + enabled: settings.runAtStartup, + status: settings.runAtStartup ? "enabled" : "disabled", + } } catch (error) { return { applied: false, @@ -107,7 +111,11 @@ export async function applyAutostart( openAtLogin: settings.runAtStartup, args: [AUTO_LAUNCH_ARG], }) - return { applied: true, enabled: settings.runAtStartup, status: settings.runAtStartup ? "enabled" : "disabled" } + return { + applied: true, + enabled: settings.runAtStartup, + status: settings.runAtStartup ? "enabled" : "disabled", + } } catch (error) { return { applied: false, @@ -147,7 +155,11 @@ async function applyXdgAutostart( } else { await rm(file, { force: true }) } - return { applied: true, enabled: settings.runAtStartup, status: settings.runAtStartup ? "enabled" : "disabled" } + return { + applied: true, + enabled: settings.runAtStartup, + status: settings.runAtStartup ? "enabled" : "disabled", + } } catch (error) { return { applied: false, From ef94501183f50c31f40822bdd75d5a36845d89d5 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 23 Sep 2026 00:23:27 -0600 Subject: [PATCH 23/43] style(desktop): apply biome formatting to portal background Signed-off-by: Samuel K --- desktop/src/main/portal-background.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/desktop/src/main/portal-background.ts b/desktop/src/main/portal-background.ts index 470100aa0..d0703616b 100644 --- a/desktop/src/main/portal-background.ts +++ b/desktop/src/main/portal-background.ts @@ -49,8 +49,10 @@ export async function requestPortalBackground( message.type !== 4 || message.interface !== "org.freedesktop.portal.Request" || message.member !== "Response" || - !message.path || (requestPath !== undefined && message.path !== requestPath) - ) return + !message.path || + (requestPath !== undefined && message.path !== requestPath) + ) + return const [code, results] = message.body const parsed = parsePortalResponse( Number(code), @@ -72,7 +74,10 @@ export async function requestPortalBackground( methodOptions.commandline = new Variant("as", options.commandline) } try { - requestPath = (await background.RequestBackground("", methodOptions)) as string + requestPath = (await background.RequestBackground( + "", + methodOptions, + )) as string const pending = pendingResponses.get(requestPath) if (pending) resolveResponse?.(pending) return await response From 4636107be67c1864156cc7f6abc8e15aa87051de Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 23 Sep 2026 00:23:41 -0600 Subject: [PATCH 24/43] style(desktop): apply biome formatting to settings service Signed-off-by: Samuel K --- desktop/src/main/settings-service.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/desktop/src/main/settings-service.ts b/desktop/src/main/settings-service.ts index 738df5413..795e6dbed 100644 --- a/desktop/src/main/settings-service.ts +++ b/desktop/src/main/settings-service.ts @@ -48,7 +48,9 @@ export class SettingsService { } async update(patch: Partial): Promise { - const update = this.queue.catch(() => {}).then(() => this.applyUpdate(patch)) + const update = this.queue + .catch(() => {}) + .then(() => this.applyUpdate(patch)) this.queue = update.catch(() => {}) return update } From 367fe56768ebb1607d908b2a391b0fd772ef2549 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 23 Sep 2026 00:23:55 -0600 Subject: [PATCH 25/43] style(desktop): apply biome formatting to tray notifications Signed-off-by: Samuel K --- desktop/src/main/tray-notifications.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/desktop/src/main/tray-notifications.ts b/desktop/src/main/tray-notifications.ts index 9cf2bf5c5..92084afd5 100644 --- a/desktop/src/main/tray-notifications.ts +++ b/desktop/src/main/tray-notifications.ts @@ -1,5 +1,5 @@ -import type { TrayNotificationLevel } from "./app-settings.js" import type { WorkspaceJob } from "../shared/workspace-operation.js" +import type { TrayNotificationLevel } from "./app-settings.js" import type { UpdateStatus } from "./updater.js" export interface NotificationRequest { @@ -71,7 +71,13 @@ export function updateNotifies( status: UpdateStatus, level: TrayNotificationLevel, lastNotifiedVersion: string | undefined, -): { notifies: boolean; version?: string; key?: string; title: string; body: string } { +): { + notifies: boolean + version?: string + key?: string + title: string + body: string +} { const none = { notifies: false, title: "", body: "" } if (level === "off") return none if (!("availableVersion" in status)) return none @@ -80,7 +86,8 @@ export function updateNotifies( if (status.state === "downloaded") { if (level !== "all") return none const key = `downloaded:${version}` - if (lastNotifiedVersion === version || lastNotifiedVersion === key) return none + if (lastNotifiedVersion === version || lastNotifiedVersion === key) + return none return { notifies: true, version, @@ -132,7 +139,11 @@ export class TrayNotifier { } return } - const outcomes = collectTerminalOutcomes(this.previous, current, this.notified) + const outcomes = collectTerminalOutcomes( + this.previous, + current, + this.notified, + ) this.previous = current for (const outcome of outcomes) { this.markNotified(outcome.commandId) From 97dd42fb4f7069ac98828896021f0407b4b0287e Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 23 Sep 2026 00:24:29 -0600 Subject: [PATCH 26/43] style(desktop): apply biome formatting to app-settings tests Signed-off-by: Samuel K --- .../src/main/__tests__/app-settings.test.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/desktop/src/main/__tests__/app-settings.test.ts b/desktop/src/main/__tests__/app-settings.test.ts index aaa0e6779..7679f874d 100644 --- a/desktop/src/main/__tests__/app-settings.test.ts +++ b/desktop/src/main/__tests__/app-settings.test.ts @@ -49,9 +49,9 @@ describe("normalizeAppSettings", () => { }) it("ignores non-boolean toggles", () => { - expect( - normalizeAppSettings({ runAtStartup: "yes" }).runAtStartup, - ).toBe(false) + expect(normalizeAppSettings({ runAtStartup: "yes" }).runAtStartup).toBe( + false, + ) }) }) @@ -75,9 +75,7 @@ describe("patchAppSettings", () => { openToTrayOnStartup: false, trayNotifications: "off" as const, } - expect( - patchAppSettings(current, { openToTrayOnStartup: true }), - ).toEqual({ + expect(patchAppSettings(current, { openToTrayOnStartup: true })).toEqual({ runAtStartup: true, openToTrayOnStartup: true, trayNotifications: "off", @@ -88,7 +86,10 @@ describe("patchAppSettings", () => { describe("sanitizeAppSettingsPatch", () => { it("accepts a valid partial patch", () => { expect( - sanitizeAppSettingsPatch({ runAtStartup: true, trayNotifications: "all" }), + sanitizeAppSettingsPatch({ + runAtStartup: true, + trayNotifications: "all", + }), ).toEqual({ runAtStartup: true, trayNotifications: "all" }) }) @@ -161,7 +162,9 @@ describe("AppSettingsStore", () => { }) it("propagates write failures and keeps the previous settings", () => { - const store = new AppSettingsStore(join(dir, "missing", "app-settings.json")) + const store = new AppSettingsStore( + join(dir, "missing", "app-settings.json"), + ) store.load() let calls = 0 store.onChange(() => calls++) From 8f651c1f960a19a182a1beb7c788c4e7efd09694 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 23 Sep 2026 00:24:43 -0600 Subject: [PATCH 27/43] style(desktop): apply biome formatting to autostart tests Signed-off-by: Samuel K --- desktop/src/main/__tests__/autostart.test.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/desktop/src/main/__tests__/autostart.test.ts b/desktop/src/main/__tests__/autostart.test.ts index 529ea7f27..76585381f 100644 --- a/desktop/src/main/__tests__/autostart.test.ts +++ b/desktop/src/main/__tests__/autostart.test.ts @@ -28,7 +28,11 @@ describe("Linux autostart", () => { it("uses APPIMAGE and XDG_CONFIG_HOME for the desktop entry", async () => { dir = mkdtempSync(join(tmpdir(), "devsy-autostart-")) await applyAutostart( - { runAtStartup: true, openToTrayOnStartup: true, trayNotifications: "all" }, + { + runAtStartup: true, + openToTrayOnStartup: true, + trayNotifications: "all", + }, { platform: "linux", isFlatpak: false, @@ -39,9 +43,9 @@ describe("Linux autostart", () => { packaged: true, }, ) - expect(readFileSync(join(dir, "autostart", "devsy.desktop"), "utf8")).toContain( - 'Exec="/opt/Devsy.AppImage" "--opened-at-login"', - ) + expect( + readFileSync(join(dir, "autostart", "devsy.desktop"), "utf8"), + ).toContain('Exec="/opt/Devsy.AppImage" "--opened-at-login"') }) it("passes the autostart argument when reading Windows login status", () => { From 066ee849b2ab9b39c092dc3cc3c16aa292ebf7ad Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 23 Sep 2026 00:24:57 -0600 Subject: [PATCH 28/43] style(desktop): apply biome formatting to launch-context tests Signed-off-by: Samuel K --- desktop/src/main/__tests__/launch-context.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/desktop/src/main/__tests__/launch-context.test.ts b/desktop/src/main/__tests__/launch-context.test.ts index 917afc574..2e8fa48f1 100644 --- a/desktop/src/main/__tests__/launch-context.test.ts +++ b/desktop/src/main/__tests__/launch-context.test.ts @@ -5,7 +5,11 @@ import { shouldSuppressInitialWindow, } from "../launch-context.js" -const base = { runAtStartup: true, openToTrayOnStartup: true, trayNotifications: "failures" as const } +const base = { + runAtStartup: true, + openToTrayOnStartup: true, + trayNotifications: "failures" as const, +} describe("isAutomaticLoginLaunch", () => { it("detects the launch argument on Windows and Linux", () => { @@ -80,7 +84,11 @@ describe("shouldSuppressInitialWindow", () => { it("never suppresses when open-to-tray is off", () => { expect( - shouldSuppressInitialWindow({ ...base, openToTrayOnStartup: false }, true, true), + shouldSuppressInitialWindow( + { ...base, openToTrayOnStartup: false }, + true, + true, + ), ).toBe(false) }) From 8c1aa461ede71d8cc3e3804c342490fcab47141c Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 23 Sep 2026 00:25:11 -0600 Subject: [PATCH 29/43] style(desktop): apply biome formatting to portal-background tests Signed-off-by: Samuel K --- .../main/__tests__/portal-background.test.ts | 35 +++++++++++++------ 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/desktop/src/main/__tests__/portal-background.test.ts b/desktop/src/main/__tests__/portal-background.test.ts index d4472415f..2c3b9841c 100644 --- a/desktop/src/main/__tests__/portal-background.test.ts +++ b/desktop/src/main/__tests__/portal-background.test.ts @@ -19,7 +19,10 @@ const { bus, requestBackground } = vi.hoisted(() => { disconnect: vi.fn(), } as { on: (event: string, listener: (message: unknown) => void) => typeof value - removeListener: (event: string, listener: (message: unknown) => void) => typeof value + removeListener: ( + event: string, + listener: (message: unknown) => void, + ) => typeof value emit: (event: string, message: unknown) => void listenerCount: (event: string) => number getProxyObject: ReturnType @@ -38,7 +41,10 @@ vi.mock("dbus-next", () => ({ }, })) -import { parsePortalResponse, requestPortalBackground } from "../portal-background.js" +import { + parsePortalResponse, + requestPortalBackground, +} from "../portal-background.js" describe("portal background requests", () => { it("parses denial and cancellation responses as disabled", () => { @@ -50,14 +56,16 @@ describe("portal background requests", () => { it("subscribes before RequestBackground and accepts its returned path", async () => { const requestPath = "/org/freedesktop/portal/desktop/request/actual" - bus.getProxyObject.mockImplementation(async (_name: string, path: string) => { - if (path.endsWith("/desktop")) { - return { - getInterface: () => ({ RequestBackground: requestBackground }), + bus.getProxyObject.mockImplementation( + async (_name: string, path: string) => { + if (path.endsWith("/desktop")) { + return { + getInterface: () => ({ RequestBackground: requestBackground }), + } } - } - throw new Error(`unexpected introspection: ${path}`) - }) + throw new Error(`unexpected introspection: ${path}`) + }, + ) requestBackground.mockImplementationOnce(async () => { queueMicrotask(() => bus.emit("message", { @@ -65,7 +73,10 @@ describe("portal background requests", () => { interface: "org.freedesktop.portal.Request", member: "Response", path: requestPath, - body: [0, { background: { value: true }, autostart: { value: true } }], + body: [ + 0, + { background: { value: true }, autostart: { value: true } }, + ], }), ) return requestPath @@ -80,7 +91,9 @@ describe("portal background requests", () => { it("rejects a failed method call and still cleans up the bus", async () => { bus.getProxyObject.mockImplementationOnce(async () => ({ - getInterface: () => ({ RequestBackground: vi.fn().mockRejectedValue(new Error("denied")) }), + getInterface: () => ({ + RequestBackground: vi.fn().mockRejectedValue(new Error("denied")), + }), })) await expect( requestPortalBackground({ reason: "test", autostart: true }), From 3f7c32e90851bf8f193fd3b588931066d6200cfc Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 23 Sep 2026 00:25:29 -0600 Subject: [PATCH 30/43] style(desktop): apply biome formatting to settings-service tests Signed-off-by: Samuel K --- .../main/__tests__/settings-service.test.ts | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/desktop/src/main/__tests__/settings-service.test.ts b/desktop/src/main/__tests__/settings-service.test.ts index 70f20d1d7..cf9ce9fe7 100644 --- a/desktop/src/main/__tests__/settings-service.test.ts +++ b/desktop/src/main/__tests__/settings-service.test.ts @@ -6,8 +6,16 @@ import { AppSettingsStore, DEFAULT_APP_SETTINGS } from "../app-settings.js" import type { AutostartApplyResult } from "../autostart.js" import { SettingsService } from "../settings-service.js" -const enabled: AutostartApplyResult = { applied: true, enabled: true, status: "enabled" } -const disabled: AutostartApplyResult = { applied: true, enabled: false, status: "disabled" } +const enabled: AutostartApplyResult = { + applied: true, + enabled: true, + status: "enabled", +} +const disabled: AutostartApplyResult = { + applied: true, + enabled: false, + status: "disabled", +} describe("SettingsService", () => { let dir: string @@ -23,7 +31,11 @@ describe("SettingsService", () => { rmSync(dir, { recursive: true, force: true }) }) - function service(applyAutostart: (s: typeof DEFAULT_APP_SETTINGS) => Promise) { + function service( + applyAutostart: ( + s: typeof DEFAULT_APP_SETTINGS, + ) => Promise, + ) { const onChanged = vi.fn() const svc = new SettingsService({ store, @@ -69,7 +81,10 @@ describe("SettingsService", () => { status: "error", detail: "boom", })) - const result = await svc.update({ runAtStartup: true, openToTrayOnStartup: true }) + const result = await svc.update({ + runAtStartup: true, + openToTrayOnStartup: true, + }) expect(result.settings.runAtStartup).toBe(false) expect(store.get().runAtStartup).toBe(false) }) From 262135ae794b34a5db939564962879c8a4b90736 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 23 Sep 2026 00:25:43 -0600 Subject: [PATCH 31/43] style(desktop): apply biome formatting to tray-notifications tests Signed-off-by: Samuel K --- .../main/__tests__/tray-notifications.test.ts | 87 +++++++++++++++---- 1 file changed, 68 insertions(+), 19 deletions(-) diff --git a/desktop/src/main/__tests__/tray-notifications.test.ts b/desktop/src/main/__tests__/tray-notifications.test.ts index a77745d10..abb9ded92 100644 --- a/desktop/src/main/__tests__/tray-notifications.test.ts +++ b/desktop/src/main/__tests__/tray-notifications.test.ts @@ -2,11 +2,11 @@ import { describe, expect, it, vi } from "vitest" import type { WorkspaceJob } from "../../shared/workspace-operation.js" import { collectTerminalOutcomes, + type NotificationRequest, outcomeNotificationBody, outcomeNotifies, TrayNotifier, updateNotifies, - type NotificationRequest, } from "../tray-notifications.js" import type { UpdateStatus } from "../updater.js" @@ -25,7 +25,12 @@ describe("collectTerminalOutcomes", () => { const previous = { ws: job({ state: "running" }) } const current = { ws: job({ state: "succeeded" }) } expect(collectTerminalOutcomes(previous, current, new Set())).toEqual([ - { workspaceId: "ws", commandId: "cmd-1", activity: "starting", state: "succeeded" }, + { + workspaceId: "ws", + commandId: "cmd-1", + activity: "starting", + state: "succeeded", + }, ]) }) @@ -42,22 +47,31 @@ describe("collectTerminalOutcomes", () => { it("skips commands already notified", () => { const current = { ws: job({ state: "failed", error: "boom" }) } - expect( - collectTerminalOutcomes({}, current, new Set(["cmd-1"])), - ).toEqual([]) + expect(collectTerminalOutcomes({}, current, new Set(["cmd-1"]))).toEqual([]) }) it("treats a new command id as a new outcome", () => { const previous = { ws: job({ state: "failed", error: "boom" }) } - const current = { ws: job({ commandId: "cmd-2", state: "failed", error: "boom" }) } - const outcomes = collectTerminalOutcomes(previous, current, new Set(["cmd-1"])) + const current = { + ws: job({ commandId: "cmd-2", state: "failed", error: "boom" }), + } + const outcomes = collectTerminalOutcomes( + previous, + current, + new Set(["cmd-1"]), + ) expect(outcomes).toHaveLength(1) expect(outcomes[0].commandId).toBe("cmd-2") }) }) describe("outcomeNotifies", () => { - const failed = { workspaceId: "ws", commandId: "c", activity: "stopping" as const, state: "failed" as const } + const failed = { + workspaceId: "ws", + commandId: "c", + activity: "stopping" as const, + state: "failed" as const, + } const succeeded = { ...failed, state: "succeeded" as const } it("notifies nothing when off", () => { @@ -79,10 +93,20 @@ describe("outcomeNotifies", () => { describe("outcomeNotificationBody", () => { it("uses text verbs", () => { expect( - outcomeNotificationBody({ workspaceId: "api", commandId: "c", activity: "stopping", state: "succeeded" }), + outcomeNotificationBody({ + workspaceId: "api", + commandId: "c", + activity: "stopping", + state: "succeeded", + }), ).toBe("api: Stop completed") expect( - outcomeNotificationBody({ workspaceId: "api", commandId: "c", activity: "starting", state: "failed" }), + outcomeNotificationBody({ + workspaceId: "api", + commandId: "c", + activity: "starting", + state: "failed", + }), ).toBe("api: Start failed") }) }) @@ -99,7 +123,9 @@ describe("updateNotifies", () => { expect(first.notifies).toBe(true) expect(first.version).toBe("1.1.0") expect(updateNotifies(downloaded, "all", "1.1.0").notifies).toBe(false) - expect(updateNotifies(downloaded, "failures", undefined).notifies).toBe(false) + expect(updateNotifies(downloaded, "failures", undefined).notifies).toBe( + false, + ) }) it("never notifies when off", () => { @@ -115,7 +141,9 @@ describe("updateNotifies", () => { error: "x", } expect(updateNotifies(failed, "failures", undefined).notifies).toBe(true) - expect(updateNotifies(failed, "failures", "install-failed:1.1.0").notifies).toBe(false) + expect( + updateNotifies(failed, "failures", "install-failed:1.1.0").notifies, + ).toBe(false) }) it("allows an install failure after the update-ready notification", () => { @@ -135,12 +163,19 @@ describe("updateNotifies", () => { it("ignores other update states", () => { expect( - updateNotifies({ state: "checking", currentVersion: "1.0.0" }, "all", undefined).notifies, + updateNotifies( + { state: "checking", currentVersion: "1.0.0" }, + "all", + undefined, + ).notifies, ).toBe(false) }) }) -function makeNotifier(level: () => "off" | "failures" | "all", focused: () => boolean) { +function makeNotifier( + level: () => "off" | "failures" | "all", + focused: () => boolean, +) { const sent: NotificationRequest[] = [] const notifier = new TrayNotifier({ getLevel: level, @@ -155,7 +190,10 @@ function makeNotifier(level: () => "off" | "failures" | "all", focused: () => bo describe("TrayNotifier", () => { it("sends a notification for a failure under the default level", () => { - const { notifier, sent } = makeNotifier(() => "failures", () => false) + const { notifier, sent } = makeNotifier( + () => "failures", + () => false, + ) notifier.onJobsChanged({ ws: job({ state: "running" }) }) notifier.onJobsChanged({ ws: job({ state: "failed", error: "boom" }) }) expect(sent).toHaveLength(1) @@ -163,7 +201,10 @@ describe("TrayNotifier", () => { }) it("never notifies for jobs recovered in the initial snapshot", () => { - const { notifier, sent } = makeNotifier(() => "all", () => false) + const { notifier, sent } = makeNotifier( + () => "all", + () => false, + ) const terminal = { ws: job({ state: "failed", error: "boom" }) } notifier.onJobsChanged(terminal) notifier.onJobsChanged(terminal) @@ -171,7 +212,10 @@ describe("TrayNotifier", () => { }) it("deduplicates repeated snapshots of the same command after hydration", () => { - const { notifier, sent } = makeNotifier(() => "failures", () => false) + const { notifier, sent } = makeNotifier( + () => "failures", + () => false, + ) const terminal = { ws: job({ state: "failed", error: "boom" }) } notifier.onJobsChanged({ ws: job({ state: "running" }) }) notifier.onJobsChanged(terminal) @@ -180,7 +224,10 @@ describe("TrayNotifier", () => { }) it("suppresses notifications while the app is focused", () => { - const { notifier, sent } = makeNotifier(() => "failures", () => true) + const { notifier, sent } = makeNotifier( + () => "failures", + () => true, + ) notifier.onJobsChanged({ ws: job({ state: "running" }) }) notifier.onJobsChanged({ ws: job({ state: "failed", error: "boom" }) }) expect(sent).toHaveLength(0) @@ -202,7 +249,9 @@ describe("TrayNotifier", () => { notifier.onJobsChanged({ ws: job({ state: "failed", error: "boom" }) }) sent[0].onClick() expect(openWorkspaceLogs).toHaveBeenCalledWith("ws") - notifier.onJobsChanged({ ws: job({ commandId: "cmd-2", state: "succeeded" }) }) + notifier.onJobsChanged({ + ws: job({ commandId: "cmd-2", state: "succeeded" }), + }) sent[1].onClick() expect(openWorkspace).toHaveBeenCalledWith("ws") }) From 7fc1cdeab12a7de99f8dec9c70f674dfa4da87e5 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 23 Sep 2026 00:25:56 -0600 Subject: [PATCH 32/43] style(desktop): apply biome formatting to tray-support tests Signed-off-by: Samuel K --- desktop/src/main/__tests__/tray-support.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/desktop/src/main/__tests__/tray-support.test.ts b/desktop/src/main/__tests__/tray-support.test.ts index 21fa7465e..30dffea9d 100644 --- a/desktop/src/main/__tests__/tray-support.test.ts +++ b/desktop/src/main/__tests__/tray-support.test.ts @@ -3,13 +3,18 @@ import { isTrayHostAvailable } from "../tray-support.js" describe("isTrayHostAvailable", () => { it("treats non-Linux platforms as tray-capable", async () => { - await expect(isTrayHostAvailable({ platform: "darwin" })).resolves.toBe(true) + await expect(isTrayHostAvailable({ platform: "darwin" })).resolves.toBe( + true, + ) await expect(isTrayHostAvailable({ platform: "win32" })).resolves.toBe(true) }) it("detects a registered StatusNotifierWatcher", async () => { await expect( - isTrayHostAvailable({ platform: "linux", getNameOwner: async () => ":1.42" }), + isTrayHostAvailable({ + platform: "linux", + getNameOwner: async () => ":1.42", + }), ).resolves.toBe(true) }) From 75c0c2ba9f20875b3a9441b2eb5fc4b06d020e37 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 23 Sep 2026 01:41:09 -0600 Subject: [PATCH 33/43] fix(desktop): keep tray stop available while a start can be interrupted Signed-off-by: Samuel K --- desktop/src/main/tray.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/desktop/src/main/tray.ts b/desktop/src/main/tray.ts index 376ee5a5f..544feed3f 100644 --- a/desktop/src/main/tray.ts +++ b/desktop/src/main/tray.ts @@ -138,8 +138,13 @@ export function buildTrayMenuTemplate( const state = trayWorkspaceState(workspace, job) // A failed start can leave the workspace stopped and a failed stop can // leave it running, so lifecycle actions follow the workspace status. + // Stop can interrupt a start or create, so those busy rows keep it. const actionState = - state === "failed" ? workspaceStatusState(workspace) : state + state === "failed" + ? workspaceStatusState(workspace) + : state === "busy" && workspaceJobInterruptible(job) + ? "running" + : state const jobLabel = workspaceJobLabel(job) const submenu: Electron.MenuItemConstructorOptions[] = [ { From 53924fbd0cfd4d8d02a2df85f1e4e40cd6ad5166 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 23 Sep 2026 01:41:23 -0600 Subject: [PATCH 34/43] test(desktop): cover tray stop for interruptible starts Signed-off-by: Samuel K --- desktop/src/main/__tests__/tray.test.ts | 49 +++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/desktop/src/main/__tests__/tray.test.ts b/desktop/src/main/__tests__/tray.test.ts index 28d4e47aa..cb8cbd1d8 100644 --- a/desktop/src/main/__tests__/tray.test.ts +++ b/desktop/src/main/__tests__/tray.test.ts @@ -353,6 +353,55 @@ describe("buildTrayMenuTemplate", () => { ).toBe(true) }) + it("keeps Stop available while a start can still be interrupted", () => { + const actions = makeActions() + const items = buildTrayMenuTemplate( + model({ + workspaces: [ + { id: "starting-ws", status: "stopped" }, + { id: "stopping-ws", status: "running" }, + ], + jobs: { + "starting-ws": { + commandId: "c1", + activity: "starting", + state: "running", + phase: "Building", + }, + "stopping-ws": { + commandId: "c2", + activity: "stopping", + state: "running", + phase: "Stopping", + }, + }, + }), + actions, + ) + const submenu = (name: string) => + items.find((item) => item.label?.includes(name)) + ?.submenu as Electron.MenuItemConstructorOptions[] + + const stop = submenu("starting-ws").find( + (item) => item.label === "Stop Workspace", + ) + expect(stop?.enabled).not.toBe(false) + ;(stop as { click?: () => void }).click?.() + expect(actions.stopWorkspace).toHaveBeenCalledWith("starting-ws") + expect( + submenu("starting-ws").some((item) => item.label === "Start Workspace"), + ).toBe(false) + + expect( + submenu("stopping-ws").some( + (item) => item.label === "Stop Workspace" && item.enabled !== false, + ), + ).toBe(false) + expect( + submenu("stopping-ws").some((item) => item.label === "View Logs"), + ).toBe(true) + }) + it("marks failed rows with a failed glyph and text", () => { const items = buildTrayMenuTemplate( model({ From 409d21a260bf7a7bd6a0ae8852b2980cfa5afd06 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 23 Sep 2026 01:41:37 -0600 Subject: [PATCH 35/43] fix(desktop): open a window for deep links when none exists Signed-off-by: Samuel K --- desktop/src/main/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/desktop/src/main/index.ts b/desktop/src/main/index.ts index 82bb71cc8..882316617 100644 --- a/desktop/src/main/index.ts +++ b/desktop/src/main/index.ts @@ -50,6 +50,8 @@ function handleDeepLink(url: string): void { else pendingDeepLinks.push(url) } else { pendingDeepLinks.push(url) + // A login launch can start without a window; open one to deliver the link. + if (app.isReady()) createWindow() } } From 7ecf9848eb8087b93c74049cc8607e7bd73d1907 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 23 Sep 2026 01:50:25 -0600 Subject: [PATCH 36/43] refactor(desktop): extract tray action state selection Signed-off-by: Samuel K --- desktop/src/main/tray.ts | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/desktop/src/main/tray.ts b/desktop/src/main/tray.ts index 544feed3f..39f33f27d 100644 --- a/desktop/src/main/tray.ts +++ b/desktop/src/main/tray.ts @@ -64,6 +64,19 @@ function workspaceStatusState(workspace: Workspace): TrayWorkspaceState { return "unknown" } +// A failed start can leave the workspace stopped and a failed stop can leave it +// running, so lifecycle actions follow the workspace status. Stop can +// interrupt a start or create, so those busy rows keep it. +function trayActionState( + state: TrayWorkspaceState, + workspace: Workspace, + job?: WorkspaceJob, +): TrayWorkspaceState { + if (state === "failed") return workspaceStatusState(workspace) + if (state === "busy" && workspaceJobInterruptible(job)) return "running" + return state +} + const STATE_GLYPHS: Record = { running: "●", stopped: "○", @@ -136,15 +149,7 @@ export function buildTrayMenuTemplate( (workspace) => { const job = jobs[workspace.id] const state = trayWorkspaceState(workspace, job) - // A failed start can leave the workspace stopped and a failed stop can - // leave it running, so lifecycle actions follow the workspace status. - // Stop can interrupt a start or create, so those busy rows keep it. - const actionState = - state === "failed" - ? workspaceStatusState(workspace) - : state === "busy" && workspaceJobInterruptible(job) - ? "running" - : state + const actionState = trayActionState(state, workspace, job) const jobLabel = workspaceJobLabel(job) const submenu: Electron.MenuItemConstructorOptions[] = [ { From 0efba43d50db923f0aef4e4c2a036bbf719de433 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 23 Sep 2026 02:32:09 -0600 Subject: [PATCH 37/43] fix(desktop): avoid a second window when a deep link wins the startup race Signed-off-by: Samuel K --- desktop/src/main/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/desktop/src/main/index.ts b/desktop/src/main/index.ts index 882316617..832bcbc81 100644 --- a/desktop/src/main/index.ts +++ b/desktop/src/main/index.ts @@ -94,6 +94,8 @@ app.on("open-url", (event, url) => { }) function createWindow(): void { + // A deep link can open the window while startup is still probing the tray. + if (mainWindow && !mainWindow.isDestroyed()) return rendererReady = false mainWindow = new BrowserWindow({ width: 1200, From 7b2f232ac72fddf9544d6293a100fcb6b75339f5 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 23 Sep 2026 03:38:55 -0600 Subject: [PATCH 38/43] fix(desktop): remove the flatpak autostart entry from the host config directory Signed-off-by: Samuel K --- desktop/src/main/autostart.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/desktop/src/main/autostart.ts b/desktop/src/main/autostart.ts index 11eb7d47d..7ae616030 100644 --- a/desktop/src/main/autostart.ts +++ b/desktop/src/main/autostart.ts @@ -22,6 +22,7 @@ export interface AutostartEnvironment { packaged: boolean appImagePath?: string xdgConfigHome?: string + hostXdgConfigHome?: string } export function detectAutostartEnvironment(): AutostartEnvironment { @@ -34,6 +35,7 @@ export function detectAutostartEnvironment(): AutostartEnvironment { packaged: app.isPackaged, appImagePath: process.env.APPIMAGE, xdgConfigHome: process.env.XDG_CONFIG_HOME, + hostXdgConfigHome: process.env.HOST_XDG_CONFIG_HOME, } } @@ -45,8 +47,11 @@ function xdgDesktopFilePath(env: AutostartEnvironment): string { return join(autostartDir(env), "devsy.desktop") } +// Inside Flatpak, XDG_CONFIG_HOME points at the app sandbox, but the portal +// writes the entry to the host autostart directory. function flatpakDesktopFilePath(env: AutostartEnvironment): string { - return join(autostartDir(env), `${env.flatpakId}.desktop`) + const configHome = env.hostXdgConfigHome || join(env.homeDir, ".config") + return join(configHome, "autostart", `${env.flatpakId}.desktop`) } function desktopExecArg(value: string): string { From ae781b11ddf4556ab50e58a3a12a99ce3bda9209 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 23 Sep 2026 03:39:09 -0600 Subject: [PATCH 39/43] test(desktop): cover flatpak autostart removal from the host directory Signed-off-by: Samuel K --- desktop/src/main/__tests__/autostart.test.ts | 42 +++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/desktop/src/main/__tests__/autostart.test.ts b/desktop/src/main/__tests__/autostart.test.ts index 76585381f..296e74b8f 100644 --- a/desktop/src/main/__tests__/autostart.test.ts +++ b/desktop/src/main/__tests__/autostart.test.ts @@ -1,4 +1,11 @@ -import { mkdtempSync, readFileSync, rmSync } from "node:fs" +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" import { afterEach, describe, expect, it, vi } from "vitest" @@ -48,6 +55,39 @@ describe("Linux autostart", () => { ).toContain('Exec="/opt/Devsy.AppImage" "--opened-at-login"') }) + it("removes the Flatpak portal entry from the host autostart directory", async () => { + dir = mkdtempSync(join(tmpdir(), "devsy-autostart-")) + const hostConfig = join(dir, "host-config") + const sandboxConfig = join(dir, "sandbox-config") + const entry = join(hostConfig, "autostart", "sh.devsy.app.desktop") + mkdirSync(join(hostConfig, "autostart"), { recursive: true }) + writeFileSync(entry, "[Desktop Entry]\n") + const env = { + platform: "linux", + isFlatpak: true, + flatpakId: "sh.devsy.app", + execPath: "/app/bin/devsy", + homeDir: dir, + xdgConfigHome: sandboxConfig, + hostXdgConfigHome: hostConfig, + packaged: true, + } + expect(readAutostartEnabled(env)).toBe(true) + + const result = await applyAutostart( + { + runAtStartup: false, + openToTrayOnStartup: true, + trayNotifications: "all", + }, + env, + ) + + expect(result.status).toBe("disabled") + expect(existsSync(entry)).toBe(false) + expect(readAutostartEnabled(env)).toBe(false) + }) + it("passes the autostart argument when reading Windows login status", () => { expect( readAutostartEnabled({ From d5a7a519d7dce7518974f91222fd1a42eb253e57 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 23 Sep 2026 03:39:22 -0600 Subject: [PATCH 40/43] fix(desktop): keep newer settings events over stale write replies Signed-off-by: Samuel K --- desktop/src/renderer/src/lib/stores/settings.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/desktop/src/renderer/src/lib/stores/settings.ts b/desktop/src/renderer/src/lib/stores/settings.ts index b58b2d7f6..2eabaaa0d 100644 --- a/desktop/src/renderer/src/lib/stores/settings.ts +++ b/desktop/src/renderer/src/lib/stores/settings.ts @@ -393,13 +393,17 @@ export async function updateDesktopSettings( patch: Partial, ): Promise { const update = desktopSettingsQueue.catch(() => {}).then(async () => { + // A settings event that lands while the request is in flight is newer + // than its reply, so the reply must not overwrite it. + const revision = desktopSettingsRevision try { const state = await setAppSettings(patch) - applyAppSettingsState(state) + if (revision === desktopSettingsRevision) applyAppSettingsState(state) } catch (err) { console.warn("[settings] setAppSettings failed:", err) try { - applyAppSettingsState(await getAppSettings()) + const state = await getAppSettings() + if (revision === desktopSettingsRevision) applyAppSettingsState(state) } catch {} } }) From ab902c29bba229fab84cf524c81e70a05baf00b0 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 23 Sep 2026 03:40:01 -0600 Subject: [PATCH 41/43] test(desktop): cover settings events racing a write reply Signed-off-by: Samuel K --- .../src/lib/stores/desktop-settings.test.ts | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 desktop/src/renderer/src/lib/stores/desktop-settings.test.ts diff --git a/desktop/src/renderer/src/lib/stores/desktop-settings.test.ts b/desktop/src/renderer/src/lib/stores/desktop-settings.test.ts new file mode 100644 index 000000000..3d2ebc813 --- /dev/null +++ b/desktop/src/renderer/src/lib/stores/desktop-settings.test.ts @@ -0,0 +1,63 @@ +import { get } from "svelte/store" +import { describe, expect, it, vi } from "vitest" +import type { AppSettingsState } from "$shared/app-settings.js" + +const ipc = vi.hoisted(() => ({ + setAppSettings: vi.fn(), + getAppSettings: vi.fn(), + listener: undefined as ((state: AppSettingsState) => void) | undefined, +})) + +vi.mock("$lib/ipc/commands.js", async (importOriginal) => ({ + ...(await importOriginal()), + setAppSettings: ipc.setAppSettings, + getAppSettings: ipc.getAppSettings, +})) +vi.mock("$lib/ipc/events.js", async (importOriginal) => ({ + ...(await importOriginal()), + onAppSettingsChanged: (listener: (state: AppSettingsState) => void) => { + ipc.listener = listener + return Promise.resolve(() => {}) + }, +})) + +import { + initDesktopSettingsListener, + runAtStartup, + updateDesktopSettings, +} from "./settings.js" + +function appState(enabled: boolean): AppSettingsState { + return { + settings: { + runAtStartup: enabled, + openToTrayOnStartup: true, + trayNotifications: "failures", + }, + startup: { + applied: true, + enabled, + status: enabled ? "enabled" : "disabled", + }, + } +} + +describe("updateDesktopSettings", () => { + it("keeps a settings event that lands before the write reply", async () => { + await initDesktopSettingsListener() + let reply: (state: AppSettingsState) => void = () => {} + ipc.setAppSettings.mockReturnValueOnce( + new Promise((resolve) => { + reply = resolve + }), + ) + + const update = updateDesktopSettings({ runAtStartup: false }) + await vi.waitFor(() => expect(ipc.setAppSettings).toHaveBeenCalled()) + ipc.listener?.(appState(true)) + reply(appState(false)) + await update + + expect(get(runAtStartup)).toBe(true) + }) +}) From a5488f4b410c210a61267fc0897a32821b2e0592 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 23 Sep 2026 04:31:35 -0600 Subject: [PATCH 42/43] style(desktop): apply biome formatting and indent new settings sections Signed-off-by: Samuel K --- .../renderer/src/pages/SettingsPage.svelte | 100 +++++++++--------- 1 file changed, 50 insertions(+), 50 deletions(-) diff --git a/desktop/src/renderer/src/pages/SettingsPage.svelte b/desktop/src/renderer/src/pages/SettingsPage.svelte index 8bf4616de..6bfc04690 100644 --- a/desktop/src/renderer/src/pages/SettingsPage.svelte +++ b/desktop/src/renderer/src/pages/SettingsPage.svelte @@ -128,11 +128,12 @@ const shortcuts = [ { keys: "Escape", action: "Close dialogs and palette" }, ] -const NOTIFICATION_OPTIONS: { value: TrayNotificationLevel; label: string }[] = [ - { value: "off", label: "Off" }, - { value: "failures", label: "Failures only" }, - { value: "all", label: "All terminal outcomes" }, -] +const NOTIFICATION_OPTIONS: { value: TrayNotificationLevel; label: string }[] = + [ + { value: "off", label: "Off" }, + { value: "failures", label: "Failures only" }, + { value: "all", label: "All terminal outcomes" }, + ] onMount(() => { local = loadLocalOptions() @@ -287,65 +288,64 @@ function toggleLocal(key: keyof LocalOptions) { {/if} -

Startup

-
-
- -

Launch Devsy automatically after you sign in

+
+
+ +

Launch Devsy automatically after you sign in

+
+ updateDesktopSettings({ runAtStartup: v })} + disabled={loading} + />
- updateDesktopSettings({ runAtStartup: v })} - disabled={loading} - /> -
- {#if $startupStatus?.status === "denied" || $startupStatus?.status === "error"} -

{$startupStatus.detail}

- {/if} + {#if $startupStatus?.status === "denied" || $startupStatus?.status === "error"} +

{$startupStatus.detail}

+ {/if} -
-
- -

Start in the system tray without opening a window. Applies only when Devsy starts automatically; clicking Devsy yourself always opens the window.

+
+
+ +

Start in the system tray without opening a window. Applies only when Devsy starts automatically; clicking Devsy yourself always opens the window.

+
+ updateDesktopSettings({ openToTrayOnStartup: v })} + disabled={loading || !$runAtStartup} + />
- updateDesktopSettings({ openToTrayOnStartup: v })} - disabled={loading || !$runAtStartup} - /> -

Notifications

-
-
- -

System notifications when workspace start and stop operations finish

+
+
+ +

System notifications when workspace start and stop operations finish

+
+ { + if (v) updateDesktopSettings({ trayNotifications: v as TrayNotificationLevel }) + }} + > + + {NOTIFICATION_OPTIONS.find((o) => o.value === $trayNotifications)?.label ?? "Failures only"} + + + {#each NOTIFICATION_OPTIONS as o (o.value)} + + {/each} + +
- { - if (v) updateDesktopSettings({ trayNotifications: v as TrayNotificationLevel }) - }} - > - - {NOTIFICATION_OPTIONS.find((o) => o.value === $trayNotifications)?.label ?? "Failures only"} - - - {#each NOTIFICATION_OPTIONS as o (o.value)} - - {/each} - - -
From c579ad8829b1719c63f1ac1fb346d47b69e464bc Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 23 Sep 2026 08:35:19 -0600 Subject: [PATCH 43/43] style(desktop): format audited settings paths --- desktop/src/main/index.ts | 46 +++++++++------ .../src/renderer/src/lib/stores/settings.ts | 56 ++++++++++--------- 2 files changed, 60 insertions(+), 42 deletions(-) diff --git a/desktop/src/main/index.ts b/desktop/src/main/index.ts index 832bcbc81..76f5c9e92 100644 --- a/desktop/src/main/index.ts +++ b/desktop/src/main/index.ts @@ -9,20 +9,23 @@ import { detectAutostartEnvironment, readAutostartEnabled, } from "./autostart.js" -import { isAutomaticLoginLaunch, shouldSuppressInitialWindow } from "./launch-context.js" -import { isTrayHostAvailable } from "./tray-support.js" -import { SettingsService } from "./settings-service.js" -import { TrayNotifier } from "./tray-notifications.js" import { CliRunner } from "./cli.js" import { DaemonManager } from "./daemon-manager.js" import { registerIpcHandlers } from "./ipc.js" +import { + isAutomaticLoginLaunch, + shouldSuppressInitialWindow, +} from "./launch-context.js" import { LogStore } from "./log-store.js" -import { MachineDiagnosticsStore } from "./machine-diagnostics-store.js" import { MachineDiagnosticsManager } from "./machine-diagnostics-manager.js" +import { MachineDiagnosticsStore } from "./machine-diagnostics-store.js" import { ProviderJobs } from "./provider-jobs.js" import { PtyManager } from "./pty.js" +import { SettingsService } from "./settings-service.js" import { DaemonState } from "./state.js" import { AppTray } from "./tray.js" +import { TrayNotifier } from "./tray-notifications.js" +import { isTrayHostAvailable } from "./tray-support.js" import { initAutoUpdater, onUpdateStatusChanged, @@ -112,10 +115,7 @@ function createWindow(): void { }) mainWindow.on("close", (event) => { - if ( - mainWindow && - !isAppQuitting() - ) { + if (mainWindow && !isAppQuitting()) { event.preventDefault() mainWindow.hide() } @@ -173,8 +173,13 @@ app.whenReady().then(async () => { // ~/.devsy/contexts//workspaces// subtree that `workspace delete` // unlinks. That separation closes the file-deletion race by construction. const logStore = new LogStore(join(homedir(), ".devsy", "desktop", "logs")) - const machineDiagnosticsStore = new MachineDiagnosticsStore(join(homedir(), ".devsy", "desktop", "diagnostics")) - const machineDiagnosticsManager = new MachineDiagnosticsManager(cli, machineDiagnosticsStore) + const machineDiagnosticsStore = new MachineDiagnosticsStore( + join(homedir(), ".devsy", "desktop", "diagnostics"), + ) + const machineDiagnosticsManager = new MachineDiagnosticsManager( + cli, + machineDiagnosticsStore, + ) try { const pruned = logStore.prune(30) if (pruned > 0) console.log(`Pruned ${pruned} old log files`) @@ -264,8 +269,8 @@ app.whenReady().then(async () => { cli, state, logStore, - machineDiagnosticsStore, - machineDiagnosticsManager, + machineDiagnosticsStore, + machineDiagnosticsManager, pty: ptyManager, getMainWindow: () => mainWindow, providerJobs, @@ -283,7 +288,8 @@ app.whenReady().then(async () => { sender.send("navigate", pendingRoute) pendingRoute = null } - for (const url of pendingDeepLinks.splice(0)) sender.send("deep-link", url) + for (const url of pendingDeepLinks.splice(0)) + sender.send("deep-link", url) }, workspaceSnapshot: () => watcher?.workspaceSnapshot(), settingsService, @@ -306,7 +312,9 @@ app.whenReady().then(async () => { workspaceJobs.setRefresh(async (id, job) => { if (!watcher) throw new Error("Workspace watcher unavailable") await watcher.refreshWorkspaces() - const exists = state.workspaceList().some((workspace) => workspace.id === id) + const exists = state + .workspaceList() + .some((workspace) => workspace.id === id) if (job.activity === "deleting" && !job.error) { if (exists) throw new Error("Workspace list has not caught up yet") } else if (exists) { @@ -354,7 +362,13 @@ app.whenReady().then(async () => { wasOpenedAsHidden: loginItems?.wasOpenedAsHidden, }) const trayHostAvailable = await isTrayHostAvailable() - if (!shouldSuppressInitialWindow(appSettingsStore.get(), automaticLaunch, trayHostAvailable)) { + if ( + !shouldSuppressInitialWindow( + appSettingsStore.get(), + automaticLaunch, + trayHostAvailable, + ) + ) { createWindow() } diff --git a/desktop/src/renderer/src/lib/stores/settings.ts b/desktop/src/renderer/src/lib/stores/settings.ts index 2eabaaa0d..44bf0db27 100644 --- a/desktop/src/renderer/src/lib/stores/settings.ts +++ b/desktop/src/renderer/src/lib/stores/settings.ts @@ -164,7 +164,10 @@ export async function syncAutoUpdateFromMain(): Promise { if (browser) localStorage.setItem(AUTO_UPDATE_KEY, String(value)) autoUpdate.set(value) } catch (err) { - console.warn("[settings] getAutoDownload failed; keeping cached value:", err) + console.warn( + "[settings] getAutoDownload failed; keeping cached value:", + err, + ) } } @@ -377,14 +380,16 @@ function applyAppSettingsState(state: AppSettingsState): void { export async function syncDesktopSettingsFromMain(): Promise { const revision = desktopSettingsRevision - const sync = desktopSettingsQueue.catch(() => {}).then(async () => { - try { - const state = await getAppSettings() - if (revision === desktopSettingsRevision) applyAppSettingsState(state) - } catch (err) { - console.warn("[settings] getAppSettings failed:", err) - } - }) + const sync = desktopSettingsQueue + .catch(() => {}) + .then(async () => { + try { + const state = await getAppSettings() + if (revision === desktopSettingsRevision) applyAppSettingsState(state) + } catch (err) { + console.warn("[settings] getAppSettings failed:", err) + } + }) desktopSettingsQueue = sync.catch(() => {}) await sync } @@ -392,21 +397,23 @@ export async function syncDesktopSettingsFromMain(): Promise { export async function updateDesktopSettings( patch: Partial, ): Promise { - const update = desktopSettingsQueue.catch(() => {}).then(async () => { - // A settings event that lands while the request is in flight is newer - // than its reply, so the reply must not overwrite it. - const revision = desktopSettingsRevision - try { - const state = await setAppSettings(patch) - if (revision === desktopSettingsRevision) applyAppSettingsState(state) - } catch (err) { - console.warn("[settings] setAppSettings failed:", err) + const update = desktopSettingsQueue + .catch(() => {}) + .then(async () => { + // A settings event that lands while the request is in flight is newer + // than its reply, so the reply must not overwrite it. + const revision = desktopSettingsRevision try { - const state = await getAppSettings() + const state = await setAppSettings(patch) if (revision === desktopSettingsRevision) applyAppSettingsState(state) - } catch {} - } - }) + } catch (err) { + console.warn("[settings] setAppSettings failed:", err) + try { + const state = await getAppSettings() + if (revision === desktopSettingsRevision) applyAppSettingsState(state) + } catch {} + } + }) desktopSettingsQueue = update.catch(() => {}) await update } @@ -474,10 +481,7 @@ export function getWorkspaceFolder(workspaceId: string): string { return "" } -export function setWorkspaceFolder( - workspaceId: string, - folder: string, -): void { +export function setWorkspaceFolder(workspaceId: string, folder: string): void { if (!browser) return try { const stored = localStorage.getItem(WORKSPACE_FOLDERS_KEY)