From c5c285df82360883ddd20ca14ee4d82a531e079f Mon Sep 17 00:00:00 2001 From: vibesoftwarecoder Date: Fri, 9 Oct 2026 10:07:53 -0500 Subject: [PATCH 1/4] feat(dashboard): update notices - banner, Updates card, nav dot, About version Reads GET /api/system/updates (design section 6) and shows informational notices. Check now, the on/off switch and the instructions panel use the other two endpoints. Instructions are text only and nothing in the dashboard runs a command. Server text is rendered as plain React text; release links are used only when they start with https://github.com/vibesoftwarecoder/. Dismissal is per component and version in localStorage, guarded against blocked storage. Adds vitest, jsdom and @testing-library/react (41 tests, mutation-checked), a `test` script, and a Test step in the 'Dashboard build + audit' CI job. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/ci.yml | 5 + src/MultiSeat.Dashboard/package-lock.json | 1122 ++++++++++++++++- src/MultiSeat.Dashboard/package.json | 7 +- src/MultiSeat.Dashboard/src/App.tsx | 14 + src/MultiSeat.Dashboard/src/api/client.ts | 11 + src/MultiSeat.Dashboard/src/api/types.ts | 58 + .../src/components/UpdateBanner.tsx | 57 + .../src/components/UpdateInstructions.tsx | 110 ++ .../src/components/UpdatesCard.tsx | 182 +++ .../src/components/updateUtils.ts | 125 ++ .../src/hooks/UpdatesContext.tsx | 18 + .../src/hooks/useUpdates.ts | 109 ++ src/MultiSeat.Dashboard/src/index.css | 196 +++ .../src/pages/SettingsPage.tsx | 10 + .../src/pages/SystemPage.tsx | 4 + src/MultiSeat.Dashboard/src/updates.test.tsx | 460 +++++++ src/MultiSeat.Dashboard/vite.config.ts | 8 +- 17 files changed, 2491 insertions(+), 5 deletions(-) create mode 100644 src/MultiSeat.Dashboard/src/components/UpdateBanner.tsx create mode 100644 src/MultiSeat.Dashboard/src/components/UpdateInstructions.tsx create mode 100644 src/MultiSeat.Dashboard/src/components/UpdatesCard.tsx create mode 100644 src/MultiSeat.Dashboard/src/components/updateUtils.ts create mode 100644 src/MultiSeat.Dashboard/src/hooks/UpdatesContext.tsx create mode 100644 src/MultiSeat.Dashboard/src/hooks/useUpdates.ts create mode 100644 src/MultiSeat.Dashboard/src/updates.test.tsx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 953ff6d..17598a0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,6 +78,11 @@ jobs: - name: Build run: npm run build + # The dashboard has a vitest + jsdom suite (update notices today). It runs here because + # nothing else does: `npm run build` type-checks the tests but never executes them. + - name: Test + run: npm test + # This FAILS the job on a high advisory, deliberately. A warning that does not fail is # what we already had -- nothing was watching, and 8 highs is what nothing-watching # produces. The tradeoff is real: an advisory published upstream can break CI on an diff --git a/src/MultiSeat.Dashboard/package-lock.json b/src/MultiSeat.Dashboard/package-lock.json index 81ba2ea..19263d8 100644 --- a/src/MultiSeat.Dashboard/package-lock.json +++ b/src/MultiSeat.Dashboard/package-lock.json @@ -14,6 +14,8 @@ }, "devDependencies": { "@eslint/js": "^9.18.0", + "@testing-library/dom": "10.4.2", + "@testing-library/react": "16.3.3", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.4", @@ -21,11 +23,75 @@ "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-refresh": "^0.4.16", "globals": "^15.14.0", + "jsdom": "27.4.0", "typescript": "~5.7.0", "typescript-eslint": "^8.20.0", - "vite": "^6.0.0" + "vite": "^6.0.0", + "vitest": "4.1.11" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", + "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.0.0", + "@csstools/css-color-parser": "^4.0.1", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.5" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -260,6 +326,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", + "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -308,6 +384,146 @@ "node": ">=6.9.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz", + "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz", + "integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.6.tgz", + "integrity": "sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.2", + "@csstools/css-calc": "^3.4.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz", + "integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.15.tgz", + "integrity": "sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz", + "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -907,6 +1123,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -1380,6 +1614,68 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/dom": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1425,6 +1721,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1775,6 +2089,119 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.11", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -1798,6 +2225,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", @@ -1815,6 +2252,16 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -1838,6 +2285,26 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1858,8 +2325,18 @@ "node": ">=6.0.0" } }, - "node_modules/brace-expansion": { - "version": "1.1.21", + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.21", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, @@ -1934,6 +2411,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2013,6 +2500,46 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/cssstyle": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", + "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -2020,6 +2547,30 @@ "dev": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz", + "integrity": "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^15.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2038,6 +2589,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2045,6 +2603,23 @@ "dev": true, "license": "MIT" }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.425", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.425.tgz", @@ -2052,6 +2627,26 @@ "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -2284,6 +2879,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -2294,6 +2899,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2445,6 +3060,47 @@ "node": ">=8" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2505,6 +3161,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2542,6 +3205,46 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "27.4.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz", + "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.28", + "@asamuzakjp/dom-selector": "^6.7.6", + "@exodus/bytes": "^1.6.0", + "cssstyle": "^5.3.4", + "data-urls": "^6.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.0", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -2646,6 +3349,33 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -2702,6 +3432,20 @@ "node": ">=18" } }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -2765,6 +3509,19 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -2785,6 +3542,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2844,6 +3608,34 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -2875,6 +3667,13 @@ "react": "^19.2.5" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -2923,6 +3722,16 @@ "react-dom": ">=18" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -2978,6 +3787,19 @@ "fsevents": "~2.3.2" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -3023,6 +3845,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", @@ -3033,6 +3862,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -3059,6 +3902,30 @@ "node": ">=8" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -3076,6 +3943,62 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz", + "integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.18", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.18.tgz", + "integrity": "sha512-somxA8xKAUzlAxl0TX/jMSYIbRNFIVGnI96pVGJPeMc5BsHmfVXn6mOJT6U++ELnl04eGfzpfcEuZe8WiASkXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.18" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.18", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.18.tgz", + "integrity": "sha512-KI2PKvCmvQI/xD6ypPqkU0i7oxNXR/O58RLRaHR4svsgVUQ2JE/XC8BtMbG6PSehD27I8sZJRcC1ASnCy6lp3g==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -3256,6 +4179,143 @@ } } }, + "node_modules/vitest": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -3272,6 +4332,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -3282,6 +4359,45 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/src/MultiSeat.Dashboard/package.json b/src/MultiSeat.Dashboard/package.json index e511b52..63a1fd8 100644 --- a/src/MultiSeat.Dashboard/package.json +++ b/src/MultiSeat.Dashboard/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", + "test": "vitest run", "preview": "vite preview" }, "dependencies": { @@ -16,6 +17,8 @@ }, "devDependencies": { "@eslint/js": "^9.18.0", + "@testing-library/dom": "10.4.2", + "@testing-library/react": "16.3.3", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.4", @@ -23,8 +26,10 @@ "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-refresh": "^0.4.16", "globals": "^15.14.0", + "jsdom": "27.4.0", "typescript": "~5.7.0", "typescript-eslint": "^8.20.0", - "vite": "^6.0.0" + "vite": "^6.0.0", + "vitest": "4.1.11" } } diff --git a/src/MultiSeat.Dashboard/src/App.tsx b/src/MultiSeat.Dashboard/src/App.tsx index 746aa3a..fd464ca 100644 --- a/src/MultiSeat.Dashboard/src/App.tsx +++ b/src/MultiSeat.Dashboard/src/App.tsx @@ -5,10 +5,21 @@ import { SystemPage } from "./pages/SystemPage"; import { InputPage } from "./pages/InputPage"; import { SettingsPage } from "./pages/SettingsPage"; import { TipsPage } from "./pages/TipsPage"; +import { UpdatesProvider, useUpdatesContext } from "./hooks/UpdatesContext"; +import { UpdateBanner } from "./components/UpdateBanner"; +import { visibleAnnouncements } from "./components/updateUtils"; + +// A small dot after "System" while any update notice is announced and not dismissed. +function SystemNavDot() { + const updates = useUpdatesContext(); + if (!updates || visibleAnnouncements(updates.data, updates.dismissed).length === 0) return null; + return ; +} export default function App() { return ( +
+ } /> } /> @@ -49,6 +62,7 @@ export default function App() {
+
); } diff --git a/src/MultiSeat.Dashboard/src/api/client.ts b/src/MultiSeat.Dashboard/src/api/client.ts index a770c95..c1903b1 100644 --- a/src/MultiSeat.Dashboard/src/api/client.ts +++ b/src/MultiSeat.Dashboard/src/api/client.ts @@ -17,6 +17,7 @@ import type { NvencQualityPreset, HostApolloInfo, ScaleChange, + UpdatesState, } from "./types"; const BASE = "/api"; @@ -183,6 +184,16 @@ export const system = { method: "POST", body: JSON.stringify({ intervalMs }), }), + // Update notices. GET only reads the service's cache; it never makes the service call GitHub. + getUpdates: () => request("/system/updates"), + // 202 with the new state; 409 when update checks are off; 429 inside the 60 s cooldown. + checkUpdates: () => + request("/system/updates/check", { method: "POST" }), + setUpdatesEnabled: (enabled: boolean) => + request("/system/updates/settings", { + method: "POST", + body: JSON.stringify({ enabled }), + }), }; // ── Host (the console's own Apollo) ─────────────────────────────── diff --git a/src/MultiSeat.Dashboard/src/api/types.ts b/src/MultiSeat.Dashboard/src/api/types.ts index 10ebbe5..2c51674 100644 --- a/src/MultiSeat.Dashboard/src/api/types.ts +++ b/src/MultiSeat.Dashboard/src/api/types.ts @@ -280,3 +280,61 @@ export interface HostApolloInfo { consoleSessionId: number; note: string | null; } + +// ── Update notifications (GET /api/system/updates) ─────────────── +// Everything in here that is text comes from the service, which got it from GitHub. Treat it +// as untrusted: render it as plain React text and never as HTML. + +export type UpdateStatus = + | "upToDate" + | "updateAvailable" + | "ahead" + | "unknownInstalled" + | "notInstalled" + | "latestOnly" + | "unavailable" + | "disabled"; + +export type UpdateInstalledSource = + | "assembly" + | "marker" + | "marker-modified" + | "release-hash" + | "commit-match"; + +export type UpdateComponentId = "multiseat" | "apollovibe" | "moonlightvibe"; + +export interface UpdateInstalled { + version: string; + display: string; + source: UpdateInstalledSource; + note: string | null; +} + +export interface UpdateLatest { + version: string; + tag: string; + publishedAt: string; + releaseUrl: string; +} + +export interface UpdateComponent { + id: UpdateComponentId | string; + name: string; + status: UpdateStatus; + installed: UpdateInstalled | null; + installedNote?: string | null; + latest: UpdateLatest | null; + announce: boolean; + checkedAt: string | null; + error: string | null; +} + +export interface UpdatesState { + enabled: boolean; + intervalHours: number; + checkedAt: string | null; + nextCheckAt: string | null; + error: string | null; + components: UpdateComponent[]; +} diff --git a/src/MultiSeat.Dashboard/src/components/UpdateBanner.tsx b/src/MultiSeat.Dashboard/src/components/UpdateBanner.tsx new file mode 100644 index 0000000..24c4c72 --- /dev/null +++ b/src/MultiSeat.Dashboard/src/components/UpdateBanner.tsx @@ -0,0 +1,57 @@ +import { useState } from "react"; +import { useUpdatesContext } from "../hooks/UpdatesContext"; +import { ReleaseLink, UpdateInstructions } from "./UpdateInstructions"; +import { announcementText, visibleAnnouncements } from "./updateUtils"; + +/** + * Informational update notices, one line per announced component (at most three). + * Mounted once in App above the routes. Silent when there is no data, which is also what a + * 401 or a failed fetch looks like. It never runs anything: it shows text and a link. + */ +export function UpdateBanner() { + const updates = useUpdatesContext(); + const [open, setOpen] = useState(null); + + if (!updates) return null; + const lines = visibleAnnouncements(updates.data, updates.dismissed); + if (lines.length === 0) return null; + + return ( +
+ {lines.map((c) => { + const canInstruct = c.status === "updateAvailable"; + const expanded = open === c.id; + return ( +
+
+ {announcementText(c)} + + {canInstruct ? ( + + ) : ( + Release notes + )} + + +
+ {canInstruct && expanded && } +
+ ); + })} +
+ ); +} diff --git a/src/MultiSeat.Dashboard/src/components/UpdateInstructions.tsx b/src/MultiSeat.Dashboard/src/components/UpdateInstructions.tsx new file mode 100644 index 0000000..0f03f39 --- /dev/null +++ b/src/MultiSeat.Dashboard/src/components/UpdateInstructions.tsx @@ -0,0 +1,110 @@ +import type { UpdateComponent } from "../api/types"; +import { safeReleaseUrl } from "./updateUtils"; + +// Update instructions. TEXT ONLY: the dashboard never runs anything. Every command here is for +// the person at the host to read and run themselves. + +const MULTISEAT_COPY = "Copy-Item 'C:\\Program Files\\MultiSeat' 'C:\\Program Files\\MultiSeat.bak-' -Recurse"; +const MULTISEAT_INSTALL = ".\\scripts\\install-service.ps1 -FromZip .\\multiseat-windows-x64.zip"; + +export function ReleaseLink({ url, children }: { url: string | null | undefined; children: string }) { + const safe = safeReleaseUrl(url); + if (!safe) return null; + return ( + + {children} + + ); +} + +export function UpdateInstructions({ component }: { component: UpdateComponent }) { + const releaseUrl = component.latest?.releaseUrl; + + if (component.id === "multiseat") { + return ( +
+

