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..05167135f --- /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 "FAIL: xfce4-panel did not claim org.kde.StatusNotifierWatcher within 30s" >&2 + exit 1 +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..7679f874d --- /dev/null +++ b/desktop/src/main/__tests__/app-settings.test.ts @@ -0,0 +1,189 @@ +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__/autostart.test.ts b/desktop/src/main/__tests__/autostart.test.ts new file mode 100644 index 000000000..296e74b8f --- /dev/null +++ b/desktop/src/main/__tests__/autostart.test.ts @@ -0,0 +1,105 @@ +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" + +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("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({ + 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__/launch-context.test.ts b/desktop/src/main/__tests__/launch-context.test.ts new file mode 100644 index 000000000..2e8fa48f1 --- /dev/null +++ b/desktop/src/main/__tests__/launch-context.test.ts @@ -0,0 +1,104 @@ +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__/portal-background.test.ts b/desktop/src/main/__tests__/portal-background.test.ts new file mode 100644 index 000000000..2c3b9841c --- /dev/null +++ b/desktop/src/main/__tests__/portal-background.test.ts @@ -0,0 +1,104 @@ +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 new file mode 100644 index 000000000..cf9ce9fe7 --- /dev/null +++ b/desktop/src/main/__tests__/settings-service.test.ts @@ -0,0 +1,295 @@ +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" +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("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 onChanged = vi.fn() + const svc = new SettingsService({ + store: badStore, + applyAutostart: async () => { + calls += 1 + if (calls > 1) + return { + applied: false, + enabled: true, + status: "error", + detail: "rollback boom", + } + return enabled + }, + currentAutostartEnabled: () => false, + onChanged, + }) + await expect(svc.update({ runAtStartup: true })).rejects.toThrow( + /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: true, + 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 () => { + 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") + }) + + 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 () => { + 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 new file mode 100644 index 000000000..abb9ded92 --- /dev/null +++ b/desktop/src/main/__tests__/tray-notifications.test.ts @@ -0,0 +1,258 @@ +import { describe, expect, it, vi } from "vitest" +import type { WorkspaceJob } from "../../shared/workspace-operation.js" +import { + collectTerminalOutcomes, + type NotificationRequest, + outcomeNotificationBody, + outcomeNotifies, + TrayNotifier, + updateNotifies, +} 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", "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", () => { + 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..30dffea9d --- /dev/null +++ b/desktop/src/main/__tests__/tray-support.test.ts @@ -0,0 +1,41 @@ +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..cb8cbd1d8 100644 --- a/desktop/src/main/__tests__/tray.test.ts +++ b/desktop/src/main/__tests__/tray.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it, vi } from "vitest" -import { buildTrayMenuTemplate, buildUpdateMenuItems } from "../tray.js" +import { + buildTrayMenuTemplate, + buildUpdateMenuItems, + countRunningWorkspaces, +} from "../tray.js" vi.mock("electron", () => ({})) vi.mock("../updater.js", () => ({ @@ -119,89 +123,440 @@ 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" }, - }, + }), + makeActions(), + ) + expect(items[0]).toMatchObject({ + label: "Devsy — 2 running workspaces", + enabled: false, + }) + }) + + 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" }, + ]), + ).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 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, ) - 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" }) + 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 an empty state and disables a pending stop", () => { - const empty = buildTrayMenuTemplate( - { - activeWorkspaces: [], - pendingStops: new Set(), - updateStatus: { state: "idle", currentVersion: "1.0.0" }, - }, + it("exposes Start for stopped workspaces", () => { + 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") + }) + + it("offers View Logs for failed, busy, and unknown rows", () => { + const items = buildTrayMenuTemplate( + model({ + workspaces: [ + { id: "failed-ws", status: "stopped" }, + { id: "busy-ws", status: "busy" }, + { id: "unknown-ws" }, + ], + jobs: { + "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", + }, + }, + }), + 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( - (empty[2].submenu as Array>)[0], - ).toMatchObject({ - label: "No Active Workspaces", - enabled: false, - }) + 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"), + ).toBe(false) + expect(row("unknown-ws").some((item) => item.label === "View Logs")).toBe( + true, + ) + }) - const pending = buildTrayMenuTemplate( - { - activeWorkspaces: [{ id: "ws-1", status: "running" }], - pendingStops: new Set(["ws-1"]), - updateStatus: { state: "idle", currentVersion: "1.0.0" }, - }, + 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 stop = ( - (pending[2].submenu as Array>)[0] - .submenu as Array> - )[1] - expect(stop).toMatchObject({ label: "Stopping…", enabled: false }) + 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("shows shared progress and permits stopping an active start", () => { + + it("keeps Stop available while a start can still be interrupted", () => { + const actions = makeActions() const items = buildTrayMenuTemplate( - { - activeWorkspaces: [{ id: "ws", status: "Stopped" }], - pendingStops: new Set(), + model({ + workspaces: [ + { id: "starting-ws", status: "stopped" }, + { id: "stopping-ws", status: "running" }, + ], jobs: { - ws: { - commandId: "start", + "starting-ws": { + commandId: "c1", activity: "starting", state: "running", - phase: "Building image", + phase: "Building", + }, + "stopping-ws": { + commandId: "c2", + activity: "stopping", + state: "running", + phase: "Stopping", }, }, - updateStatus: { state: "idle", currentVersion: "1.0.0" }, - }, + }), actions, ) - const workspace = (items[2].submenu as Array>)[0] - expect(workspace.label).toBe("ws — Starting") + 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( - (workspace.submenu as Array>)[1], - ).toMatchObject({ label: "Stop Workspace", enabled: true }) + 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({ + 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 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( + trayWorkspaceState( + { id: "a", status: "stopped" }, + { + commandId: "c", + activity: "starting", + state: "failed", + phase: "", + error: "x", + }, + ), + ).toBe("failed") }) }) 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/app-settings.ts b/desktop/src/main/app-settings.ts new file mode 100644 index 000000000..2a4b452c3 --- /dev/null +++ b/desktop/src/main/app-settings.ts @@ -0,0 +1,99 @@ +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< + string, + unknown + > + 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..7ae616030 --- /dev/null +++ b/desktop/src/main/autostart.ts @@ -0,0 +1,228 @@ +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 + appImagePath?: string + xdgConfigHome?: string + hostXdgConfigHome?: string +} + +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, + appImagePath: process.env.APPIMAGE, + xdgConfigHome: process.env.XDG_CONFIG_HOME, + hostXdgConfigHome: process.env.HOST_XDG_CONFIG_HOME, + } +} + +function autostartDir(env: AutostartEnvironment): string { + return join(env.xdgConfigHome || join(env.homeDir, ".config"), "autostart") +} + +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 { + const configHome = env.hostXdgConfigHome || join(env.homeDir, ".config") + return join(configHome, "autostart", `${env.flatpakId}.desktop`) +} + +function desktopExecArg(value: string): string { + return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"` +} + +function xdgDesktopEntry(execPath: string): string { + return [ + "[Desktop Entry]", + "Type=Application", + "Name=Devsy", + `Exec=${desktopExecArg(execPath)} ${desktopExecArg(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({ args: [AUTO_LAUNCH_ARG] }).openAtLogin + } + try { + if (env.isFlatpak) { + const file = flatpakDesktopFilePath(env) + return existsSync(file) + } + 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), { recursive: true }) + await writeFile( + file, + xdgDesktopEntry(env.appImagePath || 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..76f5c9e92 100644 --- a/desktop/src/main/index.ts +++ b/desktop/src/main/index.ts @@ -1,26 +1,43 @@ 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 { 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 { initAutoUpdater, stopAutoUpdater } from "./updater.js" +import { TrayNotifier } from "./tray-notifications.js" +import { isTrayHostAvailable } from "./tray-support.js" +import { + initAutoUpdater, + onUpdateStatusChanged, + stopAutoUpdater, +} from "./updater.js" import { Watcher } from "./watcher.js" 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 @@ -32,9 +49,12 @@ 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) + // A login launch can start without a window; open one to deliver the link. + if (app.isReady()) createWindow() } } @@ -77,6 +97,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, @@ -93,10 +115,7 @@ function createWindow(): void { }) mainWindow.on("close", (event) => { - if ( - mainWindow && - !isAppQuitting() - ) { + if (mainWindow && !isAppQuitting()) { event.preventDefault() mainWindow.hide() } @@ -113,14 +132,10 @@ function createWindow(): void { mainWindow.once("ready-to-show", () => { mainWindow?.show() - if (pendingDeepLink) { - mainWindow?.webContents.send("deep-link", pendingDeepLink) - pendingDeepLink = null - } }) } -app.whenReady().then(() => { +app.whenReady().then(async () => { initAnalytics() trackEvent("app_open") @@ -131,7 +146,7 @@ app.whenReady().then(() => { 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) => { @@ -158,8 +173,13 @@ app.whenReady().then(() => { // ~/.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`) @@ -198,6 +218,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, @@ -208,8 +269,8 @@ app.whenReady().then(() => { cli, state, logStore, - machineDiagnosticsStore, - machineDiagnosticsManager, + machineDiagnosticsStore, + machineDiagnosticsManager, pty: ptyManager, getMainWindow: () => mainWindow, providerJobs, @@ -227,8 +288,11 @@ app.whenReady().then(() => { sender.send("navigate", pendingRoute) pendingRoute = null } + for (const url of pendingDeepLinks.splice(0)) + sender.send("deep-link", url) }, workspaceSnapshot: () => watcher?.workspaceSnapshot(), + settingsService, }) // Start state watcher @@ -248,7 +312,9 @@ app.whenReady().then(() => { 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) { @@ -267,6 +333,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,16 +351,34 @@ 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) } app.on("activate", () => { - if (BrowserWindow.getAllWindows().length === 0) { - createWindow() - } + if (BrowserWindow.getAllWindows().length === 0) createWindow() + else showDevsy() }) }) 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..d0703616b --- /dev/null +++ b/desktop/src/main/portal-background.ts @@ -0,0 +1,91 @@ +import { randomUUID } from "node:crypto" +import type { Message } from "dbus-next" + +export interface PortalBackgroundResponse { + background: boolean + autostart: boolean +} + +export interface PortalBackgroundOptions { + reason: string + autostart: boolean + commandline?: string[] +} + +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, "")}` + let requestPath: string | undefined + const pendingResponses = new Map() + let resolveResponse: ((value: PortalBackgroundResponse) => void) | undefined + const response = new Promise((resolve) => { + 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), + autostart: new Variant("b", options.autostart), + } + if (options.commandline) { + methodOptions.commandline = new Variant("as", options.commandline) + } + 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 new file mode 100644 index 000000000..795e6dbed --- /dev/null +++ b/desktop/src/main/settings-service.ts @@ -0,0 +1,131 @@ +import { + type AppSettings, + type AppSettingsStore, + patchAppSettings, +} 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 +} + +export interface SettingsServiceDeps { + store: AppSettingsStore + applyAutostart: (settings: AppSettings) => Promise + currentAutostartEnabled: () => boolean | undefined + onChanged: (result: SettingsUpdateResult) => void +} + +export class SettingsService { + private queue: Promise = Promise.resolve() + + 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 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 + const startupRelevant = + next.runAtStartup !== current.runAtStartup || + 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. + const reverted = { + ...next, + runAtStartup: false, + openToTrayOnStartup: false, + } + 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(target) + } catch (err) { + if (startupRelevant) { + // 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 { + 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: ${reason}`, + ) + } + } + throw err + } + } +} diff --git a/desktop/src/main/tray-notifications.ts b/desktop/src/main/tray-notifications.ts new file mode 100644 index 000000000..92084afd5 --- /dev/null +++ b/desktop/src/main/tray-notifications.ts @@ -0,0 +1,186 @@ +import type { WorkspaceJob } from "../shared/workspace-operation.js" +import type { TrayNotificationLevel } from "./app-settings.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 + key?: string + title: string + body: string +} { + const none = { notifies: false, title: "", body: "" } + if (level === "off") 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.`, + } + } + 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 lastUpdateNotification: 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.lastUpdateNotification, + ) + if (!decision.notifies || !decision.version) return + this.lastUpdateNotification = decision.key ?? 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..2e9a0556b --- /dev/null +++ b/desktop/src/main/tray-support.ts @@ -0,0 +1,53 @@ +// 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 + let timer: ReturnType | undefined + try { + const operation = deps.getNameOwner + ? deps.getNameOwner(WATCHER_NAME) + : sessionNameOwner(WATCHER_NAME) + const owner = await Promise.race([ + 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(name: string): Promise { + const { sessionBus } = await import("dbus-next") + const bus = sessionBus() + 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/tray.ts b/desktop/src/main/tray.ts index 67471dfb5..39f33f27d 100644 --- a/desktop/src/main/tray.ts +++ b/desktop/src/main/tray.ts @@ -1,12 +1,12 @@ +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" import { getLastStatus, @@ -14,7 +14,15 @@ import { onUpdateStatusChanged, type UpdateStatus, } from "./updater.js" -import { isActiveWorkspaceStatus } from "./workspace-status.js" +import type { WorkspaceJobs } from "./workspace-jobs.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,194 @@ 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" + 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" + if (status === "failed" || status === "error") return "failed" + 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: "○", + failed: "✖", + busy: "◐", + unknown: "◌", +} + +const STATE_TEXT: Record = { + running: "Running", + stopped: "Stopped", + 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 +// 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 { - 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 pending = - model.pendingStops.has(workspace.id) || workspaceJobBusy(job) - const busy = workspace.status?.trim().toLowerCase() === "busy" - const disabled = pending && !workspaceJobInterruptible(job) - const label = workspaceJobLabel(job) + const jobs = model.jobs ?? {} + const running = countRunningWorkspaces(model.workspaces) + 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 actionState = trayActionState(state, workspace, job) + const jobLabel = workspaceJobLabel(job) + const submenu: Electron.MenuItemConstructorOptions[] = [ + { + label: "Open in Devsy", + click: () => actions.showWorkspace(workspace.id), + }, + ] + if (actionState === "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 (actionState === "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 +238,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 +250,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 +263,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 +289,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 +326,29 @@ export class AppTray { }, ) this.tray.setContextMenu(Menu.buildFromTemplate(template)) - const count = activeWorkspaces.length + const running = countRunningWorkspaces(this.deps.state.workspaceList()) 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 +358,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/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 f23e58901..e64012a0e 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() + const desktopSettingsUnlisten = await initDesktopSettingsListener() + await syncDesktopSettingsFromMain() + 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..ddb6c924b 100644 --- a/desktop/src/renderer/src/lib/ipc/commands.ts +++ b/desktop/src/renderer/src/lib/ipc/commands.ts @@ -441,3 +441,20 @@ export function analyticsTrack( ): void { invoke("analytics_track", { name, properties }).catch(() => {}) } + +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..140a54d50 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,29 @@ const COMMANDS: Record = { image_inspect_platforms: () => ["linux/amd64", "linux/arm64"], + 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/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) + }) +}) diff --git a/desktop/src/renderer/src/lib/stores/settings.ts b/desktop/src/renderer/src/lib/stores/settings.ts index bce06b10d..44bf0db27 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" @@ -152,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, + ) } } @@ -347,6 +362,70 @@ 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 desktopSettingsQueue: Promise = Promise.resolve() +let desktopSettingsRevision = 0 + +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 { + 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 { + 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) + try { + const state = await getAppSettings() + if (revision === desktopSettingsRevision) applyAppSettingsState(state) + } catch {} + } + }) + desktopSettingsQueue = update.catch(() => {}) + await update +} + +export async function initDesktopSettingsListener(): Promise<() => void> { + const unlisten = await onAppSettingsChanged((state) => { + desktopSettingsRevision++ + applyAppSettingsState(state) + }) + return unlisten +} + // ── Init ──────────────────────────────────────────────────────────── export function initSettings() { @@ -402,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) diff --git a/desktop/src/renderer/src/pages/SettingsPage.svelte b/desktop/src/renderer/src/pages/SettingsPage.svelte index e8d016acb..6bfc04690 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,18 @@ 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 +288,67 @@ 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..9aad8c1a7 100644 --- a/desktop/src/renderer/src/pages/SettingsPage.test.ts +++ b/desktop/src/renderer/src/pages/SettingsPage.test.ts @@ -12,13 +12,34 @@ 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) + 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..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("") @@ -297,6 +303,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 +}