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..d05e72544 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", @@ -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", @@ -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": { @@ -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": { @@ -4586,6 +4785,75 @@ "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", @@ -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" @@ -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", @@ -4849,6 +5176,13 @@ "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", @@ -4862,6 +5196,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,11 +5272,27 @@ "node": ">= 14" } }, - "node_modules/http2-wrapper": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", - "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", - "dev": true, + "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", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, "license": "MIT", "dependencies": { "quick-lru": "^5.1.1", @@ -4972,7 +5331,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 +5342,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 +5356,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 +5378,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 +5409,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 +5476,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 +5569,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 +5587,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 +5615,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 +5790,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5407,9 +5811,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5431,9 +5832,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5455,9 +5853,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5539,6 +5934,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 +5979,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", @@ -5626,7 +6032,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 +6042,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 +6150,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 +6332,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 +6405,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 +6522,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 +6545,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 +6572,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 +6798,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 +6837,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 +6865,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 +6891,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 +6938,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 +6986,68 @@ "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.3.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", + "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 0.12" + } + }, + "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 +7238,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 +7316,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 +7357,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 +7404,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 +7424,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 +7474,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 +7497,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 +7512,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 +7848,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 +8007,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 +8030,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 +8161,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 +8356,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 +8425,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 +9197,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 +9226,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 +9258,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 +9271,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..6db222207 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", diff --git a/desktop/scripts/tray-smoke-xfce.sh b/desktop/scripts/tray-smoke-xfce.sh new file mode 100644 index 000000000..73b3c63c5 --- /dev/null +++ b/desktop/scripts/tray-smoke-xfce.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Tier 2 Linux smoke check: launch the built desktop app under a minimal +# Xfce session (Xvfb + xfce4-panel) and assert the tray icon registers with +# the panel's StatusNotifier watcher. +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 + +dbus-run-session -- bash <<'INNER' +set -euo pipefail + +xfce4-panel & +PANEL_PID=$! + +DEVSY_DISABLE_DAEMON=true DEVSY_CLI_PATH=/bin/true npx electron . --no-sandbox & +APP_PID=$! +trap 'kill "$APP_PID" "$PANEL_PID" 2>/dev/null || true' EXIT + +# The panel needs a moment to claim org.kde.StatusNotifierWatcher. +for _ in $(seq 1 90); do + 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 + echo "tray icon registered with the Xfce panel: $ITEMS" + exit 0 + fi + sleep 1 +done + +echo "tray icon did not register with the Xfce panel" >&2 +exit 1 +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..6841481f9 --- /dev/null +++ b/desktop/src/main/__tests__/app-settings.test.ts @@ -0,0 +1,174 @@ +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("notifies listeners on save", () => { + const store = new AppSettingsStore(file) + store.load() + let calls = 0 + const off = store.onChange(() => calls++) + store.save({ ...DEFAULT_APP_SETTINGS, runAtStartup: true }) + expect(calls).toBe(1) + off() + store.save({ ...DEFAULT_APP_SETTINGS }) + expect(calls).toBe(1) + }) +}) diff --git a/desktop/src/main/__tests__/launch-context.test.ts b/desktop/src/main/__tests__/launch-context.test.ts new file mode 100644 index 000000000..79298a6ea --- /dev/null +++ b/desktop/src/main/__tests__/launch-context.test.ts @@ -0,0 +1,91 @@ +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", () => { + expect(shouldSuppressInitialWindow(base, true)).toBe(true) + }) + + it("never suppresses an explicit launch", () => { + expect(shouldSuppressInitialWindow(base, false)).toBe(false) + }) + + it("never suppresses when open-to-tray is off", () => { + expect( + shouldSuppressInitialWindow({ ...base, openToTrayOnStartup: false }, true), + ).toBe(false) + }) + + it("never suppresses when run-at-startup is off", () => { + expect( + shouldSuppressInitialWindow( + { ...base, runAtStartup: false, openToTrayOnStartup: false }, + true, + ), + ).toBe(false) + }) +}) diff --git a/desktop/src/main/__tests__/settings-service.test.ts b/desktop/src/main/__tests__/settings-service.test.ts new file mode 100644 index 000000000..2ab197d67 --- /dev/null +++ b/desktop/src/main/__tests__/settings-service.test.ts @@ -0,0 +1,103 @@ +import { mkdtempSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { AppSettingsStore, DEFAULT_APP_SETTINGS } from "../app-settings.js" +import type { AutostartApplyResult } from "../autostart.js" +import { SettingsService } from "../settings-service.js" + +const enabled: AutostartApplyResult = { applied: true, enabled: true, status: "enabled" } +const disabled: AutostartApplyResult = { applied: true, enabled: false, status: "disabled" } + +describe("SettingsService", () => { + let dir: string + let store: AppSettingsStore + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "settings-service-")) + store = new AppSettingsStore(join(dir, "app-settings.json")) + store.load() + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + function service(applyAutostart: (s: typeof DEFAULT_APP_SETTINGS) => Promise) { + const onChanged = vi.fn() + const svc = new SettingsService({ + store, + applyAutostart, + currentAutostartEnabled: () => false, + onChanged, + }) + return { svc, onChanged } + } + + it("applies autostart and persists when enabling succeeds", async () => { + const { svc, onChanged } = service(async () => enabled) + const result = await svc.update({ runAtStartup: true }) + expect(result.settings.runAtStartup).toBe(true) + expect(store.get().runAtStartup).toBe(true) + expect(onChanged).toHaveBeenCalledTimes(1) + }) + + it("does not persist run-at-startup 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(store.get().runAtStartup).toBe(false) + expect(onChanged).not.toHaveBeenCalled() + }) + + it("keeps autostart off when enabling errors", async () => { + const { svc } = service(async () => ({ + applied: false, + enabled: false, + status: "error", + detail: "boom", + })) + const result = await svc.update({ runAtStartup: true, openToTrayOnStartup: true }) + expect(result.settings.runAtStartup).toBe(false) + expect(store.get().runAtStartup).toBe(false) + }) + + it("reapplies autostart when the dependent toggle changes", async () => { + const calls: boolean[] = [] + const { svc } = service(async (s) => { + calls.push(s.openToTrayOnStartup) + return enabled + }) + await svc.update({ runAtStartup: true }) + await svc.update({ openToTrayOnStartup: true }) + expect(calls).toEqual([false, true]) + expect(store.get().openToTrayOnStartup).toBe(true) + }) + + it("does not touch autostart for unrelated changes", async () => { + const apply = vi.fn(async () => enabled) + const { svc } = service(apply) + await svc.update({ trayNotifications: "all" }) + expect(apply).not.toHaveBeenCalled() + expect(store.get().trayNotifications).toBe("all") + }) + + it("disables autostart when run-at-startup turns off", async () => { + const { svc } = service(async (s) => (s.runAtStartup ? enabled : disabled)) + await svc.update({ runAtStartup: true, openToTrayOnStartup: true }) + const result = await svc.update({ runAtStartup: false }) + expect(result.settings).toEqual({ + runAtStartup: false, + openToTrayOnStartup: false, + trayNotifications: "failures", + }) + expect(result.startup.status).toBe("disabled") + }) +}) diff --git a/desktop/src/main/__tests__/tray-notifications.test.ts b/desktop/src/main/__tests__/tray-notifications.test.ts new file mode 100644 index 000000000..bad2225d3 --- /dev/null +++ b/desktop/src/main/__tests__/tray-notifications.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it, vi } from "vitest" +import type { WorkspaceJob } from "../../shared/workspace-operation.js" +import { + collectTerminalOutcomes, + outcomeNotificationBody, + outcomeNotifies, + TrayNotifier, + updateNotifies, + type NotificationRequest, +} from "../tray-notifications.js" +import type { UpdateStatus } from "../updater.js" + +function job(partial: Partial): WorkspaceJob { + return { + commandId: "cmd-1", + activity: "starting", + state: "running", + phase: "Preparing", + ...partial, + } +} + +describe("collectTerminalOutcomes", () => { + it("emits an outcome when a job reaches a terminal state", () => { + const previous = { ws: job({ state: "running" }) } + const current = { ws: job({ state: "succeeded" }) } + expect(collectTerminalOutcomes(previous, current, new Set())).toEqual([ + { workspaceId: "ws", commandId: "cmd-1", activity: "starting", state: "succeeded" }, + ]) + }) + + it("does not emit for non-terminal states", () => { + const current = { ws: job({ state: "reconciling", refreshError: "x" }) } + expect(collectTerminalOutcomes({}, current, new Set())).toEqual([]) + }) + + it("does not re-emit for an already terminal command", () => { + const previous = { ws: job({ state: "failed", error: "boom" }) } + const current = { ws: job({ state: "failed", error: "boom" }) } + expect(collectTerminalOutcomes(previous, current, new Set())).toEqual([]) + }) + + it("skips commands already notified", () => { + const current = { ws: job({ state: "failed", error: "boom" }) } + expect( + collectTerminalOutcomes({}, current, new Set(["cmd-1"])), + ).toEqual([]) + }) + + it("treats a new command id as a new outcome", () => { + const previous = { ws: job({ state: "failed", error: "boom" }) } + const current = { ws: job({ commandId: "cmd-2", state: "failed", error: "boom" }) } + const outcomes = collectTerminalOutcomes(previous, current, new Set(["cmd-1"])) + expect(outcomes).toHaveLength(1) + expect(outcomes[0].commandId).toBe("cmd-2") + }) +}) + +describe("outcomeNotifies", () => { + const failed = { workspaceId: "ws", commandId: "c", activity: "stopping" as const, state: "failed" as const } + const succeeded = { ...failed, state: "succeeded" as const } + + it("notifies nothing when off", () => { + expect(outcomeNotifies(failed, "off")).toBe(false) + expect(outcomeNotifies(succeeded, "off")).toBe(false) + }) + + it("notifies only failures by default", () => { + expect(outcomeNotifies(failed, "failures")).toBe(true) + expect(outcomeNotifies(succeeded, "failures")).toBe(false) + }) + + it("notifies all terminal outcomes when set to all", () => { + expect(outcomeNotifies(failed, "all")).toBe(true) + expect(outcomeNotifies(succeeded, "all")).toBe(true) + }) +}) + +describe("outcomeNotificationBody", () => { + it("uses text verbs", () => { + expect( + outcomeNotificationBody({ workspaceId: "api", commandId: "c", activity: "stopping", state: "succeeded" }), + ).toBe("api: Stop completed") + expect( + outcomeNotificationBody({ workspaceId: "api", commandId: "c", activity: "starting", state: "failed" }), + ).toBe("api: Start failed") + }) +}) + +describe("updateNotifies", () => { + const downloaded: UpdateStatus = { + state: "downloaded", + currentVersion: "1.0.0", + availableVersion: "1.1.0", + } + + it("notifies once per version when enabled", () => { + const first = updateNotifies(downloaded, "failures", undefined) + expect(first.notifies).toBe(true) + expect(first.version).toBe("1.1.0") + expect(updateNotifies(downloaded, "failures", "1.1.0").notifies).toBe(false) + }) + + it("never notifies when off", () => { + expect(updateNotifies(downloaded, "off", undefined).notifies).toBe(false) + }) + + it("notifies for install failures once per version", () => { + const failed: UpdateStatus = { + state: "error", + currentVersion: "1.0.0", + availableVersion: "1.1.0", + code: "install-failed", + error: "x", + } + expect(updateNotifies(failed, "failures", undefined).notifies).toBe(true) + expect(updateNotifies(failed, "failures", "1.1.0").notifies).toBe(false) + }) + + it("ignores other update states", () => { + expect( + updateNotifies({ state: "checking", currentVersion: "1.0.0" }, "all", undefined).notifies, + ).toBe(false) + }) +}) + +function makeNotifier(level: () => "off" | "failures" | "all", focused: () => boolean) { + const sent: NotificationRequest[] = [] + const notifier = new TrayNotifier({ + getLevel: level, + isAppFocused: focused, + sink: (request) => sent.push(request), + openWorkspace: vi.fn(), + openWorkspaceLogs: vi.fn(), + openUpdates: vi.fn(), + }) + return { notifier, sent } +} + +describe("TrayNotifier", () => { + it("sends a notification for a failure under the default level", () => { + const { notifier, sent } = makeNotifier(() => "failures", () => false) + notifier.onJobsChanged({ ws: job({ state: "running" }) }) + notifier.onJobsChanged({ ws: job({ state: "failed", error: "boom" }) }) + expect(sent).toHaveLength(1) + expect(sent[0].body).toBe("ws: Start failed") + }) + + it("deduplicates repeated snapshots of the same command", () => { + const { notifier, sent } = makeNotifier(() => "failures", () => false) + const terminal = { ws: job({ state: "failed", error: "boom" }) } + 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: "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: "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.test.ts b/desktop/src/main/__tests__/tray.test.ts index aed9eb07f..9885902b8 100644 --- a/desktop/src/main/__tests__/tray.test.ts +++ b/desktop/src/main/__tests__/tray.test.ts @@ -119,89 +119,306 @@ describe("buildUpdateMenuItems", () => { }) describe("buildTrayMenuTemplate", () => { - const actions = { - showDevsy: vi.fn(), - showWorkspace: vi.fn(), - showAllWorkspaces: vi.fn(), - stopWorkspace: vi.fn(), - installUpdate: vi.fn(), - quit: vi.fn(), + const settings = { + runAtStartup: true, + openToTrayOnStartup: false, + trayNotifications: "failures" as const, } - it("shows only active workspaces with open and stop actions", () => { + function makeActions() { + return { + showDevsy: vi.fn(), + showWorkspace: vi.fn(), + showWorkspaceLogs: vi.fn(), + showAllWorkspaces: vi.fn(), + showSettings: vi.fn(), + startWorkspace: vi.fn(), + stopWorkspace: vi.fn(), + toggleRunAtStartup: vi.fn(), + toggleOpenToTray: vi.fn(), + installUpdate: vi.fn(), + quit: vi.fn(), + } + } + + function model(partial: Partial[0]>) { + return { + workspaces: [], + pendingStops: new Set(), + pendingStarts: new Set(), + updateStatus: { state: "idle" as const, currentVersion: "1.0.0" }, + settings, + ...partial, + } + } + + function labels(items: Electron.MenuItemConstructorOptions[]): (string | undefined)[] { + return items.map((item) => item.label) + } + + it("shows an empty state when there are no workspaces", () => { + const items = buildTrayMenuTemplate(model({}), makeActions()) + expect(labels(items)).toContain("No Workspaces") + }) + + it("shows the running count in the header", () => { const items = buildTrayMenuTemplate( - { - activeWorkspaces: [ - { id: "running", status: "running" }, - { id: "busy", status: "busy" }, + model({ + workspaces: [ + { id: "a", status: "running" }, + { id: "b", status: "running" }, + { id: "c", status: "stopped" }, ], - pendingStops: new Set(), - updateStatus: { state: "idle", currentVersion: "1.0.0" }, - }, - actions, - ) - expect(items[0]).toMatchObject({ label: "Show Devsy" }) - const submenu = items[2].submenu as Array> - expect(submenu[0]).toMatchObject({ label: "running" }) - expect(submenu[1]).toMatchObject({ label: "busy — Busy" }) - expect(submenu[0].submenu).toEqual([ - expect.objectContaining({ label: "Open in Devsy" }), - expect.objectContaining({ label: "Stop Workspace" }), - ]) - expect(items.at(-1)).toMatchObject({ label: "Quit Devsy" }) - }) - - it("shows an empty state and disables a pending stop", () => { - const empty = buildTrayMenuTemplate( - { - activeWorkspaces: [], - pendingStops: new Set(), - updateStatus: { state: "idle", currentVersion: "1.0.0" }, - }, - actions, + }), + makeActions(), ) - expect( - (empty[2].submenu as Array>)[0], - ).toMatchObject({ - label: "No Active Workspaces", + expect(items[0]).toMatchObject({ + label: "Devsy — 2 running workspaces", enabled: false, }) + }) - const pending = buildTrayMenuTemplate( - { - activeWorkspaces: [{ id: "ws-1", status: "running" }], - pendingStops: new Set(["ws-1"]), - updateStatus: { state: "idle", currentVersion: "1.0.0" }, - }, + it("limits the list to five most recently used workspaces with an overflow link", () => { + const workspaces = Array.from({ length: 7 }, (_, i) => ({ + id: `ws-${i}`, + status: "stopped", + })) + const actions = makeActions() + const items = buildTrayMenuTemplate(model({ workspaces }), actions) + const rows = items.filter((item) => item.label?.startsWith("○")) + expect(rows).toHaveLength(5) + const overflow = items.find((item) => + item.label?.includes("View All 7 Workspaces in Devsy"), + ) + expect(overflow).toBeDefined() + ;(overflow as { click?: () => void }).click?.() + expect(actions.showAllWorkspaces).toHaveBeenCalledTimes(1) + }) + + it("does not add an overflow link at or under the limit", () => { + const workspaces = Array.from({ length: 5 }, (_, i) => ({ + id: `ws-${i}`, + status: "stopped", + })) + const items = buildTrayMenuTemplate(model({ workspaces }), makeActions()) + expect( + items.some((item) => item.label?.includes("View All")), + ).toBe(false) + }) + + it("exposes Stop for running workspaces with text labels and state glyphs", () => { + const actions = makeActions() + const items = buildTrayMenuTemplate( + model({ workspaces: [{ id: "api", status: "running" }] }), actions, ) - const stop = ( - (pending[2].submenu as Array>)[0] - .submenu as Array> - )[1] - expect(stop).toMatchObject({ label: "Stopping…", enabled: false }) + const row = items.find((item) => item.label === "● api — Running") + expect(row).toBeDefined() + const submenu = row?.submenu as Electron.MenuItemConstructorOptions[] + const stop = submenu.find((item) => item.label === "Stop Workspace") + expect(stop).toBeDefined() + expect(stop?.enabled).not.toBe(false) + ;(stop as { click?: () => void }).click?.() + expect(actions.stopWorkspace).toHaveBeenCalledWith("api") }) - it("shows shared progress and permits stopping an active start", () => { + + it("exposes Start for stopped workspaces", () => { + const actions = makeActions() const items = buildTrayMenuTemplate( - { - activeWorkspaces: [{ id: "ws", status: "Stopped" }], - pendingStops: new Set(), + model({ workspaces: [{ id: "api", status: "stopped" }] }), + actions, + ) + const row = items.find((item) => item.label === "○ api — Stopped") + const submenu = row?.submenu as Electron.MenuItemConstructorOptions[] + const start = submenu.find((item) => item.label === "Start Workspace") + expect(start).toBeDefined() + ;(start as { click?: () => void }).click?.() + expect(actions.startWorkspace).toHaveBeenCalledWith("api") + }) + + it("offers View Logs for failed, busy, and unknown rows instead of lifecycle actions", () => { + const items = buildTrayMenuTemplate( + model({ + workspaces: [ + { id: "failed-ws", status: "stopped" }, + { id: "busy-ws", status: "busy" }, + { id: "unknown-ws" }, + ], jobs: { - ws: { - commandId: "start", + "failed-ws": { + commandId: "c1", + activity: "stopping", + state: "failed", + phase: "See workspace logs for details", + error: "boom", + }, + "busy-ws": { + commandId: "c2", activity: "starting", state: "running", - phase: "Building image", + phase: "Building", }, }, - updateStatus: { state: "idle", currentVersion: "1.0.0" }, - }, + }), + makeActions(), + ) + const row = (name: string) => + items.find((item) => item.label?.includes(name)) + ?.submenu as Electron.MenuItemConstructorOptions[] + expect(row("failed-ws").some((item) => item.label === "View Logs")).toBe(true) + expect(row("failed-ws").some((item) => item.label === "Stop Workspace")).toBe(false) + expect(row("busy-ws").some((item) => item.label === "View Logs")).toBe(true) + expect(row("busy-ws").some((item) => item.label === "Start Workspace")).toBe(false) + expect(row("unknown-ws").some((item) => item.label === "View Logs")).toBe(true) + }) + + it("marks failed rows with a failed glyph and text", () => { + const items = buildTrayMenuTemplate( + model({ + workspaces: [{ id: "api", status: "stopped" }], + jobs: { + api: { + commandId: "c1", + activity: "stopping", + state: "failed", + phase: "See workspace logs for details", + error: "boom", + }, + }, + }), + makeActions(), + ) + expect(items.some((item) => item.label === "✖ api — Stop failed")).toBe(true) + }) + + it("disables Stop while a stop is pending", () => { + const items = buildTrayMenuTemplate( + model({ + workspaces: [{ id: "api", status: "running" }], + pendingStops: new Set(["api"]), + }), + makeActions(), + ) + const row = items.find((item) => item.label?.includes("api")) + const submenu = row?.submenu as Electron.MenuItemConstructorOptions[] + const stop = submenu.find((item) => item.label?.includes("Stop")) + expect(stop?.enabled).toBe(false) + }) + + it("disables Start while a start is pending", () => { + const items = buildTrayMenuTemplate( + model({ + workspaces: [{ id: "api", status: "stopped" }], + pendingStarts: new Set(["api"]), + }), + makeActions(), + ) + const row = items.find((item) => item.label === "○ api — Stopped") + const submenu = row?.submenu as Electron.MenuItemConstructorOptions[] + const start = submenu.find((item) => item.label?.includes("Start")) + expect(start?.enabled).toBe(false) + expect(start?.click).toBeUndefined() + }) + + it("mirrors the startup toggles as native checkboxes", () => { + const actions = makeActions() + const items = buildTrayMenuTemplate( + model({ + settings: { + runAtStartup: true, + openToTrayOnStartup: false, + trayNotifications: "failures", + }, + }), actions, ) - const workspace = (items[2].submenu as Array>)[0] - expect(workspace.label).toBe("ws — Starting") + const prefs = items.find((item) => item.label === "Preferences") + const submenu = prefs?.submenu as Electron.MenuItemConstructorOptions[] + const runAtStartup = submenu.find((item) => item.label === "Run at Startup") + const openToTray = submenu.find( + (item) => item.label === "Open to Tray on Startup", + ) + expect(runAtStartup).toMatchObject({ type: "checkbox", checked: true }) + expect(openToTray).toMatchObject({ + type: "checkbox", + checked: false, + enabled: true, + }) + ;(runAtStartup as { click?: () => void }).click?.() + expect(actions.toggleRunAtStartup).toHaveBeenCalledTimes(1) + ;(openToTray as { click?: () => void }).click?.() + expect(actions.toggleOpenToTray).toHaveBeenCalledTimes(1) + }) + + it("disables the dependent toggle when run at startup is off", () => { + const items = buildTrayMenuTemplate( + model({ + settings: { + runAtStartup: false, + openToTrayOnStartup: false, + trayNotifications: "failures", + }, + }), + makeActions(), + ) + const prefs = items.find((item) => item.label === "Preferences") + const submenu = prefs?.submenu as Electron.MenuItemConstructorOptions[] + expect( + submenu.find((item) => item.label === "Open to Tray on Startup")?.enabled, + ).toBe(false) + }) + + it("links into DevSy settings from the preferences submenu", () => { + const actions = makeActions() + const items = buildTrayMenuTemplate(model({}), actions) + const prefs = items.find((item) => item.label === "Preferences") + const submenu = prefs?.submenu as Electron.MenuItemConstructorOptions[] + const openSettings = submenu.find((item) => item.label === "Open Settings…") + ;(openSettings as { click?: () => void }).click?.() + expect(actions.showSettings).toHaveBeenCalledTimes(1) + }) + + it("keeps update items and Quit", () => { + const items = buildTrayMenuTemplate( + model({ + updateStatus: { + state: "downloaded", + currentVersion: "1.0.0", + availableVersion: "9.9.9", + }, + }), + makeActions(), + ) + expect(labels(items)).toContain("Update to 9.9.9") + expect(labels(items)).toContain("Quit Devsy") + }) +}) + +describe("trayWorkspaceState", () => { + it("maps statuses and jobs to row states", async () => { + const { trayWorkspaceState } = await import("../tray.js") + expect(trayWorkspaceState({ id: "a", status: "running" })).toBe("running") + expect(trayWorkspaceState({ id: "a", status: "stopped" })).toBe("stopped") + expect( + trayWorkspaceState({ id: "a", status: '{"state":"stopped"}' }), + ).toBe("stopped") + expect(trayWorkspaceState({ id: "a" })).toBe("unknown") + expect( + trayWorkspaceState({ id: "a", status: "stopped" }, { + commandId: "c", + activity: "starting", + state: "running", + phase: "Building", + }), + ).toBe("busy") expect( - (workspace.submenu as Array>)[1], - ).toMatchObject({ label: "Stop Workspace", enabled: true }) + trayWorkspaceState({ id: "a", status: "stopped" }, { + commandId: "c", + activity: "starting", + state: "failed", + phase: "", + error: "x", + }), + ).toBe("failed") }) }) diff --git a/desktop/src/main/app-settings.ts b/desktop/src/main/app-settings.ts new file mode 100644 index 000000000..72a42abf1 --- /dev/null +++ b/desktop/src/main/app-settings.ts @@ -0,0 +1,100 @@ +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"] + +// Open-to-tray only exists as a modifier of an automatic startup launch, so +// it can never stay on while run-at-startup is off. +export function normalizeAppSettings(raw: unknown): AppSettings { + const input = ( + typeof raw === "object" && raw !== null ? raw : {} + ) as Record + const runAtStartup = input.runAtStartup === true + const openToTrayOnStartup = runAtStartup && input.openToTrayOnStartup === true + const trayNotifications = LEVELS.includes( + input.trayNotifications as TrayNotificationLevel, + ) + ? (input.trayNotifications as TrayNotificationLevel) + : DEFAULT_APP_SETTINGS.trayNotifications + return { runAtStartup, openToTrayOnStartup, trayNotifications } +} + +export function patchAppSettings( + current: AppSettings, + patch: Partial, +): AppSettings { + return normalizeAppSettings({ ...current, ...patch }) +} + +// Throws on a mistyped value so a renderer bug cannot silently persist a +// corrupt preference. +export function sanitizeAppSettingsPatch(raw: unknown): Partial { + if (!raw || typeof raw !== "object") return {} + const input = raw as Record + const patch: Partial = {} + if ("runAtStartup" in input) { + if (typeof input.runAtStartup !== "boolean") + throw new Error("runAtStartup must be boolean") + patch.runAtStartup = input.runAtStartup + } + if ("openToTrayOnStartup" in input) { + if (typeof input.openToTrayOnStartup !== "boolean") + throw new Error("openToTrayOnStartup must be boolean") + patch.openToTrayOnStartup = input.openToTrayOnStartup + } + if ("trayNotifications" in input) { + if (!LEVELS.includes(input.trayNotifications as TrayNotificationLevel)) + throw new Error("trayNotifications must be off, failures, or all") + patch.trayNotifications = input.trayNotifications as TrayNotificationLevel + } + return patch +} + +export class AppSettingsStore { + private settings: AppSettings + private listeners = new Set<() => void>() + + constructor(private readonly filePath: string) { + this.settings = DEFAULT_APP_SETTINGS + } + + load(): AppSettings { + let raw: unknown = {} + try { + raw = JSON.parse(readFileSync(this.filePath, "utf-8")) + } catch { + raw = {} + } + this.settings = normalizeAppSettings(raw) + return this.settings + } + + get(): AppSettings { + return this.settings + } + + save(next: AppSettings): void { + this.settings = normalizeAppSettings(next) + try { + const tmp = `${this.filePath}.tmp` + writeFileSync(tmp, JSON.stringify(this.settings, null, 2)) + renameSync(tmp, this.filePath) + } catch (err) { + console.warn("[app-settings] failed to persist settings:", err) + } + for (const listener of this.listeners) listener() + } + + onChange(listener: () => void): () => void { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } +} diff --git a/desktop/src/main/autostart.ts b/desktop/src/main/autostart.ts new file mode 100644 index 000000000..d5a1e049f --- /dev/null +++ b/desktop/src/main/autostart.ts @@ -0,0 +1,199 @@ +import { existsSync } from "node:fs" +import { mkdir, rm, writeFile } from "node:fs/promises" +import { join } from "node:path" +import { app } from "electron" +import type { AppSettings } from "./app-settings.js" +import { AUTO_LAUNCH_ARG } from "./launch-context.js" +import { requestPortalBackground } from "./portal-background.js" + +export interface AutostartApplyResult { + applied: boolean + enabled: boolean + status: "enabled" | "disabled" | "denied" | "unavailable" | "error" + detail?: string +} + +export interface AutostartEnvironment { + platform: string + isFlatpak: boolean + flatpakId?: string + execPath: string + homeDir: string + packaged: boolean +} + +export function detectAutostartEnvironment(): AutostartEnvironment { + return { + platform: process.platform, + isFlatpak: Boolean(process.env.FLATPAK_ID), + flatpakId: process.env.FLATPAK_ID, + execPath: process.execPath, + homeDir: app.getPath("home"), + packaged: app.isPackaged, + } +} + +function autostartDir(homeDir: string): string { + return join(homeDir, ".config", "autostart") +} + +function xdgDesktopFilePath(env: AutostartEnvironment): string { + return join(autostartDir(env.homeDir), "devsy.desktop") +} + +function flatpakDesktopFilePath(env: AutostartEnvironment): string { + return join(autostartDir(env.homeDir), `${env.flatpakId}.desktop`) +} + +function xdgDesktopEntry(execPath: string): string { + return [ + "[Desktop Entry]", + "Type=Application", + "Name=Devsy", + `Exec="${execPath}" ${AUTO_LAUNCH_ARG}`, + "X-GNOME-Autostart-enabled=true", + "", + ].join("\n") +} + +export function readAutostartEnabled( + env: AutostartEnvironment, +): boolean | undefined { + if (env.platform === "darwin" || env.platform === "win32") { + return app.getLoginItemSettings().openAtLogin + } + try { + if (env.isFlatpak) { + const file = flatpakDesktopFilePath(env) + return existsSync(file) ? true : false + } + return existsSync(xdgDesktopFilePath(env)) + } catch { + return undefined + } +} + +export async function applyAutostart( + settings: AppSettings, + env: AutostartEnvironment, +): Promise { + if (env.platform === "darwin") { + try { + app.setLoginItemSettings({ + openAtLogin: settings.runAtStartup, + openAsHidden: settings.openToTrayOnStartup, + }) + return { applied: true, enabled: settings.runAtStartup, status: settings.runAtStartup ? "enabled" : "disabled" } + } catch (error) { + return { + applied: false, + enabled: false, + status: "error", + detail: errorMessage(error), + } + } + } + if (env.platform === "win32") { + try { + app.setLoginItemSettings({ + openAtLogin: settings.runAtStartup, + args: [AUTO_LAUNCH_ARG], + }) + return { applied: true, enabled: settings.runAtStartup, status: settings.runAtStartup ? "enabled" : "disabled" } + } catch (error) { + return { + applied: false, + enabled: false, + status: "error", + detail: errorMessage(error), + } + } + } + if (env.platform !== "linux") { + return { + applied: false, + enabled: false, + status: "unavailable", + detail: `Run at startup is not supported on ${env.platform}.`, + } + } + if (env.isFlatpak) { + return applyFlatpakAutostart(settings, env) + } + return applyXdgAutostart(settings, env) +} + +async function applyXdgAutostart( + settings: AppSettings, + env: AutostartEnvironment, +): Promise { + const file = xdgDesktopFilePath(env) + try { + if (settings.runAtStartup) { + await mkdir(autostartDir(env.homeDir), { recursive: true }) + await writeFile(file, xdgDesktopEntry(env.execPath), "utf-8") + } else { + await rm(file, { force: true }) + } + return { applied: true, enabled: settings.runAtStartup, status: settings.runAtStartup ? "enabled" : "disabled" } + } catch (error) { + return { + applied: false, + enabled: readAutostartEnabled(env) === true, + status: "error", + detail: errorMessage(error), + } + } +} + +// Flatpak autostart must go through the Background portal so the user sees +// the system consent prompt. The portal has no removal call, so disabling +// removes the portal-created desktop file directly; --filesystem=home in the +// manifest makes it visible inside the sandbox. Denial is a normal result +// and is reported truthfully instead of claiming the setting is on. +async function applyFlatpakAutostart( + settings: AppSettings, + env: AutostartEnvironment, +): Promise { + if (!settings.runAtStartup) { + try { + await rm(flatpakDesktopFilePath(env), { force: true }) + } catch (error) { + return { + applied: false, + enabled: readAutostartEnabled(env) === true, + status: "error", + detail: errorMessage(error), + } + } + return { applied: true, enabled: false, status: "disabled" } + } + try { + const response = await requestPortalBackground({ + reason: "Allow Devsy to start automatically after you sign in.", + autostart: true, + commandline: ["devsy-wrapper", AUTO_LAUNCH_ARG], + }) + if (response.autostart) { + return { applied: true, enabled: true, status: "enabled" } + } + return { + applied: false, + enabled: false, + status: "denied", + detail: + "The system did not grant background autostart permission. Run at startup stays off; you can allow it from your desktop settings and try again.", + } + } catch (error) { + return { + applied: false, + enabled: false, + status: "error", + detail: errorMessage(error), + } + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/desktop/src/main/index.ts b/desktop/src/main/index.ts index f516c25fa..d9ffe65a3 100644 --- a/desktop/src/main/index.ts +++ b/desktop/src/main/index.ts @@ -1,8 +1,17 @@ import { homedir } from "node:os" import { join } from "node:path" -import { app, BrowserWindow, session } from "electron" +import { app, BrowserWindow, Notification, session } from "electron" import { initAnalytics, shutdownAnalytics, trackEvent } from "./analytics.js" import { isAppQuitting, markAppQuitting } from "./app-lifecycle.js" +import { AppSettingsStore } from "./app-settings.js" +import { + applyAutostart, + detectAutostartEnvironment, + readAutostartEnabled, +} from "./autostart.js" +import { isAutomaticLoginLaunch, shouldSuppressInitialWindow } from "./launch-context.js" +import { SettingsService } from "./settings-service.js" +import { TrayNotifier } from "./tray-notifications.js" import { CliRunner } from "./cli.js" import { DaemonManager } from "./daemon-manager.js" import { registerIpcHandlers } from "./ipc.js" @@ -13,7 +22,11 @@ import { ProviderJobs } from "./provider-jobs.js" import { PtyManager } from "./pty.js" import { DaemonState } from "./state.js" import { AppTray } from "./tray.js" -import { initAutoUpdater, stopAutoUpdater } from "./updater.js" +import { + initAutoUpdater, + onUpdateStatusChanged, + stopAutoUpdater, +} from "./updater.js" import { Watcher } from "./watcher.js" import { WorkspaceJobs } from "./workspace-jobs.js" @@ -198,6 +211,47 @@ app.whenReady().then(() => { const providerJobs = new ProviderJobs() const workspaceJobs = new WorkspaceJobs() + const appSettingsStore = new AppSettingsStore( + join(app.getPath("userData"), "app-settings.json"), + ) + appSettingsStore.load() + const autostartEnv = detectAutostartEnvironment() + const settingsService = new SettingsService({ + store: appSettingsStore, + applyAutostart: (settings) => applyAutostart(settings, autostartEnv), + currentAutostartEnabled: () => readAutostartEnabled(autostartEnv), + onChanged: (result) => { + appTray?.rebuildMenu() + const win = mainWindow + if (win && !win.isDestroyed()) { + win.webContents.send("app-settings-changed", result) + } + }, + }) + + const notifier = new TrayNotifier({ + getLevel: () => appSettingsStore.get().trayNotifications, + isAppFocused: () => { + const win = mainWindow + return Boolean(win && !win.isDestroyed() && win.isFocused()) + }, + sink: (request) => { + if (!Notification.isSupported()) return + const notification = new Notification({ + title: request.title, + body: request.body, + }) + notification.on("click", request.onClick) + notification.show() + }, + openWorkspace: (id) => showDevsy(`/workspaces/${encodeURIComponent(id)}`), + openWorkspaceLogs: (id) => + showDevsy(`/workspaces/${encodeURIComponent(id)}?tab=logs`), + openUpdates: () => showDevsy("/settings"), + }) + workspaceJobs.onChange(() => notifier.onJobsChanged(workspaceJobs.snapshot())) + onUpdateStatusChanged((status) => notifier.onUpdateStatus(status)) + // Register IPC handlers const { tunnelProcesses, @@ -229,6 +283,7 @@ app.whenReady().then(() => { } }, workspaceSnapshot: () => watcher?.workspaceSnapshot(), + settingsService, }) // Start state watcher @@ -267,6 +322,16 @@ app.whenReady().then(() => { state, workspaceJobs, showDevsy, + getSettings: () => appSettingsStore.get(), + toggleRunAtStartup: () => + void settingsService.update({ + runAtStartup: !appSettingsStore.get().runAtStartup, + }), + toggleOpenToTray: () => + void settingsService.update({ + openToTrayOnStartup: !appSettingsStore.get().openToTrayOnStartup, + }), + startWorkspace: workspaceActions.start, stopWorkspace: workspaceActions.stop, refreshWorkspace: (id) => watcher ? watcher.refreshWorkspaceStatus(id) : Promise.resolve(), @@ -275,7 +340,21 @@ app.whenReady().then(() => { }) appTray.setup() - createWindow() + // Open-to-tray suppresses the window only for automatic login launches; an + // explicit launch always shows the window. + 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, + }) + if (!shouldSuppressInitialWindow(appSettingsStore.get(), automaticLaunch)) { + createWindow() + } if (app.isPackaged) { initAutoUpdater(() => mainWindow) diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 16a6e2f56..956871dc4 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 = async (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 + }): Promise => { trackEvent("workspace_create", { provider: args.provider, workspace_ref: hashWorkspaceRef(args.workspaceId ?? args.source), @@ -1326,7 +1327,10 @@ export function registerIpcHandlers(deps: IpcDependencies): { ) }) return cmdId - }, + } + + ipcMain.handle("workspace_up", (_event, args: Parameters[0]) => + runWorkspaceUp(args), ) 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() }) + }, }, } } diff --git a/desktop/src/main/launch-context.ts b/desktop/src/main/launch-context.ts new file mode 100644 index 000000000..651d4b2e4 --- /dev/null +++ b/desktop/src/main/launch-context.ts @@ -0,0 +1,36 @@ +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 +} + +// Only an automatic login launch may open to the tray. An explicit launch +// always creates the window, otherwise clicking Devsy would appear to do +// nothing. +export function shouldSuppressInitialWindow( + settings: AppSettings, + automaticLoginLaunch: boolean, +): boolean { + return ( + automaticLoginLaunch && + settings.runAtStartup && + settings.openToTrayOnStartup + ) +} diff --git a/desktop/src/main/portal-background.ts b/desktop/src/main/portal-background.ts new file mode 100644 index 000000000..88eba22db --- /dev/null +++ b/desktop/src/main/portal-background.ts @@ -0,0 +1,80 @@ +import { randomUUID } from "node:crypto" + +export interface PortalBackgroundResponse { + background: boolean + autostart: boolean +} + +export interface PortalBackgroundOptions { + reason: string + autostart: boolean + commandline?: string[] +} + +// The portal Response signal is delivered on a Request object whose path is +// only known after the method returns. Precomputing the path from the +// handle_token lets us subscribe before the call so an instant response +// cannot race past the listener. +export function portalRequestPath(senderUniqueName: string, token: string): string { + const sender = senderUniqueName.replace(/^:/, "").replace(/\./g, "_") + return `/org/freedesktop/portal/desktop/request/${sender}/${token}` +} + +export function parsePortalResponse( + code: number, + results: Record | undefined, +): PortalBackgroundResponse { + if (code !== 0) return { background: false, autostart: false } + return { + background: results?.background?.value === true, + autostart: results?.autostart?.value === true, + } +} + +const PORTAL_TIMEOUT_MS = 120_000 + +export async function requestPortalBackground( + options: PortalBackgroundOptions, +): Promise { + // Imported lazily so non-Flatpak platforms never load the D-Bus stack. + const { sessionBus, Variant } = await import("dbus-next") + const bus = sessionBus() + try { + const desktop = await bus.getProxyObject( + "org.freedesktop.portal.Desktop", + "/org/freedesktop/portal/desktop", + ) + const background = desktop.getInterface("org.freedesktop.portal.Background") + const token = `devsy${randomUUID().replace(/-/g, "")}` + const path = portalRequestPath(bus.name, token) + const requestObject = await bus.getProxyObject( + "org.freedesktop.portal.Desktop", + path, + ) + const request = requestObject.getInterface("org.freedesktop.portal.Request") + const response = new Promise((resolve) => { + const timer = setTimeout(() => { + resolve({ background: false, autostart: false }) + }, PORTAL_TIMEOUT_MS) + request.on( + "Response", + (code: number, results: Record) => { + clearTimeout(timer) + resolve(parsePortalResponse(code, results)) + }, + ) + }) + const methodOptions: Record> = { + handle_token: new Variant("s", token), + reason: new Variant("s", options.reason), + autostart: new Variant("b", options.autostart), + } + if (options.commandline) { + methodOptions.commandline = new Variant("as", options.commandline) + } + await background.RequestBackground("", methodOptions) + return await response + } finally { + bus.disconnect() + } +} diff --git a/desktop/src/main/settings-service.ts b/desktop/src/main/settings-service.ts new file mode 100644 index 000000000..9be2a0127 --- /dev/null +++ b/desktop/src/main/settings-service.ts @@ -0,0 +1,65 @@ +import { + type AppSettings, + type AppSettingsStore, + patchAppSettings, +} from "./app-settings.js" +import type { AutostartApplyResult } from "./autostart.js" + +export interface SettingsUpdateResult { + settings: AppSettings + startup: AutostartApplyResult +} + +export interface SettingsServiceDeps { + store: AppSettingsStore + applyAutostart: (settings: AppSettings) => Promise + currentAutostartEnabled: () => boolean | undefined + onChanged: (result: SettingsUpdateResult) => void +} + +export class SettingsService { + constructor(private deps: SettingsServiceDeps) {} + + get(): AppSettings { + return this.deps.store.get() + } + + status(): SettingsUpdateResult { + const enabled = this.deps.currentAutostartEnabled() + return { + settings: this.get(), + startup: { + applied: enabled !== undefined, + enabled: enabled === true, + status: + enabled === undefined + ? "unavailable" + : enabled + ? "enabled" + : "disabled", + }, + } + } + + async update(patch: Partial): Promise { + const current = this.deps.store.get() + const next = patchAppSettings(current, patch) + let startup: AutostartApplyResult = this.status().startup + const startupRelevant = + next.runAtStartup !== current.runAtStartup || + next.openToTrayOnStartup !== current.openToTrayOnStartup + if (startupRelevant) { + startup = await this.deps.applyAutostart(next) + // A denied or failed enable must not persist: Settings and the tray + // keep showing Run at startup off instead of claiming it is on. + if (next.runAtStartup && !startup.enabled) { + const reverted = { ...next, runAtStartup: false, openToTrayOnStartup: false } + return { settings: reverted, startup } + } + } + this.deps.store.save(next) + const result = { settings: next, startup } + this.deps.onChanged(result) + return result + } +} diff --git a/desktop/src/main/tray-notifications.ts b/desktop/src/main/tray-notifications.ts new file mode 100644 index 000000000..3eabc748c --- /dev/null +++ b/desktop/src/main/tray-notifications.ts @@ -0,0 +1,162 @@ +import type { TrayNotificationLevel } from "./app-settings.js" +import type { WorkspaceJob } from "../shared/workspace-operation.js" +import type { UpdateStatus } from "./updater.js" + +export interface NotificationRequest { + title: string + body: string + onClick: () => void +} + +export type NotificationSink = (request: NotificationRequest) => void + +export interface TerminalOutcome { + workspaceId: string + commandId: string + activity: WorkspaceJob["activity"] + state: "succeeded" | "failed" +} + +const ACTIVITY_VERBS: Record = { + creating: "Create", + starting: "Start", + stopping: "Stop", + deleting: "Delete", + rebuilding: "Rebuild", + resetting: "Reset", +} + +// Jobs move running -> reconciling -> succeeded|failed; only the final +// transition is terminal. Reconciling states and refresh errors never +// notify. +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` +} + +// Update notifications fire once per version so a re-emitted status does not +// re-notify. +export function updateNotifies( + status: UpdateStatus, + level: TrayNotificationLevel, + lastNotifiedVersion: string | undefined, +): { notifies: boolean; version?: string; title: string; body: string } { + const none = { notifies: false, title: "", body: "" } + if (level === "off") return none + const version = status.availableVersion ?? "" + if (!version || version === lastNotifiedVersion) return none + if (status.state === "downloaded") { + return { + notifies: true, + version, + title: "Devsy update ready", + body: `Version ${version} is ready to install.`, + } + } + if (status.state === "error" && status.code === "install-failed") { + return { + notifies: true, + version, + title: "Devsy update failed", + body: `Version ${version} could not be installed.`, + } + } + return none +} + +export interface TrayNotifierDeps { + getLevel: () => TrayNotificationLevel + isAppFocused: () => boolean + sink: NotificationSink + openWorkspace: (workspaceId: string) => void + openWorkspaceLogs: (workspaceId: string) => void + openUpdates: () => void +} + +const NOTIFIED_CAP = 200 + +export class TrayNotifier { + private previous: Record = {} + private notified = new Set() + private lastUpdateVersion: string | undefined + + constructor(private deps: TrayNotifierDeps) {} + + onJobsChanged(current: Record): void { + const outcomes = collectTerminalOutcomes(this.previous, current, this.notified) + this.previous = current + for (const outcome of outcomes) { + this.markNotified(outcome.commandId) + if (!outcomeNotifies(outcome, this.deps.getLevel())) continue + if (this.deps.isAppFocused()) continue + this.deps.sink({ + title: "Devsy", + body: outcomeNotificationBody(outcome), + onClick: () => + outcome.state === "failed" + ? this.deps.openWorkspaceLogs(outcome.workspaceId) + : this.deps.openWorkspace(outcome.workspaceId), + }) + } + } + + onUpdateStatus(status: UpdateStatus): void { + const decision = updateNotifies( + status, + this.deps.getLevel(), + this.lastUpdateVersion, + ) + if (!decision.notifies || !decision.version) return + this.lastUpdateVersion = decision.version + if (this.deps.isAppFocused()) return + this.deps.sink({ + title: decision.title, + body: decision.body, + onClick: () => this.deps.openUpdates(), + }) + } + + private markNotified(commandId: string): void { + this.notified.add(commandId) + if (this.notified.size > NOTIFIED_CAP) { + const oldest = this.notified.values().next().value + if (oldest !== undefined) this.notified.delete(oldest) + } + } +} diff --git a/desktop/src/main/tray.ts b/desktop/src/main/tray.ts index 67471dfb5..64ee6919d 100644 --- a/desktop/src/main/tray.ts +++ b/desktop/src/main/tray.ts @@ -7,6 +7,7 @@ import { import type { WorkspaceJobs } from "./workspace-jobs.js" import { join } from "node:path" import { app, Menu, nativeImage, nativeTheme, Tray } from "electron" +import type { AppSettings } from "./app-settings.js" import type { DaemonState, Workspace } from "./state.js" import { getLastStatus, @@ -14,7 +15,14 @@ import { onUpdateStatusChanged, type UpdateStatus, } from "./updater.js" -import { isActiveWorkspaceStatus } from "./workspace-status.js" +import { + isActiveWorkspaceStatus, + normalizeWorkspaceStatus, +} from "./workspace-status.js" + +// Native menus do not scroll well, so the tray shows only the most recently +// used workspaces and links into the app for the rest. +export const TRAY_WORKSPACE_LIMIT = 5 export function buildUpdateMenuItems( status: UpdateStatus, @@ -32,79 +40,182 @@ export function buildUpdateMenuItems( return [{ label, click: onInstall }, { type: "separator" }] } +export type TrayWorkspaceState = + | "running" + | "stopped" + | "failed" + | "busy" + | "unknown" + +export function trayWorkspaceState( + workspace: Workspace, + job?: WorkspaceJob, +): TrayWorkspaceState { + if (workspaceJobBusy(job)) return "busy" + if (job?.error || job?.state === "failed") return "failed" + const status = normalizeWorkspaceStatus(workspace.status ?? "")?.toLowerCase() + if (status === "running" || status === "busy") return "running" + if (status === "stopped") return "stopped" + if (status === "failed" || status === "error") return "failed" + return "unknown" +} + +const STATE_GLYPHS: Record = { + running: "●", + stopped: "○", + failed: "✖", + busy: "◐", + unknown: "◌", +} + +const STATE_TEXT: Record = { + running: "Running", + stopped: "Stopped", + failed: "Failed", + busy: "Busy", + unknown: "Unknown", +} + +export function countRunningWorkspaces( + workspaces: Workspace[], + jobs: Record, +): number { + return workspaces.filter( + (workspace) => + isActiveWorkspaceStatus(workspace.status) || + workspaceJobBusy(jobs[workspace.id]), + ).length +} + export interface TrayMenuModel { - activeWorkspaces: Workspace[] + workspaces: Workspace[] jobs?: Record pendingStops: ReadonlySet + pendingStarts: ReadonlySet updateStatus: UpdateStatus + settings: AppSettings } export interface TrayMenuActions { showDevsy: () => void showWorkspace: (id: string) => void + showWorkspaceLogs: (id: string) => void showAllWorkspaces: () => void + showSettings: () => void + startWorkspace: (id: string) => void stopWorkspace: (id: string) => void + toggleRunAtStartup: () => void + toggleOpenToTray: () => void installUpdate: () => void quit: () => void } +// Action meaning lives in text labels because native menu-item images are +// best-effort on Windows and several Linux panels; glyphs only mark state. export function buildTrayMenuTemplate( model: TrayMenuModel, actions: TrayMenuActions, ): Electron.MenuItemConstructorOptions[] { - const active = model.activeWorkspaces - const workspaceItems: Electron.MenuItemConstructorOptions[] = active - .slice(0, 10) - .map((workspace) => { - const job = model.jobs?.[workspace.id] + const jobs = model.jobs ?? {} + const running = countRunningWorkspaces(model.workspaces, jobs) + const header: Electron.MenuItemConstructorOptions[] = [ + { + label: + running === 0 + ? "Devsy — No running workspaces" + : `Devsy — ${running} running workspace${running === 1 ? "" : "s"}`, + enabled: false, + }, + { type: "separator" }, + ] + + const shown = model.workspaces.slice(0, TRAY_WORKSPACE_LIMIT) + const workspaceItems: Electron.MenuItemConstructorOptions[] = shown.map( + (workspace) => { + const job = jobs[workspace.id] + const state = trayWorkspaceState(workspace, job) const pending = - model.pendingStops.has(workspace.id) || workspaceJobBusy(job) - const busy = workspace.status?.trim().toLowerCase() === "busy" - const disabled = pending && !workspaceJobInterruptible(job) - const label = workspaceJobLabel(job) + model.pendingStops.has(workspace.id) || + model.pendingStarts.has(workspace.id) || + state === "busy" + const jobLabel = workspaceJobLabel(job) + const submenu: Electron.MenuItemConstructorOptions[] = [ + { + label: "Open in Devsy", + click: () => actions.showWorkspace(workspace.id), + }, + ] + if (state === "running") { + const stopping = + model.pendingStops.has(workspace.id) || + (workspaceJobBusy(job) && !workspaceJobInterruptible(job)) + submenu.push({ + label: stopping ? `${jobLabel ?? "Stopping"}…` : "Stop Workspace", + enabled: !stopping, + click: stopping + ? undefined + : () => actions.stopWorkspace(workspace.id), + }) + } else if (state === "stopped") { + submenu.push({ + label: model.pendingStarts.has(workspace.id) + ? "Starting…" + : "Start Workspace", + enabled: !model.pendingStarts.has(workspace.id), + click: model.pendingStarts.has(workspace.id) + ? undefined + : () => actions.startWorkspace(workspace.id), + }) + } + if (state === "failed" || state === "busy" || state === "unknown") { + submenu.push({ + label: "View Logs", + click: () => actions.showWorkspaceLogs(workspace.id), + }) + } return { - label: `${workspace.id}${label ? ` — ${label}` : busy && !pending ? " — Busy" : ""}`, - submenu: [ - { - label: "Open in Devsy", - click: () => actions.showWorkspace(workspace.id), - }, - { - label: disabled - ? `${workspaceJobLabel(job) ?? "Stopping"}…` - : "Stop Workspace", - enabled: !disabled, - click: disabled - ? undefined - : () => actions.stopWorkspace(workspace.id), - }, - ], + label: `${STATE_GLYPHS[state]} ${workspace.id} — ${jobLabel && (state === "busy" || state === "failed") ? jobLabel : STATE_TEXT[state]}`, + submenu, } - }) - - const activeSubmenu: Electron.MenuItemConstructorOptions[] = - workspaceItems.length > 0 - ? [ - ...workspaceItems, - ...(active.length > 10 ? [{ type: "separator" as const }] : []), - { - label: "Show All Workspaces…", - click: actions.showAllWorkspaces, - }, - ] - : [ - { label: "No Active Workspaces", enabled: false }, - { type: "separator" }, - { - label: "Open Workspaces in Devsy…", - click: actions.showAllWorkspaces, - }, - ] + }, + ) + if (workspaceItems.length === 0) { + workspaceItems.push({ label: "No Workspaces", enabled: false }) + } else if (model.workspaces.length > TRAY_WORKSPACE_LIMIT) { + workspaceItems.push( + { type: "separator" }, + { + label: `View All ${model.workspaces.length} Workspaces in Devsy`, + click: actions.showAllWorkspaces, + }, + ) + } return [ - { label: "Show Devsy", click: actions.showDevsy }, + ...header, + ...workspaceItems, { type: "separator" }, - { label: `Active Workspaces (${active.length})`, submenu: activeSubmenu }, + { label: "Open Devsy", click: actions.showDevsy }, + { + label: "Preferences", + submenu: [ + { + label: "Run at Startup", + type: "checkbox", + checked: model.settings.runAtStartup, + click: actions.toggleRunAtStartup, + }, + { + label: "Open to Tray on Startup", + type: "checkbox", + checked: model.settings.openToTrayOnStartup, + enabled: model.settings.runAtStartup, + click: actions.toggleOpenToTray, + }, + { type: "separator" }, + { label: "Open Settings…", click: actions.showSettings }, + ], + }, ...buildUpdateMenuItems(model.updateStatus, actions.installUpdate), { type: "separator" }, { label: "Quit Devsy", click: actions.quit }, @@ -115,6 +226,10 @@ interface TrayDeps { workspaceJobs?: WorkspaceJobs state: DaemonState showDevsy: (route?: string) => void + getSettings: () => AppSettings + toggleRunAtStartup: () => void + toggleOpenToTray: () => void + startWorkspace: (workspaceId: string) => Promise stopWorkspace: (workspaceId: string) => Promise refreshWorkspace: (workspaceId: string) => Promise refreshWorkspaces: () => Promise @@ -123,6 +238,7 @@ interface TrayDeps { export class AppTray { private tray: Tray | null = null private pendingStops = new Set() + private pendingStarts = new Set() private unsubscribeWorkspaceState: (() => void) | null = null private unsubscribeWorkspaceJobs: (() => void) | null = null private unsubscribeUpdateStatus: (() => void) | null = null @@ -135,7 +251,7 @@ export class AppTray { setup(): void { if (this.tray) return this.tray = new Tray(this.createTrayIcon()) - this.tray.setToolTip("Devsy — No active workspaces") + this.tray.setToolTip("Devsy — No running workspaces") this.unsubscribeWorkspaceState = this.deps.state.onWorkspacesChange(() => this.rebuildMenu(), ) @@ -161,53 +277,35 @@ export class AppTray { nativeTheme.off("updated", this.onThemeUpdated) } this.pendingStops.clear() + this.pendingStarts.clear() this.tray?.destroy() this.tray = null } - private createTrayIcon(): Electron.NativeImage { - const trayDir = join(__dirname, "../../resources/tray") - if (process.platform === "darwin") { - const icon = nativeImage.createFromPath( - join(trayDir, "icon-trayTemplate.png"), - ) - icon.setTemplateImage(true) - return icon - } - const variant = nativeTheme.shouldUseDarkColors ? "dark" : "light" - return nativeImage.createFromPath(join(trayDir, `icon-tray-${variant}.png`)) - } - - private rebuildMenu(): void { + rebuildMenu(): void { if (!this.tray) return const jobs = this.deps.workspaceJobs?.snapshot() ?? {} - const workspaces = this.deps.state.workspaceList() - const activeWorkspaces = workspaces.filter( - (workspace) => - isActiveWorkspaceStatus(workspace.status) || - workspaceJobBusy(jobs[workspace.id]), - ) - for (const [id, job] of Object.entries(jobs)) { - if ( - workspaceJobBusy(job) && - !workspaces.some((workspace) => workspace.id === id) - ) - activeWorkspaces.push({ id }) - } - const template = buildTrayMenuTemplate( { - activeWorkspaces, + workspaces: this.deps.state.workspaceList(), pendingStops: this.pendingStops, - jobs: this.deps.workspaceJobs?.snapshot(), + pendingStarts: this.pendingStarts, + jobs, updateStatus: getLastStatus(), + settings: this.deps.getSettings(), }, { showDevsy: () => this.deps.showDevsy(), showWorkspace: (id) => this.deps.showDevsy(`/workspaces/${encodeURIComponent(id)}`), + showWorkspaceLogs: (id) => + this.deps.showDevsy(`/workspaces/${encodeURIComponent(id)}?tab=logs`), showAllWorkspaces: () => this.deps.showDevsy("/workspaces"), + showSettings: () => this.deps.showDevsy("/settings"), + startWorkspace: (id) => void this.startFromTray(id), stopWorkspace: (id) => void this.stopFromTray(id), + toggleRunAtStartup: () => this.deps.toggleRunAtStartup(), + toggleOpenToTray: () => this.deps.toggleOpenToTray(), installUpdate: () => void installUpdate().catch((error) => console.warn("[tray] failed to install update:", error), @@ -216,12 +314,34 @@ export class AppTray { }, ) this.tray.setContextMenu(Menu.buildFromTemplate(template)) - const count = activeWorkspaces.length + const running = countRunningWorkspaces( + this.deps.state.workspaceList(), + jobs, + ) this.tray.setToolTip( - `Devsy — ${count} active workspace${count === 1 ? "" : "s"}`, + running === 0 + ? "Devsy — No running workspaces" + : `Devsy — ${running} running workspace${running === 1 ? "" : "s"}`, ) } + // Repeat clicks deduplicate through the pending sets and through + // WorkspaceJobs itself, which rejects a second in-progress operation. + 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 +351,35 @@ export class AppTray { } catch (error) { console.warn(`[tray] failed to stop workspace ${workspaceId}:`, error) } finally { - try { - await this.deps.refreshWorkspace(workspaceId) - } catch (error) { - console.warn( - `[tray] failed to refresh workspace ${workspaceId}:`, - error, - ) - } - try { - await this.deps.refreshWorkspaces() - } catch (error) { - console.warn("[tray] failed to refresh workspaces:", error) - } + await this.refreshAfterAction(workspaceId) this.pendingStops.delete(workspaceId) this.rebuildMenu() } } + + private async refreshAfterAction(workspaceId: string): Promise { + try { + await this.deps.refreshWorkspace(workspaceId) + } catch (error) { + console.warn(`[tray] failed to refresh workspace ${workspaceId}:`, error) + } + try { + await this.deps.refreshWorkspaces() + } catch (error) { + console.warn("[tray] failed to refresh workspaces:", error) + } + } + + private createTrayIcon(): Electron.NativeImage { + const trayDir = join(__dirname, "../../resources/tray") + if (process.platform === "darwin") { + const icon = nativeImage.createFromPath( + join(trayDir, "icon-trayTemplate.png"), + ) + icon.setTemplateImage(true) + return icon + } + const variant = nativeTheme.shouldUseDarkColors ? "dark" : "light" + return nativeImage.createFromPath(join(trayDir, `icon-tray-${variant}.png`)) + } } diff --git a/desktop/src/renderer/src/App.svelte b/desktop/src/renderer/src/App.svelte index f23e58901..e43ade120 100644 --- a/desktop/src/renderer/src/App.svelte +++ b/desktop/src/renderer/src/App.svelte @@ -16,7 +16,9 @@ import { initContexts, destroyContexts } from "$lib/stores/contexts.js" import { initSecrets } from "$lib/stores/secrets.js" import { initEnv } from "$lib/stores/env.js" import { + initDesktopSettingsListener, initSettings, + syncDesktopSettingsFromMain, syncAutoUpdateFromMain, autoUpdate, } from "$lib/stores/settings.js" @@ -73,6 +75,7 @@ const routes = { } let destroySettings: (() => void) | undefined +let unsubDesktopSettings: (() => void) | undefined let updateDialogOpen = $state(false) let unsubscribeToasts: (() => void) | null = null @@ -175,6 +178,10 @@ onMount(async () => { await initUpdateStore() await syncAutoUpdateFromMain() + await syncDesktopSettingsFromMain() + const desktopSettingsUnlisten = await initDesktopSettingsListener() + if (destroyed) desktopSettingsUnlisten() + else unsubDesktopSettings = desktopSettingsUnlisten unsubscribeToasts = initUpdateToasts(() => { let value = true autoUpdate.subscribe((v) => (value = v))() @@ -185,6 +192,7 @@ onMount(async () => { onDestroy(() => { destroyed = true + unsubDesktopSettings?.() unsubscribeToasts?.() disposeUpdateStore() stopSessionTracking?.() diff --git a/desktop/src/renderer/src/lib/ipc/commands.ts b/desktop/src/renderer/src/lib/ipc/commands.ts index d735341f1..5ddc27e7f 100644 --- a/desktop/src/renderer/src/lib/ipc/commands.ts +++ b/desktop/src/renderer/src/lib/ipc/commands.ts @@ -441,3 +441,21 @@ export function analyticsTrack( ): void { invoke("analytics_track", { name, properties }).catch(() => {}) } + +// Desktop app settings (owned by the main process) +export async function getAppSettings(): Promise< + import("$shared/app-settings.js").AppSettingsState +> { + return invoke( + "get_app_settings", + ) +} + +export async function setAppSettings( + patch: Partial, +): Promise { + return invoke( + "set_app_settings", + { patch }, + ) +} diff --git a/desktop/src/renderer/src/lib/ipc/events.ts b/desktop/src/renderer/src/lib/ipc/events.ts index b56bd440c..26d3d0802 100644 --- a/desktop/src/renderer/src/lib/ipc/events.ts +++ b/desktop/src/renderer/src/lib/ipc/events.ts @@ -92,6 +92,7 @@ export const EVENT_NAMES = { COMMAND_PROGRESS: "command-progress", WORKSPACE_STATUS: "workspace-status", UPDATE_STATUS: "update-status", + APP_SETTINGS_CHANGED: "app-settings-changed", } as const interface WorkspacesPayload { @@ -182,3 +183,14 @@ export function onUpdateStatus( callback(event.payload) }) } + +export function onAppSettingsChanged( + callback: (state: import("$shared/app-settings.js").AppSettingsState) => void, +): Promise { + return listen( + EVENT_NAMES.APP_SETTINGS_CHANGED, + (event) => { + callback(event.payload) + }, + ) +} diff --git a/desktop/src/renderer/src/lib/ipc/mock.ts b/desktop/src/renderer/src/lib/ipc/mock.ts index aebe97087..1bf0771f0 100644 --- a/desktop/src/renderer/src/lib/ipc/mock.ts +++ b/desktop/src/renderer/src/lib/ipc/mock.ts @@ -107,6 +107,27 @@ const MACHINES: Machine[] = [ }, ] +let MOCK_APP_SETTINGS = { + settings: { + runAtStartup: false, + openToTrayOnStartup: false, + trayNotifications: "failures", + }, + startup: { applied: true, enabled: false, status: "disabled" }, +} as { + settings: { + runAtStartup: boolean + openToTrayOnStartup: boolean + trayNotifications: "off" | "failures" | "all" + } + startup: { + applied: boolean + enabled: boolean + status: "enabled" | "disabled" | "denied" | "unavailable" | "error" + detail?: string + } +} + const CONTEXTS: Context[] = [{ name: "default" }, { name: "staging" }] const SSH_KEYS: SshKeyInfo[] = [ @@ -283,6 +304,30 @@ const COMMANDS: Record = { image_inspect_platforms: () => ["linux/amd64", "linux/arm64"], + // App settings (startup + tray notifications) + get_app_settings: () => MOCK_APP_SETTINGS, + set_app_settings: (args) => { + const patch = (args?.patch ?? {}) as Partial + MOCK_APP_SETTINGS = { + settings: { + ...MOCK_APP_SETTINGS.settings, + ...patch, + openToTrayOnStartup: + (patch.runAtStartup ?? MOCK_APP_SETTINGS.settings.runAtStartup) === false + ? false + : (patch.openToTrayOnStartup ?? MOCK_APP_SETTINGS.settings.openToTrayOnStartup), + }, + startup: { + applied: true, + enabled: patch.runAtStartup ?? MOCK_APP_SETTINGS.settings.runAtStartup, + status: (patch.runAtStartup ?? MOCK_APP_SETTINGS.settings.runAtStartup) + ? ("enabled" as const) + : ("disabled" as const), + }, + } + return MOCK_APP_SETTINGS + }, + // Release channel get_release_channel: () => "stable", set_release_channel: () => undefined, diff --git a/desktop/src/renderer/src/lib/stores/settings.ts b/desktop/src/renderer/src/lib/stores/settings.ts index bce06b10d..dbdd71f4f 100644 --- a/desktop/src/renderer/src/lib/stores/settings.ts +++ b/desktop/src/renderer/src/lib/stores/settings.ts @@ -1,5 +1,17 @@ import { writable } from "svelte/store" -import { getAutoDownload, setAutoDownload } from "$lib/ipc/commands.js" +import { + getAppSettings, + getAutoDownload, + setAppSettings, + setAutoDownload, +} from "$lib/ipc/commands.js" +import { onAppSettingsChanged } from "$lib/ipc/events.js" +import type { + AppSettings, + AppSettingsState, + StartupStatus, + TrayNotificationLevel, +} from "$shared/app-settings.js" const browser = typeof window !== "undefined" @@ -347,6 +359,61 @@ export function parseContextOptions( } } +// ── Desktop settings (main-process owned) ─────────────────────────── +// Startup and tray notification preferences live in the main process so the +// tray and Settings always share one truth. Main pushes +// app-settings-changed when the tray toggles them; stores apply the payload +// unless a renderer write is in flight. +export const runAtStartup = writable(false) +export const openToTrayOnStartup = writable(false) +export const trayNotifications = writable("failures") +export const startupStatus = writable(null) + +let desktopSettingsWriteInFlight = false + +function applyAppSettingsState(state: AppSettingsState): void { + runAtStartup.set(state.settings.runAtStartup) + openToTrayOnStartup.set(state.settings.openToTrayOnStartup) + trayNotifications.set(state.settings.trayNotifications) + startupStatus.set(state.startup) +} + +export async function syncDesktopSettingsFromMain(): Promise { + try { + const state = await getAppSettings() + if (desktopSettingsWriteInFlight) return + applyAppSettingsState(state) + } catch (err) { + console.warn("[settings] getAppSettings failed:", err) + } +} + +export async function updateDesktopSettings( + patch: Partial, +): Promise { + desktopSettingsWriteInFlight = true + try { + applyAppSettingsState(await setAppSettings(patch)) + } catch (err) { + console.warn("[settings] setAppSettings failed:", err) + try { + applyAppSettingsState(await getAppSettings()) + } catch { + // Keep the previous store values. + } + } finally { + desktopSettingsWriteInFlight = false + } +} + +export async function initDesktopSettingsListener(): Promise<() => void> { + const unlisten = await onAppSettingsChanged((state) => { + if (desktopSettingsWriteInFlight) return + applyAppSettingsState(state) + }) + return unlisten +} + // ── Init ──────────────────────────────────────────────────────────── export function initSettings() { diff --git a/desktop/src/renderer/src/pages/SettingsPage.svelte b/desktop/src/renderer/src/pages/SettingsPage.svelte index e8d016acb..8bf4616de 100644 --- a/desktop/src/renderer/src/pages/SettingsPage.svelte +++ b/desktop/src/renderer/src/pages/SettingsPage.svelte @@ -19,6 +19,12 @@ import { localOptions as localOptionsStore, loadLocalOptions, saveLocalOption, + runAtStartup, + openToTrayOnStartup, + trayNotifications, + startupStatus, + syncDesktopSettingsFromMain, + updateDesktopSettings, } from "$lib/stores/settings.js" import type { Theme, @@ -26,6 +32,7 @@ import type { LocalOptions, OnBuildFailure, } from "$lib/stores/settings.js" +import type { TrayNotificationLevel } from "$shared/app-settings.js" import * as Select from "$lib/components/ui/select/index.js" import UpdatesPanel from "$lib/components/update/UpdatesPanel.svelte" import { Skeleton } from "$lib/components/ui/skeleton/index.js" @@ -121,10 +128,17 @@ const shortcuts = [ { keys: "Escape", action: "Close dialogs and palette" }, ] +const NOTIFICATION_OPTIONS: { value: TrayNotificationLevel; label: string }[] = [ + { value: "off", label: "Off" }, + { value: "failures", label: "Failures only" }, + { value: "all", label: "All terminal outcomes" }, +] + onMount(() => { local = loadLocalOptions() localOptionsStore.set(local) loading = false + void syncDesktopSettingsFromMain() }) function saveLocal(key: keyof LocalOptions, value: string | boolean) { @@ -273,6 +287,68 @@ function toggleLocal(key: keyof LocalOptions) { {/if} + +
+