+ Run these on the host, in an elevated PowerShell, when nobody is streaming. + The service restarts during the update, so every seat drops. +

+
    +
  1. + Download multiseat-windows-x64.zip from the release page and unzip it to a folder. +
  2. +
  3. + Copy the install folder first, so you can roll back: +
    {MULTISEAT_COPY}
    +
  4. +
  5. + From the unzipped folder: +
    {MULTISEAT_INSTALL}
    +
  6. +
+
    +
  • + The installer keeps your appsettings.json and appsettings.local.json byte + for byte and puts a copy in C:\ProgramData\MultiSeat\config-backups\. From 0.6.20 it + also keeps a full backup of the old install folder in{" "} + C:\ProgramData\MultiSeat\install-backups\. +
  • +
  • + Do not run a plain .\scripts\install-service.ps1 (a source build) over + an install that came from a release zip. Before 0.6.20 that could mix a self-contained install with a + framework-dependent one and leave the service unable to start ("No frameworks were found"). Update + release installs with -FromZip. +
  • +
  • + Do not use the System page's "Rebuild & Redeploy" button to update a release install. It runs + that same source-build path. +
  • +
+ Open the release page +
+ ); + } + + if (component.id === "apollovibe") { + return ( +
+

+ The MultiSeat installer does not update an ApolloVibe that is already installed: it + sees sunshine.exe and skips. There is no update script yet; one is planned. Until it + exists, update by hand: +

+
    +
  1. + When nobody is streaming, stop the MultiSeat service and anything running from your ApolloVibe folder + (by default C:\Program Files\ApolloVibe). +
  2. +
  3. + Back up sunshine.exe and the assets folder. +
  4. +
  5. + Download apollovibe-windows-x64.zip from the release page and check its SHA-256 against + the one in the release notes. +
  6. +
  7. + Extract it over the ApolloVibe folder. The zip holds sunshine.exe, assets and{" "} + tools, and no config folder, so Apollo's configuration is kept. +
  8. +
  9. Start the MultiSeat service again.
  10. +
+ Open the release page +
+ ); + } + + if (component.id === "moonlightvibe") { + return ( +
+

+ MoonlightVibe runs on your client devices, not on this host. Update it on each device from the + release page. The app also checks GitHub itself when it starts. +

+ Open the release page +
+ ); + } + + return null; +} diff --git a/src/MultiSeat.Dashboard/src/components/UpdatesCard.tsx b/src/MultiSeat.Dashboard/src/components/UpdatesCard.tsx new file mode 100644 index 0000000..688e297 --- /dev/null +++ b/src/MultiSeat.Dashboard/src/components/UpdatesCard.tsx @@ -0,0 +1,182 @@ +import { useEffect, useRef, useState } from "react"; +import type { UpdateComponent } from "../api/types"; +import { useUpdatesContext } from "../hooks/UpdatesContext"; +import { ReleaseLink, UpdateInstructions } from "./UpdateInstructions"; +import { formatAge, statusLabel } from "./updateUtils"; + +/** Mirrors the server-side cooldown for a manual check. */ +export const CHECK_COOLDOWN_MS = 60_000; + +/** What leaves the machine when update checks are on (design sections 4.7 and 5). */ +export const WHAT_IS_SENT = + "Sends: GET api.github.com, generic User-Agent, nothing else. Never downloads or installs anything."; + +function installedText(c: UpdateComponent): string { + if (c.status === "notInstalled") return "not found on this host"; + if (c.status === "unknownInstalled") return "not identified"; + if (c.status === "latestOnly") return "runs on other devices, cannot be seen"; + if (c.installed) return c.installed.display || c.installed.version; + return "unknown"; +} + +function latestText(c: UpdateComponent): string { + return c.latest ? c.latest.version : "none yet"; +} + +function noteText(c: UpdateComponent): string | null { + return c.installedNote || c.installed?.note || null; +} + +function UpdateRow({ c }: { c: UpdateComponent }) { + const note = noteText(c); + return ( +
+
{c.name}
+
+ installed: {installedText(c)} + latest: {latestText(c)} + {statusLabel(c.status)} +
+ {note &&
{note}
} + {c.latest && ( +
+ Release notes +
+ )} +
+ ); +} + +export function UpdatesCard() { + const updates = useUpdatesContext(); + const [busy, setBusy] = useState(false); + const [cooling, setCooling] = useState(false); + const [message, setMessage] = useState(null); + const timer = useRef>(undefined); + + useEffect(() => () => clearTimeout(timer.current), []); + + const startCooldown = () => { + setCooling(true); + clearTimeout(timer.current); + timer.current = setTimeout(() => setCooling(false), CHECK_COOLDOWN_MS); + }; + + if (!updates) return null; + const data = updates.data; + + if (!data) { + return ( +
+
+

Updates

+
+ {updates.loaded ? "Update information is not available right now." : "Loading..."} +
+
+
+ ); + } + + const handleCheck = async () => { + setBusy(true); + setMessage(null); + const r = await updates.checkNow(); + setBusy(false); + if (r.ok) { + startCooldown(); + } else { + setMessage(r.message); + if (r.kind === "cooldown") startCooldown(); + } + }; + + const handleEnabled = async (enabled: boolean) => { + setBusy(true); + setMessage(null); + const r = await updates.setEnabled(enabled); + setBusy(false); + if (!r.ok) setMessage(r.message); + }; + + // ── Off: nothing has contacted the internet; offer the one-click opt-in ── + if (!data.enabled) { + return ( +
+
+

Updates

+

+ Update checks are off. MultiSeat has not contacted the internet. +

+

+ If you turn them on, about every {data.intervalHours} hours this host asks api.github.com for the + release lists of vibesoftwarecoder/MultiSeat, vibesoftwarecoder/ApolloVibe and + vibesoftwarecoder/MoonlightVibe. {WHAT_IS_SENT} The request carries a generic User-Agent and no + version, host name, user name, seat data or API key. GitHub, and anyone who can see your network + traffic, can see that this host's IP address asked for those pages. Details are in + docs/security-posture.md, "Outbound connections". +

+ + {message &&
{message}
} + {data.components.length > 0 && ( +
+ {data.components.map((c) => ( + + ))} +
+ )} +
+
+ ); + } + + // ── On ── + const checkDisabled = busy || cooling; + return ( +
+
+
+

Updates

+ Last checked {formatAge(data.checkedAt)} + +
+ {cooling && !message && ( +
+ A check just ran. You can check again in a minute. +
+ )} + {message &&
{message}
} + {data.error &&
Last check failed: {data.error}
} + +
+ {data.components.map((c) => ( + + ))} +
+ +
+ Update checks: ON + +
+
{WHAT_IS_SENT}
+ +

How to update