Startup

+
+
+
+ +

Launch Devsy automatically after you sign in

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

{$startupStatus.detail}

+ {/if} + +
+
+ +

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

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

Notifications

+
+
+
+ +

System notifications when workspace start and stop operations finish

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

Appearance

diff --git a/desktop/src/renderer/src/pages/SettingsPage.test.ts b/desktop/src/renderer/src/pages/SettingsPage.test.ts index aae0ce7c5..a1a2a161e 100644 --- a/desktop/src/renderer/src/pages/SettingsPage.test.ts +++ b/desktop/src/renderer/src/pages/SettingsPage.test.ts @@ -12,13 +12,36 @@ describe("SettingsPage layout", () => { document.body.innerHTML = "" }) + it("renders the startup toggles with the dependent open-to-tray switch disabled", async () => { + render(SettingsPage) + + const section = document.querySelector("#startup") + expect(section).toBeTruthy() + const switches = section!.querySelectorAll('[role="switch"]') + expect(switches).toHaveLength(2) + const [runAtStartup, openToTray] = switches + expect(runAtStartup.hasAttribute("disabled")).toBe(false) + // The mock main process defaults to runAtStartup off, so the dependent + // toggle must be disabled. + await vi.waitFor(() => { + expect(openToTray.hasAttribute("disabled")).toBe(true) + }) + }) + + it("renders the notification level select", () => { + render(SettingsPage) + + expect(screen.getByText("Workspace Notifications")).toBeTruthy() + expect(screen.getByText("Failures only")).toBeTruthy() + }) + it("renders all settings sections as one page without section navigation links", () => { render(SettingsPage) expect(screen.queryByRole("navigation", { name: "Settings sections" })).toBeNull() expect(document.querySelectorAll('a[href^="#"]')).toHaveLength(0) - for (const name of ["General", "Appearance", "Updates", "Advanced"]) { + for (const name of ["General", "Startup", "Notifications", "Appearance", "Updates", "Advanced"]) { expect(screen.getByRole("heading", { name, level: 2 })).toBeTruthy() } }) diff --git a/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte b/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte index d4c585ec5..745864079 100644 --- a/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte +++ b/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte @@ -297,6 +297,10 @@ onMount(async () => { void reconcileRecoveryFromStatus() const qs = new URLSearchParams($querystring ?? "") + const tab = qs.get("tab") + if (tab === "logs" || tab === "terminal" || tab === "overview") { + activeTab = tab + } const action = qs.get("action") if (action === "open-ide" || action === "start") { // Clear query param so refresh doesn't re-trigger diff --git a/desktop/src/shared/app-settings.ts b/desktop/src/shared/app-settings.ts new file mode 100644 index 000000000..bbc9642c5 --- /dev/null +++ b/desktop/src/shared/app-settings.ts @@ -0,0 +1,19 @@ +export type TrayNotificationLevel = "off" | "failures" | "all" + +export interface AppSettings { + runAtStartup: boolean + openToTrayOnStartup: boolean + trayNotifications: TrayNotificationLevel +} + +export interface StartupStatus { + applied: boolean + enabled: boolean + status: "enabled" | "disabled" | "denied" | "unavailable" | "error" + detail?: string +} + +export interface AppSettingsState { + settings: AppSettings + startup: StartupStatus +}