+
+ Nothing here runs anything. These are steps for you to follow on the host. +
+ {data.components.map((c) => ( +
+ {c.name} + +
+ ))} +
+
+ ); +} diff --git a/src/MultiSeat.Dashboard/src/components/updateUtils.ts b/src/MultiSeat.Dashboard/src/components/updateUtils.ts new file mode 100644 index 0000000..1498ce1 --- /dev/null +++ b/src/MultiSeat.Dashboard/src/components/updateUtils.ts @@ -0,0 +1,125 @@ +import type { UpdateComponent, UpdateStatus, UpdatesState } from "../api/types"; + +// Helpers for the update notices. Kept free of React so they are easy to test. + +export const DISMISS_KEY = "multiseat-update-dismissed"; + +/** At most this many announcement lines are shown at once. */ +export const MAX_ANNOUNCEMENTS = 3; + +/** Release links are only followed when the service-built URL points into our own GitHub account. */ +const RELEASE_URL_PREFIX = "https://github.com/vibesoftwarecoder/"; + +/** + * Returns the URL when it is safe to put in an href, otherwise null. + * The service builds this URL from a fixed repo name and the parsed tag, so a mismatch means + * something is wrong, and the safe answer is to show no link at all. + */ +export function safeReleaseUrl(url: unknown): string | null { + if (typeof url !== "string" || !url.startsWith(RELEASE_URL_PREFIX)) return null; + try { + const parsed = new URL(url); + if ( + parsed.protocol !== "https:" || + parsed.hostname !== "github.com" || + parsed.username !== "" || + parsed.password !== "" || + !parsed.pathname.startsWith("/vibesoftwarecoder/") + ) { + return null; + } + } catch { + return null; + } + return url; +} + +/** Dismissed versions per component id. Storage can be blocked or throw, so any failure is an empty map. */ +export function loadDismissed(): Record { + try { + const raw = localStorage.getItem(DISMISS_KEY); + if (!raw) return {}; + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {}; + const out: Record = {}; + for (const [id, version] of Object.entries(parsed)) { + if (typeof version === "string") out[id] = version; + } + return out; + } catch { + return {}; + } +} + +/** Best effort. A failed write only means the notice comes back on the next page load. */ +export function saveDismissed(map: Record): void { + try { + localStorage.setItem(DISMISS_KEY, JSON.stringify(map)); + } catch { + // Storage blocked or full: the in-memory state still hides the notice for this visit. + } +} + +/** The components whose banner line should show: announced, with a latest version, not dismissed for that version. */ +export function visibleAnnouncements( + state: UpdatesState | null, + dismissed: Record +): UpdateComponent[] { + if (!state || !Array.isArray(state.components)) return []; + return state.components + .filter((c) => c.announce && c.latest != null && dismissed[c.id] !== c.latest.version) + .slice(0, MAX_ANNOUNCEMENTS); +} + +/** "just now", "5 min ago", "2 h ago", "3 d ago"; null or unparseable input gives "not yet". */ +export function formatAge(iso: string | null | undefined, now: number = Date.now()): string { + if (!iso) return "not yet"; + const then = Date.parse(iso); + if (Number.isNaN(then)) return "not yet"; + const minutes = Math.floor((now - then) / 60_000); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes} min ago`; + const hours = Math.floor(minutes / 60); + if (hours < 48) return `${hours} h ago`; + return `${Math.floor(hours / 24)} d ago`; +} + +/** Short status text for the System page card. None of these may call an unidentified build outdated or available. */ +export function statusLabel(status: UpdateStatus): string { + switch (status) { + case "upToDate": + return "Up to date"; + case "updateAvailable": + return "Update available"; + case "ahead": + return "Newer than the latest release"; + case "unknownInstalled": + return "Cannot compare"; + case "notInstalled": + return "Not found on this host"; + case "latestOnly": + return "Not tied to a specific client"; + case "unavailable": + return "No data yet"; + case "disabled": + return "Update checks off"; + default: + return "Unknown"; + } +} + +/** The one-line banner wording for an announced component (design section 7.3). Plain text only. */ +export function announcementText(c: UpdateComponent): string { + const latest = c.latest; + if (!latest) return `${c.name}: a newer release exists.`; + if (c.status === "latestOnly") { + return `New ${c.name} release ${latest.version} for your client devices. MultiSeat cannot see which version they run.`; + } + if (c.status === "unknownInstalled") { + return `${c.name} ${latest.tag} is available. This host's build could not be identified, so it may already be current.`; + } + if (c.installed) { + return `${c.name} ${latest.version} is available. You have ${c.installed.version}.`; + } + return `${c.name} ${latest.version} is available.`; +} diff --git a/src/MultiSeat.Dashboard/src/hooks/UpdatesContext.tsx b/src/MultiSeat.Dashboard/src/hooks/UpdatesContext.tsx new file mode 100644 index 0000000..23d6658 --- /dev/null +++ b/src/MultiSeat.Dashboard/src/hooks/UpdatesContext.tsx @@ -0,0 +1,18 @@ +import { createContext, useContext, type ReactNode } from "react"; +import { useUpdates } from "./useUpdates"; + +type UpdatesValue = ReturnType; + +const UpdatesContext = createContext(null); + +/** One shared copy of the update state, so the banner, nav dot, System card and About all agree. */ +export function UpdatesProvider({ children }: { children: ReactNode }) { + const value = useUpdates(); + return {children}; +} + +/** Null outside a provider, so callers must cope with the data being absent. */ +// eslint-disable-next-line react-refresh/only-export-components +export function useUpdatesContext(): UpdatesValue | null { + return useContext(UpdatesContext); +} diff --git a/src/MultiSeat.Dashboard/src/hooks/useUpdates.ts b/src/MultiSeat.Dashboard/src/hooks/useUpdates.ts new file mode 100644 index 0000000..0ca107d --- /dev/null +++ b/src/MultiSeat.Dashboard/src/hooks/useUpdates.ts @@ -0,0 +1,109 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { ApiError, system } from "../api/client"; +import type { UpdatesState } from "../api/types"; +import { loadDismissed, saveDismissed } from "../components/updateUtils"; + +/** A cheap local read on the service, so ten minutes is plenty. */ +export const UPDATES_POLL_MS = 10 * 60 * 1000; +/** Window-focus reloads are throttled so tabbing back and forth cannot become a request storm. */ +const FOCUS_MIN_GAP_MS = 10_000; + +export type ActionResult = + | { ok: true } + | { ok: false; kind: "off" | "cooldown" | "error"; message: string }; + +function looksLikeState(value: unknown): value is UpdatesState { + return ( + typeof value === "object" && + value !== null && + Array.isArray((value as UpdatesState).components) + ); +} + +function toResult(e: unknown): ActionResult { + if (e instanceof ApiError) { + if (e.status === 409) { + return { ok: false, kind: "off", message: "Update checks are off. Turn them on first." }; + } + if (e.status === 429) { + return { + ok: false, + kind: "cooldown", + message: "A check ran a moment ago. Try again in a minute.", + }; + } + return { ok: false, kind: "error", message: e.message || "The request failed." }; + } + return { ok: false, kind: "error", message: "Could not reach the service." }; +} + +/** + * Loads the update state on mount, on window focus and every ten minutes. + * A 401 or any other failure clears the data, which hides the banner. There is no error state + * to show and no retry beyond the normal schedule. + */ +export function useUpdates() { + const [data, setData] = useState(null); + const [loaded, setLoaded] = useState(false); + const [dismissed, setDismissed] = useState>(loadDismissed); + const alive = useRef(true); + const lastLoad = useRef(0); + + const load = useCallback(async () => { + lastLoad.current = Date.now(); + try { + const result = await system.getUpdates(); + if (alive.current) setData(looksLikeState(result) ? result : null); + } catch { + if (alive.current) setData(null); + } finally { + if (alive.current) setLoaded(true); + } + }, []); + + useEffect(() => { + alive.current = true; + void load(); + const timer = setInterval(() => void load(), UPDATES_POLL_MS); + const onFocus = () => { + if (Date.now() - lastLoad.current >= FOCUS_MIN_GAP_MS) void load(); + }; + window.addEventListener("focus", onFocus); + return () => { + alive.current = false; + clearInterval(timer); + window.removeEventListener("focus", onFocus); + }; + }, [load]); + + const apply = useCallback( + async (call: () => Promise): Promise => { + try { + const result = await call(); + if (alive.current && looksLikeState(result)) setData(result); + await load(); + return { ok: true }; + } catch (e) { + return toResult(e); + } + }, + [load] + ); + + const checkNow = useCallback(() => apply(() => system.checkUpdates()), [apply]); + const setEnabled = useCallback( + (enabled: boolean) => apply(() => system.setUpdatesEnabled(enabled)), + [apply] + ); + + /** Hides this component's notice until a newer version is announced. */ + const dismiss = useCallback((id: string, version: string) => { + setDismissed((prev) => { + const next = { ...prev, [id]: version }; + saveDismissed(next); + return next; + }); + }, []); + + return { data, loaded, dismissed, dismiss, refresh: load, checkNow, setEnabled }; +} diff --git a/src/MultiSeat.Dashboard/src/index.css b/src/MultiSeat.Dashboard/src/index.css index dba3b71..03b4523 100644 --- a/src/MultiSeat.Dashboard/src/index.css +++ b/src/MultiSeat.Dashboard/src/index.css @@ -983,3 +983,199 @@ select:focus { .process-table tr:last-child td { border-bottom: none; } + +/* ── Update notices ──────────────────────────────────────────── */ +/* Informational blue only (the info-banner palette). Never red or yellow. */ +.update-banners { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 16px; +} + +.update-banner-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.update-banner-text { + flex: 1; + min-width: 0; + overflow-wrap: anywhere; +} + +.update-banner-actions { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.update-dismiss { + color: inherit; + font-size: 16px; + line-height: 1; +} + +.update-banner .update-instructions { + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid #3b82f6; +} + +.nav-dot { + display: inline-block; + width: 8px; + height: 8px; + margin-left: 8px; + border-radius: 50%; + background: var(--accent); + vertical-align: middle; +} + +.nav-links a.active .nav-dot { + background: #fff; +} + +.update-card-head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + margin-bottom: 8px; +} + +.update-card-head h3 { + margin-right: auto; +} + +.update-checked, +.update-message, +.update-sent, +.update-howto-lead { + font-size: 12px; + overflow-wrap: anywhere; +} + +.update-message { + margin-bottom: 8px; +} + +.update-sent { + margin: 8px 0 12px; +} + +.update-off-lead { + font-size: 14px; + margin-bottom: 8px; +} + +.update-rows { + display: flex; + flex-direction: column; + margin: 12px 0; + border-top: 1px solid var(--border); +} + +.update-row { + padding: 10px 0; + border-bottom: 1px solid var(--border); + font-size: 13px; +} + +.update-row-name { + font-weight: 600; +} + +.update-row-main { + display: flex; + flex-wrap: wrap; + gap: 2px 14px; + overflow-wrap: anywhere; +} + +.update-row-status { + color: var(--text-secondary); +} + +.update-status--updateAvailable { + color: var(--accent); + font-weight: 600; +} + +.update-status--upToDate { + color: var(--success); +} + +.update-row-note { + font-size: 12px; + overflow-wrap: anywhere; +} + +.update-row-link { + margin-top: 4px; +} + +.update-link { + text-decoration: none; +} + +.update-toggle { + display: flex; + align-items: center; + gap: 12px; + font-size: 13px; +} + +.update-howto-title { + margin-top: 14px; + font-size: 14px; +} + +.update-howto { + margin-top: 8px; + font-size: 13px; +} + +.update-howto summary { + cursor: pointer; + font-weight: 600; +} + +.update-instructions { + font-size: 13px; + overflow-wrap: anywhere; +} + +.update-instructions p, +.update-instructions ol, +.update-instructions ul { + margin: 8px 0; +} + +.update-instructions ol, +.update-instructions ul { + padding-left: 20px; +} + +.update-instructions li { + margin-bottom: 6px; +} + +.update-instructions code, +.update-code { + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 12px; +} + +.update-code { + margin: 4px 0; + padding: 6px 8px; + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: 6px; + white-space: pre-wrap; + overflow-wrap: anywhere; +} diff --git a/src/MultiSeat.Dashboard/src/pages/SettingsPage.tsx b/src/MultiSeat.Dashboard/src/pages/SettingsPage.tsx index e04c243..65924b1 100644 --- a/src/MultiSeat.Dashboard/src/pages/SettingsPage.tsx +++ b/src/MultiSeat.Dashboard/src/pages/SettingsPage.tsx @@ -1,6 +1,10 @@ import { useState } from "react"; +import { useUpdatesContext } from "../hooks/UpdatesContext"; export function SettingsPage() { + // The installed version comes from the update data; it is simply absent when that is unavailable. + const updates = useUpdatesContext(); + const installedVersion = updates?.data?.components.find((c) => c.id === "multiseat")?.installed?.display; const [apiKey, setApiKey] = useState( () => localStorage.getItem("multiseat-api-key") ?? "" ); @@ -56,6 +60,12 @@ export function SettingsPage() { each streamed via Apollo (Sunshine fork) to Moonlight clients.

+ {installedVersion && ( +
+ MultiSeat Version + {installedVersion} +
+ )}
API Endpoint {window.location.origin}/api diff --git a/src/MultiSeat.Dashboard/src/pages/SystemPage.tsx b/src/MultiSeat.Dashboard/src/pages/SystemPage.tsx index ad86eb7..d8ec2e8 100644 --- a/src/MultiSeat.Dashboard/src/pages/SystemPage.tsx +++ b/src/MultiSeat.Dashboard/src/pages/SystemPage.tsx @@ -3,6 +3,7 @@ import { system, input } from "../api/client"; import { usePolling } from "../hooks/usePolling"; import type { HookStatus, TopProcess, DiskInfo } from "../api/types"; import { RefreshRateControl } from "../components/RefreshRateControl"; +import { UpdatesCard } from "../components/UpdatesCard"; export function SystemPage() { const [rebuilding, setRebuilding] = useState(false); @@ -287,6 +288,9 @@ export function SystemPage() {
)} + {/* Update notices (opt-in) */} + + {/* Host-wide refresh rate (issue #74) */}
diff --git a/src/MultiSeat.Dashboard/src/updates.test.tsx b/src/MultiSeat.Dashboard/src/updates.test.tsx new file mode 100644 index 0000000..3a7f5aa --- /dev/null +++ b/src/MultiSeat.Dashboard/src/updates.test.tsx @@ -0,0 +1,460 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import type { UpdateComponent, UpdatesState } from "./api/types"; +import { UpdatesProvider } from "./hooks/UpdatesContext"; +import { UpdateBanner } from "./components/UpdateBanner"; +import { UpdatesCard } from "./components/UpdatesCard"; +import { SettingsPage } from "./pages/SettingsPage"; +import App from "./App"; +import { DISMISS_KEY, safeReleaseUrl, visibleAnnouncements } from "./components/updateUtils"; + +// ── Fixtures ──────────────────────────────────────────────────── + +const REL = "https://github.com/vibesoftwarecoder"; + +function multiseat(over: Partial = {}): UpdateComponent { + return { + id: "multiseat", + name: "MultiSeat", + status: "updateAvailable", + installed: { version: "0.6.18", display: "0.6.18 (ebcd253)", source: "assembly", note: null }, + latest: { version: "0.6.19", tag: "v0.6.19", publishedAt: "2026-10-09T10:20:56Z", releaseUrl: `${REL}/MultiSeat/releases/tag/v0.6.19` }, + announce: true, + checkedAt: "2026-10-09T14:02:11Z", + error: null, + ...over, + }; +} + +function apollo(over: Partial = {}): UpdateComponent { + return { + id: "apollovibe", + name: "ApolloVibe", + status: "unknownInstalled", + installed: null, + installedNote: "Built locally or not a published release (no hash match).", + latest: { version: "2026.6.1-ms7", tag: "v2026.6.1-ms7", publishedAt: "2026-10-09T10:20:56Z", releaseUrl: `${REL}/ApolloVibe/releases/tag/v2026.6.1-ms7` }, + announce: true, + checkedAt: "2026-10-09T14:02:11Z", + error: null, + ...over, + }; +} + +function moonlight(over: Partial = {}): UpdateComponent { + return { + id: "moonlightvibe", + name: "MoonlightVibe", + status: "latestOnly", + installed: null, + installedNote: "Runs on your other devices. MultiSeat cannot see its version.", + latest: { version: "6.3.10", tag: "v6.3.10", publishedAt: "2026-10-09T10:20:56Z", releaseUrl: `${REL}/MoonlightVibe/releases/tag/v6.3.10` }, + announce: true, + checkedAt: "2026-10-09T14:02:11Z", + error: null, + ...over, + }; +} + +function state(components: UpdateComponent[], over: Partial = {}): UpdatesState { + return { + enabled: true, + intervalHours: 12, + checkedAt: new Date(Date.now() - 2 * 3600_000).toISOString(), + nextCheckAt: null, + error: null, + components, + ...over, + }; +} + +// ── fetch mock ────────────────────────────────────────────────── + +type Handler = (init?: RequestInit) => { status: number; body?: unknown } | Promise<{ status: number; body?: unknown }>; +let routes: Record; +let calls: { key: string; init?: RequestInit }[]; + +function json(status: number, body?: unknown) { + return { status, body }; +} + +beforeEach(() => { + localStorage.clear(); + calls = []; + routes = {}; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, init?: RequestInit) => { + const key = `${init?.method ?? "GET"} ${url}`; + calls.push({ key, init }); + const h = routes[key]; + if (!h) return new Response(JSON.stringify({ error: "not found" }), { status: 404 }); + const r = await h(init); + return new Response(r.body === undefined ? null : JSON.stringify(r.body), { + status: r.status, + headers: { "Content-Type": "application/json" }, + }); + }) + ); +}); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +const GET = "GET /api/system/updates"; +const CHECK = "POST /api/system/updates/check"; +const SETTINGS = "POST /api/system/updates/settings"; + +function mount(ui: React.ReactNode = <>) { + return render({ui}); +} + +function banner() { + return screen.queryByRole("region", { name: "Update notices" }); +} + +// ── Banner ────────────────────────────────────────────────────── + +describe("banner wording per status", () => { + it("shows the design 7.3 line for each announced component", async () => { + routes[GET] = () => json(200, state([multiseat(), apollo(), moonlight()])); + mount(); + const b = await screen.findByRole("region", { name: "Update notices" }); + expect(within(b).getByText("MultiSeat 0.6.19 is available. You have 0.6.18.")).toBeTruthy(); + expect( + within(b).getByText( + "ApolloVibe v2026.6.1-ms7 is available. This host's build could not be identified, so it may already be current." + ) + ).toBeTruthy(); + expect( + within(b).getByText( + "New MoonlightVibe release 6.3.10 for your client devices. MultiSeat cannot see which version they run." + ) + ).toBeTruthy(); + // How to update for the one we can compare, Release notes (a link) for the other two. + expect(within(b).getByRole("button", { name: "How to update" })).toBeTruthy(); + expect(within(b).getAllByRole("link", { name: "Release notes" })).toHaveLength(2); + }); + + it("shows nothing for components that are not announced", async () => { + routes[GET] = () => json(200, state([multiseat({ status: "upToDate", announce: false }), apollo({ announce: false })])); + mount(); + await waitFor(() => expect(calls.length).toBeGreaterThan(0)); + await act(async () => {}); + expect(banner()).toBeNull(); + }); + + it("shows at most three lines", () => { + const four = [multiseat(), apollo(), moonlight(), multiseat({ id: "extra", name: "Extra" })]; + expect(visibleAnnouncements(state(four), {})).toHaveLength(3); + }); + + it("uses only the informational palette, never an error banner", async () => { + routes[GET] = () => json(200, state([multiseat()])); + const { container } = mount(); + await screen.findByRole("region", { name: "Update notices" }); + expect(container.querySelector(".info-banner")).toBeTruthy(); + expect(container.querySelector(".error-banner")).toBeNull(); + }); + + it("opens the text-only MultiSeat instructions from the banner", async () => { + routes[GET] = () => json(200, state([multiseat()])); + mount(); + fireEvent.click(await screen.findByRole("button", { name: "How to update" })); + expect(screen.getByText(/install-service\.ps1 -FromZip/)).toBeTruthy(); + }); +}); + +describe("dismissal", () => { + it("hides a dismissed notice and stores it per component and version", async () => { + routes[GET] = () => json(200, state([multiseat()])); + mount(); + fireEvent.click(await screen.findByRole("button", { name: /Dismiss the MultiSeat notice/ })); + expect(banner()).toBeNull(); + expect(JSON.parse(localStorage.getItem(DISMISS_KEY)!)).toEqual({ multiseat: "0.6.19" }); + }); + + it("stays hidden for the dismissed version after a reload", async () => { + localStorage.setItem(DISMISS_KEY, JSON.stringify({ multiseat: "0.6.19" })); + routes[GET] = () => json(200, state([multiseat()])); + mount(); + await waitFor(() => expect(calls.length).toBe(1)); + await act(async () => {}); + expect(banner()).toBeNull(); + }); + + it("announces again when a newer version appears", async () => { + localStorage.setItem(DISMISS_KEY, JSON.stringify({ multiseat: "0.6.19" })); + const next = multiseat({ + latest: { version: "0.6.20", tag: "v0.6.20", publishedAt: "x", releaseUrl: `${REL}/MultiSeat/releases/tag/v0.6.20` }, + }); + routes[GET] = () => json(200, state([next])); + mount(); + expect(await screen.findByText("MultiSeat 0.6.20 is available. You have 0.6.18.")).toBeTruthy(); + }); + + it("dismissing one component leaves the others", async () => { + routes[GET] = () => json(200, state([multiseat(), moonlight()])); + mount(); + fireEvent.click(await screen.findByRole("button", { name: /Dismiss the MultiSeat notice/ })); + expect(screen.queryByText(/MultiSeat 0\.6\.19 is available/)).toBeNull(); + expect(screen.getByText(/New MoonlightVibe release 6\.3\.10/)).toBeTruthy(); + }); + + it("still renders, and still hides for the visit, when localStorage throws", async () => { + const real = Storage.prototype; + vi.spyOn(real, "getItem").mockImplementation(function (this: Storage, k: string) { + if (k === DISMISS_KEY) throw new Error("blocked"); + return null; + }); + vi.spyOn(real, "setItem").mockImplementation(() => { + throw new Error("blocked"); + }); + routes[GET] = () => json(200, state([multiseat()])); + mount(); + fireEvent.click(await screen.findByRole("button", { name: /Dismiss the MultiSeat notice/ })); + expect(banner()).toBeNull(); + }); +}); + +describe("silent failure", () => { + it("shows nothing on a 401", async () => { + routes[GET] = () => json(401, { error: "Unauthorized" }); + const { container } = mount(); + await waitFor(() => expect(calls.length).toBe(1)); + await act(async () => {}); + expect(banner()).toBeNull(); + expect(container.querySelector(".error-banner")).toBeNull(); + expect(screen.queryByText(/unauthori/i)).toBeNull(); + }); + + it("shows nothing on a network error and does not retry in a loop", async () => { + (fetch as unknown as ReturnType).mockRejectedValue(new TypeError("Failed to fetch")); + const { container } = mount(); + await waitFor(() => expect((fetch as unknown as ReturnType).mock.calls.length).toBe(1)); + await act(async () => {}); + await new Promise((r) => setTimeout(r, 200)); + expect(banner()).toBeNull(); + expect(container.querySelector(".error-banner")).toBeNull(); + expect((fetch as unknown as ReturnType).mock.calls.length).toBe(1); + }); +}); + +describe("loading", () => { + it("sends the saved API key like every other call", async () => { + localStorage.setItem("multiseat-api-key", "k123"); + routes[GET] = () => json(200, state([])); + mount(); + await waitFor(() => expect(calls.length).toBe(1)); + expect((calls[0].init?.headers as Record)["X-MultiSeat-Key"]).toBe("k123"); + }); + + it("reloads on window focus, but not twice in quick succession", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + routes[GET] = () => json(200, state([])); + mount(); + await waitFor(() => expect(calls.length).toBe(1)); + fireEvent.focus(window); + expect(calls.length).toBe(1); + vi.setSystemTime(Date.now() + 11_000); + fireEvent.focus(window); + await waitFor(() => expect(calls.length).toBe(2)); + }); +}); + +// ── System page card ──────────────────────────────────────────── + +describe("Updates card", () => { + it("shows all three rows with installed, latest and status", async () => { + routes[GET] = () => json(200, state([multiseat(), apollo(), moonlight()])); + mount(); + const rows = await screen.findAllByText(/^installed:/); + expect(rows).toHaveLength(3); + expect(screen.getByText("installed: 0.6.18 (ebcd253)")).toBeTruthy(); + expect(screen.getByText("Update available")).toBeTruthy(); + expect(screen.getByText(/Last checked 2 h ago/)).toBeTruthy(); + }); + + it("never calls an unidentified ApolloVibe build available or outdated", async () => { + routes[GET] = () => json(200, state([apollo()])); + const { container } = mount(); + await screen.findByText("installed: not identified"); + const row = container.querySelector('.update-row[data-component="apollovibe"]') as HTMLElement; + expect(row.textContent).toContain("not identified"); + expect(row.textContent).toContain("Built locally or not a published release"); + expect(row.textContent).not.toMatch(/available|outdated/i); + }); + + it("labels MoonlightVibe as not tied to a specific client", async () => { + routes[GET] = () => json(200, state([moonlight()])); + mount(); + expect(await screen.findByText("Not tied to a specific client")).toBeTruthy(); + }); + + it("renders server text as plain text, never as HTML", async () => { + const evil = ''; + const hostile = apollo({ + installedNote: evil, + latest: { version: evil, tag: evil, publishedAt: "x", releaseUrl: `${REL}/ApolloVibe/releases/tag/x` }, + }); + routes[GET] = () => json(200, state([hostile])); + const { container } = mount(); + await screen.findByText("installed: not identified"); + expect(container.querySelector("img")).toBeNull(); + expect(container.textContent).toContain(evil); + expect((window as unknown as { __pwned?: number }).__pwned).toBeUndefined(); + }); + + it("gives the MultiSeat steps with -FromZip and the warnings, and nothing that runs a command", async () => { + routes[GET] = () => json(200, state([multiseat(), apollo(), moonlight()])); + const { container } = mount(); + await screen.findByText("installed: 0.6.18 (ebcd253)"); + const ms = container.querySelector('details[data-component="multiseat"]') as HTMLElement; + expect(ms.textContent).toContain("-FromZip"); + const commands = Array.from(ms.querySelectorAll(".update-code")).map((e) => e.textContent); + expect(commands).toContain(".\\scripts\\install-service.ps1 -FromZip .\\multiseat-windows-x64.zip"); + expect(ms.textContent).toContain("when nobody is streaming"); + expect(ms.textContent).toMatch(/Do not.*run a plain/s); + expect(ms.textContent).toContain("Rebuild & Redeploy"); + const ap = container.querySelector('details[data-component="apollovibe"]') as HTMLElement; + expect(ap.textContent).toContain("There is no update script yet"); + // Instruction panels hold text and links only. + for (const panel of container.querySelectorAll(".update-instructions")) { + expect(panel.querySelectorAll("button, form, input")).toHaveLength(0); + } + }); + + it("when off, offers the one-click turn-on and says exactly what is sent", async () => { + routes[GET] = () => json(200, state([multiseat({ status: "disabled", announce: false, latest: null })], { enabled: false })); + routes[SETTINGS] = () => json(200, state([], { enabled: true })); + mount(); + expect(await screen.findByText(/Update checks are off\. MultiSeat has not contacted the internet\./)).toBeTruthy(); + expect(screen.getByText(/Sends: GET api\.github\.com, generic User-Agent, nothing else\. Never downloads or installs anything\./)).toBeTruthy(); + expect(screen.getByText(/no\s+version, host name, user name, seat data or API key/)).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Check now" })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Turn on update checks" })); + await waitFor(() => expect(calls.some((c) => c.key === SETTINGS)).toBe(true)); + const post = calls.find((c) => c.key === SETTINGS)!; + expect(JSON.parse(post.init!.body as string)).toEqual({ enabled: true }); + }); + + it("when on, offers Turn off, which posts enabled false", async () => { + routes[GET] = () => json(200, state([multiseat()])); + routes[SETTINGS] = () => json(200, state([], { enabled: false })); + mount(); + fireEvent.click(await screen.findByRole("button", { name: "Turn off" })); + await waitFor(() => expect(calls.some((c) => c.key === SETTINGS)).toBe(true)); + expect(JSON.parse(calls.find((c) => c.key === SETTINGS)!.init!.body as string)).toEqual({ enabled: false }); + }); + + it("Check now posts, refreshes and then waits out the cooldown", async () => { + routes[GET] = () => json(200, state([multiseat()])); + routes[CHECK] = () => json(202, state([multiseat()])); + mount(); + const btn = await screen.findByRole("button", { name: "Check now" }); + fireEvent.click(btn); + await waitFor(() => expect(calls.some((c) => c.key === CHECK)).toBe(true)); + await waitFor(() => expect((screen.getByRole("button", { name: "Check now" }) as HTMLButtonElement).disabled).toBe(true)); + expect(screen.getByText(/You can check again in a minute/)).toBeTruthy(); + }); + + it("handles 409 (checks off) with a clear message", async () => { + routes[GET] = () => json(200, state([multiseat()])); + routes[CHECK] = () => json(409, { error: "Update checks are off" }); + mount(); + fireEvent.click(await screen.findByRole("button", { name: "Check now" })); + expect(await screen.findByText("Update checks are off. Turn them on first.")).toBeTruthy(); + }); + + it("handles 429 (cooldown) with a clear message and disables the button", async () => { + routes[GET] = () => json(200, state([multiseat()])); + routes[CHECK] = () => json(429, { error: "Too many" }); + mount(); + fireEvent.click(await screen.findByRole("button", { name: "Check now" })); + expect(await screen.findByText("A check ran a moment ago. Try again in a minute.")).toBeTruthy(); + expect((screen.getByRole("button", { name: "Check now" }) as HTMLButtonElement).disabled).toBe(true); + }); +}); + +// ── Release links ─────────────────────────────────────────────── + +describe("release links", () => { + it.each([ + [`${REL}/MultiSeat/releases/tag/v0.6.19`, true], + ["http://github.com/vibesoftwarecoder/MultiSeat/releases/tag/v1", false], + ["https://github.com/evil/MultiSeat/releases/tag/v1", false], + ["https://github.com/vibesoftwarecoder.evil.com/x", false], + ["https://evil.example/https://github.com/vibesoftwarecoder/x", false], + ["https://github.com/vibesoftwarecoder/../evil/x", false], + ["https://github.com.evil.example/vibesoftwarecoder/x", false], + ["https://user@github.com/vibesoftwarecoder/x", false], + ["javascript:alert(1)", false], + ["", false], + ])("%s -> allowed %s", (url, ok) => { + expect(safeReleaseUrl(url)).toBe(ok ? url : null); + }); + + it("rejects non-strings", () => { + expect(safeReleaseUrl(undefined)).toBeNull(); + expect(safeReleaseUrl(42)).toBeNull(); + }); + + it("opens allowed links in a new tab with noopener noreferrer", async () => { + routes[GET] = () => json(200, state([moonlight()])); + mount(); + const a = (await screen.findByRole("link", { name: "Release notes" })) as HTMLAnchorElement; + expect(a.getAttribute("href")).toBe(`${REL}/MoonlightVibe/releases/tag/v6.3.10`); + expect(a.getAttribute("target")).toBe("_blank"); + expect(a.getAttribute("rel")).toBe("noopener noreferrer"); + }); + + it("renders no link at all for a hostile URL", async () => { + const bad = moonlight({ latest: { version: "6.3.10", tag: "v6.3.10", publishedAt: "x", releaseUrl: "javascript:alert(1)" } }); + routes[GET] = () => json(200, state([bad])); + const { container } = mount(); + await screen.findByRole("region", { name: "Update notices" }); + expect(container.querySelector("a")).toBeNull(); + }); +}); + +// ── Settings and nav ──────────────────────────────────────────── + +describe("Settings > About", () => { + it("shows the installed MultiSeat version", async () => { + routes[GET] = () => json(200, state([multiseat()])); + mount(); + expect(await screen.findByText("0.6.18 (ebcd253)")).toBeTruthy(); + expect(screen.getByText("MultiSeat Version")).toBeTruthy(); + }); + + it("omits it when the update data is unavailable", async () => { + routes[GET] = () => json(401, { error: "x" }); + mount(); + await waitFor(() => expect(calls.length).toBe(1)); + await act(async () => {}); + expect(screen.queryByText("MultiSeat Version")).toBeNull(); + expect(screen.getByText("API Endpoint")).toBeTruthy(); + }); +}); + +describe("System nav dot", () => { + it("shows while a notice is announced and goes away once dismissed", async () => { + routes[GET] = () => json(200, state([multiseat()])); + render(); + expect(await screen.findByRole("img", { name: "Update notice" })).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: /Dismiss the MultiSeat notice/ })); + expect(screen.queryByRole("img", { name: "Update notice" })).toBeNull(); + }); + + it("is absent when nothing is announced", async () => { + routes[GET] = () => json(200, state([multiseat({ announce: false, status: "upToDate" })])); + render(); + await waitFor(() => expect(calls.some((c) => c.key === GET)).toBe(true)); + await act(async () => {}); + expect(screen.queryByRole("img", { name: "Update notice" })).toBeNull(); + }); +}); diff --git a/src/MultiSeat.Dashboard/vite.config.ts b/src/MultiSeat.Dashboard/vite.config.ts index 6f097b5..2860faa 100644 --- a/src/MultiSeat.Dashboard/vite.config.ts +++ b/src/MultiSeat.Dashboard/vite.config.ts @@ -1,8 +1,14 @@ -import { defineConfig } from "vite"; +import { defineConfig } from "vitest/config"; import react from "@vitejs/plugin-react"; export default defineConfig({ plugins: [react()], + test: { + environment: "jsdom", + include: ["src/**/*.test.{ts,tsx}"], + testTimeout: 10000, + hookTimeout: 10000, + }, server: { port: 3000, proxy: { From 89b013729f4c844323ee7938f9b168e5c19dac68 Mon Sep 17 00:00:00 2001 From: vibesoftwarecoder Date: Fri, 9 Oct 2026 10:39:56 -0500 Subject: [PATCH 2/4] feat(dashboard): one-time "update checks are off" notice Design section 5 asks for a dismissible notice when update checks are off. It shows only when the update data loaded, checks are off and it was not dismissed in this browser (localStorage["multiseat-update-off-notice-dismissed"] = "1", guarded against blocked storage). It never enables anything: "See what is sent" links to the System page, "Not now" dismisses. It sits outside the three-line update limit. Co-Authored-By: Claude Sonnet 5.5 --- src/MultiSeat.Dashboard/src/App.tsx | 2 + .../src/components/UpdateOffNotice.tsx | 34 ++++ .../src/components/updateUtils.ts | 20 +++ .../src/hooks/useUpdates.ts | 11 +- .../src/updateOffNotice.test.tsx | 170 ++++++++++++++++++ 5 files changed, 235 insertions(+), 2 deletions(-) create mode 100644 src/MultiSeat.Dashboard/src/components/UpdateOffNotice.tsx create mode 100644 src/MultiSeat.Dashboard/src/updateOffNotice.test.tsx diff --git a/src/MultiSeat.Dashboard/src/App.tsx b/src/MultiSeat.Dashboard/src/App.tsx index fd464ca..4267cf8 100644 --- a/src/MultiSeat.Dashboard/src/App.tsx +++ b/src/MultiSeat.Dashboard/src/App.tsx @@ -7,6 +7,7 @@ import { SettingsPage } from "./pages/SettingsPage"; import { TipsPage } from "./pages/TipsPage"; import { UpdatesProvider, useUpdatesContext } from "./hooks/UpdatesContext"; import { UpdateBanner } from "./components/UpdateBanner"; +import { UpdateOffNotice } from "./components/UpdateOffNotice"; import { visibleAnnouncements } from "./components/updateUtils"; // A small dot after "System" while any update notice is announced and not dismissed. @@ -52,6 +53,7 @@ export default function App() {
+ } /> } /> diff --git a/src/MultiSeat.Dashboard/src/components/UpdateOffNotice.tsx b/src/MultiSeat.Dashboard/src/components/UpdateOffNotice.tsx new file mode 100644 index 0000000..9c509fb --- /dev/null +++ b/src/MultiSeat.Dashboard/src/components/UpdateOffNotice.tsx @@ -0,0 +1,34 @@ +import { Link } from "react-router-dom"; +import { useUpdatesContext } from "../hooks/UpdatesContext"; + +/** + * One-time notice that update checks are off. It only informs: it never turns the check on. + * The person reads what is sent on the System page and decides there. + * Shown only when the data loaded and the check is off, and not dismissed in this browser. + */ +export function UpdateOffNotice() { + const updates = useUpdatesContext(); + if (!updates || !updates.data || updates.data.enabled !== false || updates.offNoticeDismissed) { + return null; + } + return ( +
+
+
+ + Update checks are off. MultiSeat has not contacted the internet. You can turn them on to be told + when a new MultiSeat, ApolloVibe or MoonlightVibe release is out. + + + + See what is sent + + + +
+
+
+ ); +} diff --git a/src/MultiSeat.Dashboard/src/components/updateUtils.ts b/src/MultiSeat.Dashboard/src/components/updateUtils.ts index 1498ce1..c481f1f 100644 --- a/src/MultiSeat.Dashboard/src/components/updateUtils.ts +++ b/src/MultiSeat.Dashboard/src/components/updateUtils.ts @@ -4,6 +4,8 @@ import type { UpdateComponent, UpdateStatus, UpdatesState } from "../api/types"; export const DISMISS_KEY = "multiseat-update-dismissed"; +export const OFF_NOTICE_DISMISS_KEY = "multiseat-update-off-notice-dismissed"; + /** At most this many announcement lines are shown at once. */ export const MAX_ANNOUNCEMENTS = 3; @@ -60,6 +62,24 @@ export function saveDismissed(map: Record): void { } } +/** Whether the one-time "update checks are off" notice was dismissed in this browser. Any storage failure means not dismissed. */ +export function loadOffNoticeDismissed(): boolean { + try { + return localStorage.getItem(OFF_NOTICE_DISMISS_KEY) === "1"; + } catch { + return false; + } +} + +/** Best effort, like saveDismissed. */ +export function saveOffNoticeDismissed(): void { + try { + localStorage.setItem(OFF_NOTICE_DISMISS_KEY, "1"); + } catch { + // Storage blocked or full: the in-memory flag still hides the notice for this visit. + } +} + /** The components whose banner line should show: announced, with a latest version, not dismissed for that version. */ export function visibleAnnouncements( state: UpdatesState | null, diff --git a/src/MultiSeat.Dashboard/src/hooks/useUpdates.ts b/src/MultiSeat.Dashboard/src/hooks/useUpdates.ts index 0ca107d..a6fc804 100644 --- a/src/MultiSeat.Dashboard/src/hooks/useUpdates.ts +++ b/src/MultiSeat.Dashboard/src/hooks/useUpdates.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { ApiError, system } from "../api/client"; import type { UpdatesState } from "../api/types"; -import { loadDismissed, saveDismissed } from "../components/updateUtils"; +import { loadDismissed, loadOffNoticeDismissed, saveDismissed, saveOffNoticeDismissed } from "../components/updateUtils"; /** A cheap local read on the service, so ten minutes is plenty. */ export const UPDATES_POLL_MS = 10 * 60 * 1000; @@ -46,6 +46,7 @@ export function useUpdates() { const [data, setData] = useState(null); const [loaded, setLoaded] = useState(false); const [dismissed, setDismissed] = useState>(loadDismissed); + const [offNoticeDismissed, setOffNoticeDismissed] = useState(loadOffNoticeDismissed); const alive = useRef(true); const lastLoad = useRef(0); @@ -105,5 +106,11 @@ export function useUpdates() { }); }, []); - return { data, loaded, dismissed, dismiss, refresh: load, checkNow, setEnabled }; + /** Hides the one-time "update checks are off" notice for good in this browser. */ + const dismissOffNotice = useCallback(() => { + saveOffNoticeDismissed(); + setOffNoticeDismissed(true); + }, []); + + return { data, loaded, dismissed, dismiss, offNoticeDismissed, dismissOffNotice, refresh: load, checkNow, setEnabled }; } diff --git a/src/MultiSeat.Dashboard/src/updateOffNotice.test.tsx b/src/MultiSeat.Dashboard/src/updateOffNotice.test.tsx new file mode 100644 index 0000000..5c7b2a3 --- /dev/null +++ b/src/MultiSeat.Dashboard/src/updateOffNotice.test.tsx @@ -0,0 +1,170 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import type { UpdateComponent, UpdatesState } from "./api/types"; +import { UpdatesProvider } from "./hooks/UpdatesContext"; +import { UpdateBanner } from "./components/UpdateBanner"; +import { UpdateOffNotice } from "./components/UpdateOffNotice"; +import { OFF_NOTICE_DISMISS_KEY } from "./components/updateUtils"; + +const REL = "https://github.com/vibesoftwarecoder"; + +function comp(id: string, name: string): UpdateComponent { + return { + id, + name, + status: "updateAvailable", + installed: { version: "1.0.0", display: "1.0.0", source: "assembly", note: null }, + latest: { version: "2.0.0", tag: "v2.0.0", publishedAt: "x", releaseUrl: `${REL}/${name}/releases/tag/v2.0.0` }, + announce: true, + checkedAt: null, + error: null, + }; +} + +function state(over: Partial = {}): UpdatesState { + return { enabled: false, intervalHours: 12, checkedAt: null, nextCheckAt: null, error: null, components: [], ...over }; +} + +let respond: () => Promise; +let calls: string[]; + +beforeEach(() => { + localStorage.clear(); + calls = []; + respond = async () => new Response(JSON.stringify(state()), { status: 200 }); + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, init?: RequestInit) => { + calls.push(`${init?.method ?? "GET"} ${url}`); + return respond(); + }) + ); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +function mount() { + return render( + + + + + + home page} /> + system page} /> + + + + ); +} + +const notice = () => screen.queryByRole("region", { name: "Update checks are off" }); +const settle = async () => { + await waitFor(() => expect(calls.length).toBeGreaterThan(0)); + await act(async () => {}); +}; + +describe("update checks are off notice", () => { + it("shows when the data loaded, checks are off and it was not dismissed", async () => { + mount(); + expect(await screen.findByRole("region", { name: "Update checks are off" })).toBeTruthy(); + }); + + it("says what the design says, including that nothing has contacted the internet", async () => { + mount(); + const n = await screen.findByRole("region", { name: "Update checks are off" }); + expect(n.textContent).toContain("Update checks are off. MultiSeat has not contacted the internet."); + expect(n.textContent).toContain("a new MultiSeat, ApolloVibe or MoonlightVibe release is out"); + }); + + it("is hidden when checks are on", async () => { + respond = async () => new Response(JSON.stringify(state({ enabled: true })), { status: 200 }); + mount(); + await settle(); + expect(notice()).toBeNull(); + }); + + it("is hidden while the data is still loading", async () => { + respond = () => new Promise(() => {}); + mount(); + await settle(); + expect(notice()).toBeNull(); + }); + + it("is hidden on a 401", async () => { + respond = async () => new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }); + mount(); + await settle(); + expect(notice()).toBeNull(); + }); + + it("is hidden on a network error", async () => { + respond = async () => { + throw new TypeError("Failed to fetch"); + }; + mount(); + await settle(); + expect(notice()).toBeNull(); + }); + + it("'Not now' hides it, stores the flag, and it stays hidden after a reload", async () => { + const first = mount(); + fireEvent.click(await screen.findByRole("button", { name: "Not now" })); + expect(notice()).toBeNull(); + expect(localStorage.getItem(OFF_NOTICE_DISMISS_KEY)).toBe("1"); + first.unmount(); + calls = []; + mount(); + await settle(); + expect(notice()).toBeNull(); + }); + + it("stays hidden if checks are turned on and then off again after a dismissal", async () => { + localStorage.setItem(OFF_NOTICE_DISMISS_KEY, "1"); + mount(); + await settle(); + expect(notice()).toBeNull(); + }); + + it("still renders, and hides for the visit, when localStorage throws", async () => { + vi.spyOn(Storage.prototype, "getItem").mockImplementation(function (this: Storage, k: string) { + if (k === OFF_NOTICE_DISMISS_KEY) throw new Error("blocked"); + return null; + }); + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("blocked"); + }); + mount(); + fireEvent.click(await screen.findByRole("button", { name: "Not now" })); + expect(notice()).toBeNull(); + }); + + it("'See what is sent' goes to the System page", async () => { + mount(); + fireEvent.click(await screen.findByRole("link", { name: "See what is sent" })); + expect(await screen.findByText("system page")).toBeTruthy(); + }); + + it("has no control that turns checks on, and sends no write on its own", async () => { + mount(); + const n = await screen.findByRole("region", { name: "Update checks are off" }); + const labels = Array.from(n.querySelectorAll("a, button")).map((e) => e.textContent); + expect(labels).toEqual(["See what is sent", "Not now"]); + fireEvent.click(screen.getByRole("button", { name: "Not now" })); + fireEvent.click(n.querySelector("a")!); + expect(calls.filter((c) => c.startsWith("POST"))).toEqual([]); + }); + + it("does not count toward the three update lines", async () => { + const four = [comp("multiseat", "MultiSeat"), comp("apollovibe", "ApolloVibe"), comp("moonlightvibe", "MoonlightVibe")]; + respond = async () => new Response(JSON.stringify(state({ components: four })), { status: 200 }); + const { container } = mount(); + await screen.findByRole("region", { name: "Update checks are off" }); + expect(container.querySelectorAll('.update-banner[data-component]')).toHaveLength(3); + }); +}); From f77fdeb535c60b1a4c76dd7e34dcc868dc11a997 Mon Sep 17 00:00:00 2001 From: vibesoftwarecoder Date: Fri, 9 Oct 2026 10:43:59 -0500 Subject: [PATCH 3/4] feat(dashboard): land on the Updates card; hold a clear busy state during Check now - /system#updates scrolls the Updates card into view and focuses its heading, on first render and when arriving from "See what is sent". A missing card is ignored. - Check now keeps its own busy state ("Checking...", button disabled, one request) for as long as the service takes (up to about 30 s), re-enables with a plain message on failure, still handles 409 and 429, and does not touch state after unmount. Co-Authored-By: Claude Sonnet 5.5 --- .../src/components/UpdatesCard.tsx | 55 ++++- src/MultiSeat.Dashboard/src/updates.test.tsx | 3 +- .../src/updatesCard.test.tsx | 204 ++++++++++++++++++ 3 files changed, 251 insertions(+), 11 deletions(-) create mode 100644 src/MultiSeat.Dashboard/src/updatesCard.test.tsx diff --git a/src/MultiSeat.Dashboard/src/components/UpdatesCard.tsx b/src/MultiSeat.Dashboard/src/components/UpdatesCard.tsx index 688e297..17e640a 100644 --- a/src/MultiSeat.Dashboard/src/components/UpdatesCard.tsx +++ b/src/MultiSeat.Dashboard/src/components/UpdatesCard.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from "react"; +import { useLocation } from "react-router-dom"; import type { UpdateComponent } from "../api/types"; import { useUpdatesContext } from "../hooks/UpdatesContext"; import { ReleaseLink, UpdateInstructions } from "./UpdateInstructions"; @@ -50,11 +51,33 @@ function UpdateRow({ c }: { c: UpdateComponent }) { export function UpdatesCard() { const updates = useUpdatesContext(); const [busy, setBusy] = useState(false); + const [checking, setChecking] = useState(false); + const checkInFlight = useRef(false); + const mounted = useRef(true); + const location = useLocation(); const [cooling, setCooling] = useState(false); const [message, setMessage] = useState(null); const timer = useRef>(undefined); - useEffect(() => () => clearTimeout(timer.current), []); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + clearTimeout(timer.current); + }; + }, []); + + // Arriving at /system#updates (first render or from the "See what is sent" link) lands on this + // card: scroll it into view and move focus to its heading for keyboard users. The card only + // exists once the data has loaded, hence the dependency on it. A missing element is ignored. + const hasData = updates?.data != null; + useEffect(() => { + if (location.hash !== "#updates" || !hasData) return; + const card = document.getElementById("updates"); + if (!card) return; + card.scrollIntoView?.({ block: "start" }); + card.querySelector("h3")?.focus(); + }, [location.hash, location.key, hasData]); const startCooldown = () => { setCooling(true); @@ -69,7 +92,7 @@ export function UpdatesCard() { return (
-

Updates

+

Updates

{updates.loaded ? "Update information is not available right now." : "Loading..."}
@@ -78,11 +101,17 @@ export function UpdatesCard() { ); } + // The service runs the check inside this request, which can take about 30 seconds. The button + // stays disabled for the whole time and a second click cannot start a second request. const handleCheck = async () => { - setBusy(true); + if (checkInFlight.current) return; + checkInFlight.current = true; + setChecking(true); setMessage(null); const r = await updates.checkNow(); - setBusy(false); + checkInFlight.current = false; + if (!mounted.current) return; + setChecking(false); if (r.ok) { startCooldown(); } else { @@ -95,6 +124,7 @@ export function UpdatesCard() { setBusy(true); setMessage(null); const r = await updates.setEnabled(enabled); + if (!mounted.current) return; setBusy(false); if (!r.ok) setMessage(r.message); }; @@ -104,7 +134,7 @@ export function UpdatesCard() { return (
-

Updates

+

Updates

Update checks are off. MultiSeat has not contacted the internet.

@@ -133,18 +163,23 @@ export function UpdatesCard() { } // ── On ── - const checkDisabled = busy || cooling; + const checkDisabled = busy || checking || cooling; return (
-

Updates

+

Updates

Last checked {formatAge(data.checkedAt)}
- {cooling && !message && ( + {checking && ( +
+ Checking GitHub for new releases. This can take up to 30 seconds. +
+ )} + {cooling && !checking && !message && (
A check just ran. You can check again in a minute.
@@ -160,7 +195,7 @@ export function UpdatesCard() {
Update checks: ON -
diff --git a/src/MultiSeat.Dashboard/src/updates.test.tsx b/src/MultiSeat.Dashboard/src/updates.test.tsx index 3a7f5aa..425a118 100644 --- a/src/MultiSeat.Dashboard/src/updates.test.tsx +++ b/src/MultiSeat.Dashboard/src/updates.test.tsx @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { MemoryRouter } from "react-router-dom"; import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import type { UpdateComponent, UpdatesState } from "./api/types"; import { UpdatesProvider } from "./hooks/UpdatesContext"; @@ -110,7 +111,7 @@ const CHECK = "POST /api/system/updates/check"; const SETTINGS = "POST /api/system/updates/settings"; function mount(ui: React.ReactNode = <>) { - return render({ui}); + return render({ui}); } function banner() { diff --git a/src/MultiSeat.Dashboard/src/updatesCard.test.tsx b/src/MultiSeat.Dashboard/src/updatesCard.test.tsx new file mode 100644 index 0000000..bc12ebc --- /dev/null +++ b/src/MultiSeat.Dashboard/src/updatesCard.test.tsx @@ -0,0 +1,204 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, render, screen } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import type { UpdatesState } from "./api/types"; +import { UpdatesProvider } from "./hooks/UpdatesContext"; +import { UpdatesCard } from "./components/UpdatesCard"; +import { UpdateOffNotice } from "./components/UpdateOffNotice"; + +function state(over: Partial = {}): UpdatesState { + return { + enabled: true, + intervalHours: 12, + checkedAt: null, + nextCheckAt: null, + error: null, + components: [], + ...over, + }; +} + +let calls: string[]; +let checkReply: (r: Response | Error) => void; +let serverEnabled = true; +let scrollSpy: ReturnType; + +beforeEach(() => { + vi.useFakeTimers(); + calls = []; + serverEnabled = true; + scrollSpy = vi.fn(); + Element.prototype.scrollIntoView = scrollSpy as unknown as typeof Element.prototype.scrollIntoView; + vi.stubGlobal( + "fetch", + vi.fn((url: string, init?: RequestInit) => { + const key = `${init?.method ?? "GET"} ${url}`; + calls.push(key); + if (key === "POST /api/system/updates/check") { + // The service runs the check inside this request; the test decides when it answers. + return new Promise((resolve, reject) => { + checkReply = (r) => (r instanceof Error ? reject(r) : resolve(r)); + }); + } + return Promise.resolve(new Response(JSON.stringify(state({ enabled: serverEnabled })), { status: 200 })); + }) + ); +}); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +const flush = () => act(async () => { await vi.advanceTimersByTimeAsync(0); }); +const advance = (ms: number) => act(async () => { await vi.advanceTimersByTimeAsync(ms); }); +const posts = () => calls.filter((c) => c === "POST /api/system/updates/check").length; +const checkBtn = () => screen.getByRole("button", { name: /Check now|Checking/ }) as HTMLButtonElement; + +function mountCard(entry = "/") { + return render( + + + + + + ); +} + +async function startCheck() { + mountCard(); + await flush(); + await act(async () => { checkBtn().click(); }); +} + +describe("Check now while the service works", () => { + it("shows Checking... and a disabled button for the whole 30 seconds, with one request", async () => { + await startCheck(); + expect(checkBtn().textContent).toBe("Checking…"); + expect(checkBtn().disabled).toBe(true); + await advance(30_000); + expect(checkBtn().textContent).toBe("Checking…"); + expect(checkBtn().disabled).toBe(true); + expect(posts()).toBe(1); + expect(screen.getByText(/This can take up to 30 seconds/)).toBeTruthy(); + }); + + it("a double click before React re-renders still sends one request", async () => { + mountCard(); + await flush(); + const btn = checkBtn(); + await act(async () => { + btn.click(); + btn.click(); + }); + expect(posts()).toBe(1); + }); + + it("returns to Check now after a slow success", async () => { + await startCheck(); + await advance(30_000); + await act(async () => { checkReply(new Response(JSON.stringify(state()), { status: 202 })); }); + await flush(); + await advance(60_000); // the one-minute cooldown after a check + expect(checkBtn().textContent).toBe("Check now"); + expect(checkBtn().disabled).toBe(false); + }); + + it("re-enables the button with a plain message when the service fails", async () => { + await startCheck(); + await advance(30_000); + await act(async () => { checkReply(new Response(JSON.stringify({ error: "GitHub is unreachable" }), { status: 500 })); }); + await flush(); + expect(screen.getByText("GitHub is unreachable")).toBeTruthy(); + expect(checkBtn().textContent).toBe("Check now"); + expect(checkBtn().disabled).toBe(false); + }); + + it("re-enables the button when the request itself fails", async () => { + await startCheck(); + await act(async () => { checkReply(new TypeError("Failed to fetch")); }); + await flush(); + expect(screen.getByText("Could not reach the service.")).toBeTruthy(); + expect(checkBtn().disabled).toBe(false); + }); + + it("still handles 409 and 429 after the wait", async () => { + await startCheck(); + await act(async () => { checkReply(new Response(JSON.stringify({ error: "off" }), { status: 409 })); }); + await flush(); + expect(screen.getByText("Update checks are off. Turn them on first.")).toBeTruthy(); + expect(checkBtn().disabled).toBe(false); + await act(async () => { checkBtn().click(); }); + await act(async () => { checkReply(new Response(JSON.stringify({ error: "slow down" }), { status: 429 })); }); + await flush(); + expect(screen.getByText("A check ran a moment ago. Try again in a minute.")).toBeTruthy(); + expect(checkBtn().disabled).toBe(true); + }); + + it("does not touch state or log errors when unmounted mid-request", async () => { + const errors = vi.spyOn(console, "error").mockImplementation(() => {}); + const { unmount } = mountCard(); + await flush(); + await act(async () => { checkBtn().click(); }); + unmount(); + await act(async () => { checkReply(new Response(JSON.stringify(state()), { status: 202 })); }); + await advance(120_000); + expect(errors).not.toHaveBeenCalled(); + }); +}); + +describe("landing on the Updates card", () => { + function app(entry: string) { + return render( + + + + + home
} /> + } /> + + + + ); + } + + it("scrolls to the card and focuses its heading when the URL hash is #updates", async () => { + app("/system#updates"); + await flush(); + const card = document.getElementById("updates")!; + expect(card).toBeTruthy(); + expect(scrollSpy.mock.contexts).toContain(card); + expect(document.activeElement).toBe(screen.getByRole("heading", { name: "Updates" })); + }); + + it("does the same when 'See what is sent' navigates there", async () => { + serverEnabled = false; // the notice only shows while checks are off + app("/"); + await flush(); + expect(scrollSpy).not.toHaveBeenCalled(); + await act(async () => { screen.getByRole("link", { name: "See what is sent" }).click(); }); + await flush(); + expect(screen.queryByText("home")).toBeNull(); + expect(scrollSpy.mock.contexts).toContain(document.getElementById("updates")); + expect(document.activeElement).toBe(screen.getByRole("heading", { name: "Updates" })); + }); + + it("does nothing without the hash", async () => { + app("/system"); + await flush(); + expect(document.getElementById("updates")).toBeTruthy(); + expect(scrollSpy).not.toHaveBeenCalled(); + }); + + it("copes when there is no card to land on", async () => { + render( + + + + ); + await flush(); + expect(scrollSpy).not.toHaveBeenCalled(); + }); +}); From b06cb6e4aed166219ad8bc17a9d219ef79a94c06 Mon Sep 17 00:00:00 2001 From: vibesoftwarecoder Date: Fri, 9 Oct 2026 10:47:23 -0500 Subject: [PATCH 4/4] feat(dashboard): give Check now a 45 s client-side timeout request() takes an optional timeout and aborts through an AbortController; only the manual check passes one. A timeout surfaces as "The check took too long. Try again in a minute.", clears the busy state, re-enables the button and starts no cooldown. Co-Authored-By: Claude Sonnet 5.5 --- src/MultiSeat.Dashboard/src/api/client.ts | 49 +++++++++++++----- .../src/hooks/useUpdates.ts | 11 +++- .../src/updatesCard.test.tsx | 50 +++++++++++++++++++ 3 files changed, 95 insertions(+), 15 deletions(-) diff --git a/src/MultiSeat.Dashboard/src/api/client.ts b/src/MultiSeat.Dashboard/src/api/client.ts index c1903b1..729f833 100644 --- a/src/MultiSeat.Dashboard/src/api/client.ts +++ b/src/MultiSeat.Dashboard/src/api/client.ts @@ -22,9 +22,19 @@ import type { const BASE = "/api"; +/** Status carried by the ApiError thrown when a request with a timeout ran out of time. */ +export const REQUEST_TIMED_OUT = 0; + +/** + * The service runs a manual update check inside its request (about 30 s at most), so that one + * call gets a client-side limit. Other calls pass no timeout and behave exactly as before. + */ +export const CHECK_TIMEOUT_MS = 45_000; + async function request( path: string, - init?: RequestInit + init?: RequestInit, + timeoutMs?: number ): Promise { const headers: Record = { "Content-Type": "application/json", @@ -36,18 +46,31 @@ async function request( headers["X-MultiSeat-Key"] = apiKey; } - const res = await fetch(`${BASE}${path}`, { - ...init, - headers: { ...headers, ...init?.headers }, - }); - - if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new ApiError(res.status, body?.error ?? res.statusText); + const controller = timeoutMs ? new AbortController() : undefined; + const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : undefined; + + try { + const res = await fetch(`${BASE}${path}`, { + ...init, + headers: { ...headers, ...init?.headers }, + ...(controller ? { signal: controller.signal } : {}), + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new ApiError(res.status, body?.error ?? res.statusText); + } + + if (res.status === 204) return undefined as T; + return await res.json(); + } catch (e) { + if (controller?.signal.aborted && !(e instanceof ApiError)) { + throw new ApiError(REQUEST_TIMED_OUT, "The request timed out"); + } + throw e; + } finally { + clearTimeout(timer); } - - if (res.status === 204) return undefined as T; - return res.json(); } export class ApiError extends Error { @@ -188,7 +211,7 @@ export const system = { getUpdates: () => request("/system/updates"), // 202 with the new state; 409 when update checks are off; 429 inside the 60 s cooldown. checkUpdates: () => - request("/system/updates/check", { method: "POST" }), + request("/system/updates/check", { method: "POST" }, CHECK_TIMEOUT_MS), setUpdatesEnabled: (enabled: boolean) => request("/system/updates/settings", { method: "POST", diff --git a/src/MultiSeat.Dashboard/src/hooks/useUpdates.ts b/src/MultiSeat.Dashboard/src/hooks/useUpdates.ts index a6fc804..98549a2 100644 --- a/src/MultiSeat.Dashboard/src/hooks/useUpdates.ts +++ b/src/MultiSeat.Dashboard/src/hooks/useUpdates.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { ApiError, system } from "../api/client"; +import { ApiError, REQUEST_TIMED_OUT, system } from "../api/client"; import type { UpdatesState } from "../api/types"; import { loadDismissed, loadOffNoticeDismissed, saveDismissed, saveOffNoticeDismissed } from "../components/updateUtils"; @@ -10,7 +10,7 @@ const FOCUS_MIN_GAP_MS = 10_000; export type ActionResult = | { ok: true } - | { ok: false; kind: "off" | "cooldown" | "error"; message: string }; + | { ok: false; kind: "off" | "cooldown" | "timeout" | "error"; message: string }; function looksLikeState(value: unknown): value is UpdatesState { return ( @@ -25,6 +25,13 @@ function toResult(e: unknown): ActionResult { if (e.status === 409) { return { ok: false, kind: "off", message: "Update checks are off. Turn them on first." }; } + if (e.status === REQUEST_TIMED_OUT) { + return { + ok: false, + kind: "timeout", + message: "The check took too long. Try again in a minute.", + }; + } if (e.status === 429) { return { ok: false, diff --git a/src/MultiSeat.Dashboard/src/updatesCard.test.tsx b/src/MultiSeat.Dashboard/src/updatesCard.test.tsx index bc12ebc..9081444 100644 --- a/src/MultiSeat.Dashboard/src/updatesCard.test.tsx +++ b/src/MultiSeat.Dashboard/src/updatesCard.test.tsx @@ -21,11 +21,14 @@ function state(over: Partial = {}): UpdatesState { let calls: string[]; let checkReply: (r: Response | Error) => void; let serverEnabled = true; +let inits: Record; let scrollSpy: ReturnType; beforeEach(() => { vi.useFakeTimers(); calls = []; + inits = {}; + localStorage.clear(); serverEnabled = true; scrollSpy = vi.fn(); Element.prototype.scrollIntoView = scrollSpy as unknown as typeof Element.prototype.scrollIntoView; @@ -34,9 +37,11 @@ beforeEach(() => { vi.fn((url: string, init?: RequestInit) => { const key = `${init?.method ?? "GET"} ${url}`; calls.push(key); + inits[key] = init; if (key === "POST /api/system/updates/check") { // The service runs the check inside this request; the test decides when it answers. return new Promise((resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError"))); checkReply = (r) => (r instanceof Error ? reject(r) : resolve(r)); }); } @@ -149,6 +154,51 @@ describe("Check now while the service works", () => { }); }); +describe("Check now gives up after 45 seconds", () => { + const CHECK = "POST /api/system/updates/check"; + + it("does not abort before 45 s, and aborts at 45 s", async () => { + await startCheck(); + await advance(44_999); + expect(inits[CHECK]!.signal!.aborted).toBe(false); + expect(checkBtn().textContent).toBe("Checking…"); + await advance(1); + expect(inits[CHECK]!.signal!.aborted).toBe(true); + }); + + it("shows a plain message, re-enables the button and starts no cooldown", async () => { + await startCheck(); + await advance(45_000); + expect(screen.getByText("The check took too long. Try again in a minute.")).toBeTruthy(); + expect(checkBtn().textContent).toBe("Check now"); + expect(checkBtn().disabled).toBe(false); + expect(screen.queryByText(/You can check again in a minute/)).toBeNull(); + await act(async () => { checkBtn().click(); }); + expect(posts()).toBe(2); + }); + + it("an abort after unmount is silent", async () => { + const errors = vi.spyOn(console, "error").mockImplementation(() => {}); + const { unmount } = mountCard(); + await flush(); + await act(async () => { checkBtn().click(); }); + unmount(); + await advance(60_000); + expect(errors).not.toHaveBeenCalled(); + }); + + it("still sends the API key header", async () => { + localStorage.setItem("multiseat-api-key", "k123"); + await startCheck(); + expect((inits[CHECK]!.headers as Record)["X-MultiSeat-Key"]).toBe("k123"); + }); + + it("puts no timeout on other calls", async () => { + await startCheck(); + expect(inits["GET /api/system/updates"]!.signal).toBeUndefined(); + }); +}); + describe("landing on the Updates card", () => { function app(entry: string) { return render(