* feat: add Decky Sigil controller foundation Add a controller-first local appliance panel backed by bounded Sigil and systemd adapters. Validate and apply managed configuration transactionally, restore known-good state on failure, and keep enrollment reset separate from unavailable identity reset and diagnostics contracts. * test: harden Decky appliance lifecycle Require fresh daemon instances after every service start, separate post-commit recovery from rollback, reap cancelled subprocesses, and keep uncertain candidates stopped. Add controller confirmations, deterministic provenance-bound packaging, and a dedicated CI gate. * fix: bind Decky release and service recovery Recover lost commit responses without unsafe rollback, support first-run restarts, always confirm configuration restarts, and publish required store artwork. Build packages from a clean pinned toolchain and bind every archived file to provenance hashes. * fix: make Decky release claims precise
- commit
- be30ff3ea199e808e72ab966446b7ebf35e56a98
- parent
- b44b0125e47c28e83261b3ddbf463e036663bf17
- author
- FelineStateMachine <dami.etoile@gmail.com>
| M | .github/ | 37 | + |
| A | decky/ | 5 | + |
| A | decky/ | 21 | + |
| A | decky/ | 39 | + |
| A | decky/ | 10 | + |
| A | decky/ | 50 | + |
| A | decky/ | 40 | + |
| A | decky/ | 11 | + |
| A | decky/ | 1235 | ++++++++++++++++++++++++++++++ |
| A | decky/ | 6 | + |
| A | decky/ | 530 | +++++++++++++ |
| A | decky/ | 9 | + |
| A | decky/ | 194 | +++++ |
| A | decky/ | 3 | + |
| A | decky/ | 13 | + |
| A | decky/ | 401 | ++++++++++ |
| A | decky/ | 59 | + |
| A | decky/ | 178 | ++++ |
| A | decky/ | 377 | +++++++++ |
| A | decky/ | 121 | +++ |
| A | decky/ | 21 | + |
| A | scripts/ | 185 | ++++ |
| A | scripts/ | 67 | ++ |
| A | website/ | bin |
@@ -13,6 +13,43 @@ concurrency:
1313 cancel-in-progress: true1414 1515 jobs:16+ decky-gate:17+ name: Decky plugin gate18+ runs-on: ubuntu-24.0419+ timeout-minutes: 1020+ steps:21+ - name: Check out repository22+ uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.123+24+ - name: Install pinned pnpm25+ uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v426+ with:27+ version: 9.4.028+ run_install: false29+30+ - name: Set up Node.js31+ uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.032+ with:33+ node-version: "22"34+ cache: pnpm35+ cache-dependency-path: decky/pnpm-lock.yaml36+37+ - name: Install Decky dependencies38+ working-directory: decky39+ run: pnpm install --frozen-lockfile40+41+ - name: Test and build Decky plugin42+ working-directory: decky43+ run: |44+ pnpm test45+ pnpm typecheck46+ pnpm build47+48+ - name: Verify deterministic Decky package49+ run: |50+ python3 scripts/package-decky-plugin.py "$RUNNER_TEMP/decky-artifacts"51+ python3 -m unittest scripts.tests.test_decky_package -v52+1653 demo-gate:1754 name: Complete demo gate1855 runs-on: ubuntu-24.04@@ -0,0 +1,21 @@
1+MIT License2+3+Copyright (c) 2026 Dami4+5+Permission is hereby granted, free of charge, to any person obtaining a copy6+of this software and associated documentation files (the "Software"), to deal7+in the Software without restriction, including without limitation the rights8+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell9+copies of the Software, and to permit persons to whom the Software is10+furnished to do so, subject to the following conditions:11+12+The above copyright notice and this permission notice shall be included in all13+copies or substantial portions of the Software.14+15+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR16+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,17+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE18+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER19+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,20+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE21+SOFTWARE.@@ -0,0 +1,39 @@
1+# Goq Sigil for Decky2+3+Goq Sigil is a controller-first, local management surface for the Sigil host4+daemon. It supervises the existing `sigil-host.service`; Decky never owns the5+streaming process and reloading or uninstalling the plugin does not stop Sigil.6+7+The initial compatibility target is Bazzite with a separately installed,8+compatible Sigil build. Until the signed Sigil channel publishes immutable9+release tags, compatibility is proven from the required appliance schemas,10+not the non-unique development version string. SteamOS remains unproven until11+its hardware acceptance milestone is complete.12+13+## Supported management14+15+- View bounded appliance, daemon, enrollment, and session status.16+- View the single paired Portal and its grants.17+- Restart the fixed per-user Sigil service.18+- Validate and transactionally apply the allowlisted stream configuration.19+- Roll back a pending configuration transaction.20+- Revoke Portal enrollment without rotating the Sigil identity.21+22+Live stream diagnostics and identity factory reset remain visibly unavailable23+until Sigil exports dedicated bounded contracts. The plugin does not read24+private identity, authorization, invitation, or raw TOML bytes.25+26+## Development27+28+The toolchain follows the official Decky plugin template and pins pnpm 9.29+30+```sh31+pnpm install --frozen-lockfile32+pnpm test33+pnpm typecheck34+pnpm build35+```36+37+Backend tests use only the Python standard library and mocked Sigil/systemd38+adapters. Hardware acceptance is still required for Steam focus navigation,39+Decky reload, cold boot, daemon recovery, rollback, reset, and uninstall.@@ -0,0 +1,50 @@
1+"""Decky RPC facade for the local Sigil appliance controller."""2+3+import decky4+5+from goq_sigil import Controller, ControllerError6+from goq_sigil.runner import Runner7+8+9+class Plugin:10+ async def _main(self):11+ self.controller = Controller(Runner(decky.DECKY_USER_HOME))12+ decky.logger.info("Goq Sigil controller loaded")13+14+ async def _unload(self):15+ # systemd owns Sigil. A Decky reload must never stop the daemon.16+ decky.logger.info("Goq Sigil controller unloaded")17+18+ async def _call(self, operation, *arguments):19+ try:20+ value = await operation(*arguments)21+ return {"ok": True, "value": value}22+ except ControllerError as error:23+ decky.logger.warning("Sigil controller operation failed: %s", error.code)24+ return {"ok": False, "error": error.code}25+ except Exception:26+ decky.logger.exception("Unexpected Sigil controller failure")27+ return {"ok": False, "error": "internal_error"}28+29+ async def get_snapshot(self):30+ return await self._call(self.controller.get_snapshot)31+32+ async def get_config(self):33+ return await self._call(self.controller.get_config)34+35+ async def validate_config(self, request):36+ return await self._call(self.controller.validate_config, request)37+38+ async def apply_config(self, request):39+ return await self._call(self.controller.apply_config, request)40+41+ async def restart_service(self):42+ return await self._call(self.controller.restart_service)43+44+ async def rollback_pending(self, transaction):45+ return await self._call(self.controller.rollback_pending, transaction)46+47+ async def reset_enrollment(self, expected_host_fingerprint):48+ return await self._call(49+ self.controller.reset_enrollment, expected_host_fingerprint50+ )@@ -0,0 +1,40 @@
1+{2+ "name": "goq-sigil",3+ "version": "0.1.0",4+ "description": "Controller-first Decky management for a Goq Sigil appliance",5+ "type": "module",6+ "packageManager": "pnpm@9.4.0",7+ "scripts": {8+ "build": "rollup -c",9+ "typecheck": "tsc --noEmit",10+ "test:frontend": "node --test tests/model.test.mjs",11+ "test:backend": "PYTHONPATH=py_modules python3 -m unittest discover -s tests/backend -v",12+ "test": "pnpm run test:frontend && pnpm run test:backend"13+ },14+ "repository": {15+ "type": "git",16+ "url": "https://github.com/FelineStateMachine/goq.git",17+ "directory": "decky"18+ },19+ "license": "MIT",20+ "devDependencies": {21+ "@decky/rollup": "1.0.2",22+ "@decky/ui": "4.11.0",23+ "@rollup/rollup-linux-x64-musl": "4.53.3",24+ "@types/react": "19.1.1",25+ "@types/react-dom": "19.1.1",26+ "@types/react-router": "5.1.20",27+ "rollup": "4.53.3",28+ "typescript": "5.6.2"29+ },30+ "dependencies": {31+ "@decky/api": "1.1.3",32+ "react-icons": "5.3.0",33+ "tslib": "2.7.0"34+ },35+ "pnpm": {36+ "peerDependencyRules": {37+ "ignoreMissing": ["react", "react-dom"]38+ }39+ }40+}@@ -0,0 +1,11 @@
1+{2+ "name": "Goq Sigil",3+ "author": "Feline State Machine",4+ "flags": [],5+ "api_version": 1,6+ "publish": {7+ "tags": ["utility", "streaming", "gaming"],8+ "description": "Controller-first local management for a Goq Sigil host.",9+ "image": "https://goq.sh/assets/decky-sigil.webp"10+ }11+}@@ -0,0 +1,1235 @@
1+lockfileVersion: '9.0'2+3+settings:4+ autoInstallPeers: true5+ excludeLinksFromLockfile: false6+7+importers:8+9+ .:10+ dependencies:11+ '@decky/api':12+ specifier: 1.1.313+ version: 1.1.314+ react-icons:15+ specifier: 5.3.016+ version: 5.3.0(react@19.2.8)17+ tslib:18+ specifier: 2.7.019+ version: 2.7.020+ devDependencies:21+ '@decky/rollup':22+ specifier: 1.0.223+ version: 1.0.224+ '@decky/ui':25+ specifier: 4.11.026+ version: 4.11.027+ '@rollup/rollup-linux-x64-musl':28+ specifier: 4.53.329+ version: 4.53.330+ '@types/react':31+ specifier: 19.1.132+ version: 19.1.133+ '@types/react-dom':34+ specifier: 19.1.135+ version: 19.1.1(@types/react@19.1.1)36+ '@types/react-router':37+ specifier: 5.1.2038+ version: 5.1.2039+ rollup:40+ specifier: 4.53.341+ version: 4.53.342+ typescript:43+ specifier: 5.6.244+ version: 5.6.245+46+packages:47+48+ '@decky/api@1.1.3':49+ resolution: {integrity: sha512-XsPCZxfxk5I1UtylIUN3qaWQI31siQbKfbLIskkI5innEatY1m4NQqBv/6hwPaO9mKMbdqYpnh5PSJDeMEOOBA==}50+51+ '@decky/rollup@1.0.2':52+ resolution: {integrity: sha512-ixuHH3msw156ACbgWZi4hgccdzDBIuqYGOVja8sLX4lHFgaWUKji1vomDi7Dk1CamgjmKt+Dqf75Pezv2FB6Bw==}53+54+ '@decky/ui@4.11.0':55+ resolution: {integrity: sha512-l9PstFC+S8FE8M2kIM78L8cYW4vzJ/ZD30II0huarHLcCsKM4Q+rbmEnbWjlJ1/KLmGXVRXBdAbyD4X/FzfxnQ==}56+57+ '@isaacs/cliui@8.0.2':58+ resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}59+ engines: {node: '>=12'}60+61+ '@jridgewell/sourcemap-codec@1.5.5':62+ resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}63+64+ '@nodelib/fs.scandir@2.1.5':65+ resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}66+ engines: {node: '>= 8'}67+68+ '@nodelib/fs.stat@2.0.5':69+ resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==}70+ engines: {node: '>= 8'}71+72+ '@nodelib/fs.walk@1.2.8':73+ resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}74+ engines: {node: '>= 8'}75+76+ '@pkgjs/parseargs@0.11.0':77+ resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}78+ engines: {node: '>=14'}79+80+ '@rollup/plugin-commonjs@26.0.3':81+ resolution: {integrity: sha512-2BJcolt43MY+y5Tz47djHkodCC3c1VKVrBDKpVqHKpQ9z9S158kCCqB8NF6/gzxLdNlYW9abB3Ibh+kOWLp8KQ==}82+ engines: {node: '>=16.0.0 || 14 >= 14.17'}83+ peerDependencies:84+ rollup: ^2.68.0||^3.0.0||^4.0.085+ peerDependenciesMeta:86+ rollup:87+ optional: true88+89+ '@rollup/plugin-json@6.1.0':90+ resolution: {integrity: sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==}91+ engines: {node: '>=14.0.0'}92+ peerDependencies:93+ rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.094+ peerDependenciesMeta:95+ rollup:96+ optional: true97+98+ '@rollup/plugin-node-resolve@15.3.1':99+ resolution: {integrity: sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==}100+ engines: {node: '>=14.0.0'}101+ peerDependencies:102+ rollup: ^2.78.0||^3.0.0||^4.0.0103+ peerDependenciesMeta:104+ rollup:105+ optional: true106+107+ '@rollup/plugin-replace@5.0.7':108+ resolution: {integrity: sha512-PqxSfuorkHz/SPpyngLyg5GCEkOcee9M1bkxiVDr41Pd61mqP1PLOoDPbpl44SB2mQGKwV/In74gqQmGITOhEQ==}109+ engines: {node: '>=14.0.0'}110+ peerDependencies:111+ rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0112+ peerDependenciesMeta:113+ rollup:114+ optional: true115+116+ '@rollup/plugin-typescript@11.1.6':117+ resolution: {integrity: sha512-R92yOmIACgYdJ7dJ97p4K69I8gg6IEHt8M7dUBxN3W6nrO8uUxX5ixl0yU/N3aZTi8WhPuICvOHXQvF6FaykAA==}118+ engines: {node: '>=14.0.0'}119+ peerDependencies:120+ rollup: ^2.14.0||^3.0.0||^4.0.0121+ tslib: '*'122+ typescript: '>=3.7.0'123+ peerDependenciesMeta:124+ rollup:125+ optional: true126+ tslib:127+ optional: true128+129+ '@rollup/pluginutils@5.4.0':130+ resolution: {integrity: sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==}131+ engines: {node: '>=14.0.0'}132+ peerDependencies:133+ rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0134+ peerDependenciesMeta:135+ rollup:136+ optional: true137+138+ '@rollup/rollup-android-arm-eabi@4.53.3':139+ resolution: {integrity: sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==}140+ cpu: [arm]141+ os: [android]142+143+ '@rollup/rollup-android-arm64@4.53.3':144+ resolution: {integrity: sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==}145+ cpu: [arm64]146+ os: [android]147+148+ '@rollup/rollup-darwin-arm64@4.53.3':149+ resolution: {integrity: sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==}150+ cpu: [arm64]151+ os: [darwin]152+153+ '@rollup/rollup-darwin-x64@4.53.3':154+ resolution: {integrity: sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==}155+ cpu: [x64]156+ os: [darwin]157+158+ '@rollup/rollup-freebsd-arm64@4.53.3':159+ resolution: {integrity: sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==}160+ cpu: [arm64]161+ os: [freebsd]162+163+ '@rollup/rollup-freebsd-x64@4.53.3':164+ resolution: {integrity: sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==}165+ cpu: [x64]166+ os: [freebsd]167+168+ '@rollup/rollup-linux-arm-gnueabihf@4.53.3':169+ resolution: {integrity: sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==}170+ cpu: [arm]171+ os: [linux]172+173+ '@rollup/rollup-linux-arm-musleabihf@4.53.3':174+ resolution: {integrity: sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==}175+ cpu: [arm]176+ os: [linux]177+178+ '@rollup/rollup-linux-arm64-gnu@4.53.3':179+ resolution: {integrity: sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==}180+ cpu: [arm64]181+ os: [linux]182+183+ '@rollup/rollup-linux-arm64-musl@4.53.3':184+ resolution: {integrity: sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==}185+ cpu: [arm64]186+ os: [linux]187+188+ '@rollup/rollup-linux-loong64-gnu@4.53.3':189+ resolution: {integrity: sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==}190+ cpu: [loong64]191+ os: [linux]192+193+ '@rollup/rollup-linux-ppc64-gnu@4.53.3':194+ resolution: {integrity: sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==}195+ cpu: [ppc64]196+ os: [linux]197+198+ '@rollup/rollup-linux-riscv64-gnu@4.53.3':199+ resolution: {integrity: sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==}200+ cpu: [riscv64]201+ os: [linux]202+203+ '@rollup/rollup-linux-riscv64-musl@4.53.3':204+ resolution: {integrity: sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==}205+ cpu: [riscv64]206+ os: [linux]207+208+ '@rollup/rollup-linux-s390x-gnu@4.53.3':209+ resolution: {integrity: sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==}210+ cpu: [s390x]211+ os: [linux]212+213+ '@rollup/rollup-linux-x64-gnu@4.53.3':214+ resolution: {integrity: sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==}215+ cpu: [x64]216+ os: [linux]217+218+ '@rollup/rollup-linux-x64-musl@4.53.3':219+ resolution: {integrity: sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==}220+ cpu: [x64]221+ os: [linux]222+223+ '@rollup/rollup-openharmony-arm64@4.53.3':224+ resolution: {integrity: sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==}225+ cpu: [arm64]226+ os: [openharmony]227+228+ '@rollup/rollup-win32-arm64-msvc@4.53.3':229+ resolution: {integrity: sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==}230+ cpu: [arm64]231+ os: [win32]232+233+ '@rollup/rollup-win32-ia32-msvc@4.53.3':234+ resolution: {integrity: sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==}235+ cpu: [ia32]236+ os: [win32]237+238+ '@rollup/rollup-win32-x64-gnu@4.53.3':239+ resolution: {integrity: sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==}240+ cpu: [x64]241+ os: [win32]242+243+ '@rollup/rollup-win32-x64-msvc@4.53.3':244+ resolution: {integrity: sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==}245+ cpu: [x64]246+ os: [win32]247+248+ '@types/estree@1.0.8':249+ resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}250+251+ '@types/estree@1.0.9':252+ resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}253+254+ '@types/history@4.7.11':255+ resolution: {integrity: sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==}256+257+ '@types/react-dom@19.1.1':258+ resolution: {integrity: sha512-jFf/woGTVTjUJsl2O7hcopJ1r0upqoq/vIOoCj0yLh3RIXxWcljlpuZ+vEBRXsymD1jhfeJrlyTy/S1UW+4y1w==}259+ peerDependencies:260+ '@types/react': ^19.0.0261+262+ '@types/react-router@5.1.20':263+ resolution: {integrity: sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==}264+265+ '@types/react@19.1.1':266+ resolution: {integrity: sha512-ePapxDL7qrgqSF67s0h9m412d9DbXyC1n59O2st+9rjuuamWsZuD2w55rqY12CbzsZ7uVXb5Nw0gEp9Z8MMutQ==}267+268+ '@types/resolve@1.20.2':269+ resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}270+271+ aggregate-error@3.1.0:272+ resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==}273+ engines: {node: '>=8'}274+275+ ansi-regex@5.0.1:276+ resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}277+ engines: {node: '>=8'}278+279+ ansi-regex@6.2.2:280+ resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}281+ engines: {node: '>=12'}282+283+ ansi-styles@4.3.0:284+ resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}285+ engines: {node: '>=8'}286+287+ ansi-styles@6.2.3:288+ resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}289+ engines: {node: '>=12'}290+291+ array-union@2.1.0:292+ resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==}293+ engines: {node: '>=8'}294+295+ balanced-match@1.0.2:296+ resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}297+298+ brace-expansion@1.1.16:299+ resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==}300+301+ brace-expansion@2.1.2:302+ resolution: {integrity: sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==}303+304+ braces@3.0.3:305+ resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}306+ engines: {node: '>=8'}307+308+ clean-stack@2.2.0:309+ resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==}310+ engines: {node: '>=6'}311+312+ color-convert@2.0.1:313+ resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}314+ engines: {node: '>=7.0.0'}315+316+ color-name@1.1.4:317+ resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}318+319+ commondir@1.0.1:320+ resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==}321+322+ concat-map@0.0.1:323+ resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}324+325+ cross-spawn@7.0.6:326+ resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}327+ engines: {node: '>= 8'}328+329+ csstype@3.2.3:330+ resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}331+332+ deepmerge@4.3.1:333+ resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}334+ engines: {node: '>=0.10.0'}335+336+ del@6.1.1:337+ resolution: {integrity: sha512-ua8BhapfP0JUJKC/zV9yHHDW/rDoDxP4Zhn3AkA6/xT6gY7jYXJiaeyBZznYVujhZZET+UgcbZiQ7sN3WqcImg==}338+ engines: {node: '>=10'}339+340+ dir-glob@3.0.1:341+ resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}342+ engines: {node: '>=8'}343+344+ eastasianwidth@0.2.0:345+ resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}346+347+ emoji-regex@8.0.0:348+ resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}349+350+ emoji-regex@9.2.2:351+ resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}352+353+ es-errors@1.3.0:354+ resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}355+ engines: {node: '>= 0.4'}356+357+ estree-walker@0.6.1:358+ resolution: {integrity: sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==}359+360+ estree-walker@2.0.2:361+ resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}362+363+ estree-walker@3.0.3:364+ resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}365+366+ fast-glob@3.3.3:367+ resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}368+ engines: {node: '>=8.6.0'}369+370+ fastq@1.20.1:371+ resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}372+373+ fill-range@7.1.1:374+ resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}375+ engines: {node: '>=8'}376+377+ foreground-child@3.3.1:378+ resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}379+ engines: {node: '>=14'}380+381+ fs.realpath@1.0.0:382+ resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}383+384+ fsevents@2.3.3:385+ resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}386+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}387+ os: [darwin]388+389+ function-bind@1.1.2:390+ resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}391+392+ glob-parent@5.1.2:393+ resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}394+ engines: {node: '>= 6'}395+396+ glob@10.5.0:397+ resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==}398+ deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me399+ hasBin: true400+401+ glob@7.2.3:402+ resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}403+ deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me404+405+ globby@11.1.0:406+ resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==}407+ engines: {node: '>=10'}408+409+ graceful-fs@4.2.11:410+ resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}411+412+ hasown@2.0.4:413+ resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}414+ engines: {node: '>= 0.4'}415+416+ ignore@5.3.2:417+ resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}418+ engines: {node: '>= 4'}419+420+ indent-string@4.0.0:421+ resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}422+ engines: {node: '>=8'}423+424+ inflight@1.0.6:425+ resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}426+ deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.427+428+ inherits@2.0.4:429+ resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}430+431+ is-core-module@2.16.2:432+ resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==}433+ engines: {node: '>= 0.4'}434+435+ is-extglob@2.1.1:436+ resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}437+ engines: {node: '>=0.10.0'}438+439+ is-fullwidth-code-point@3.0.0:440+ resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}441+ engines: {node: '>=8'}442+443+ is-glob@4.0.3:444+ resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}445+ engines: {node: '>=0.10.0'}446+447+ is-module@1.0.0:448+ resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==}449+450+ is-number@7.0.0:451+ resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}452+ engines: {node: '>=0.12.0'}453+454+ is-path-cwd@2.2.0:455+ resolution: {integrity: sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ==}456+ engines: {node: '>=6'}457+458+ is-path-inside@3.0.3:459+ resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==}460+ engines: {node: '>=8'}461+462+ is-reference@1.2.1:463+ resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==}464+465+ is-reference@3.0.3:466+ resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==}467+468+ is-what@5.5.0:469+ resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==}470+ engines: {node: '>=18'}471+472+ isexe@2.0.0:473+ resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}474+475+ jackspeak@3.4.3:476+ resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}477+478+ lru-cache@10.4.3:479+ resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}480+481+ magic-string@0.30.21:482+ resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}483+484+ merge-anything@6.0.6:485+ resolution: {integrity: sha512-F3K1W45PvTjRZzbcYIhXntNr8cux00gUxR8IzNPPG+80gNlAHZGVBwFyN4x5yjw/7QkLPKDbRQBK4KrJKo69mw==}486+ engines: {node: '>=18'}487+488+ merge2@1.4.1:489+ resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}490+ engines: {node: '>= 8'}491+492+ micromatch@4.0.8:493+ resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}494+ engines: {node: '>=8.6'}495+496+ minimatch@3.1.5:497+ resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}498+499+ minimatch@9.0.9:500+ resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}501+ engines: {node: '>=16 || 14 >=14.17'}502+503+ minipass@7.1.3:504+ resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}505+ engines: {node: '>=16 || 14 >=14.17'}506+507+ once@1.4.0:508+ resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}509+510+ p-map@4.0.0:511+ resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==}512+ engines: {node: '>=10'}513+514+ package-json-from-dist@1.0.1:515+ resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}516+517+ path-is-absolute@1.0.1:518+ resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==}519+ engines: {node: '>=0.10.0'}520+521+ path-key@3.1.1:522+ resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}523+ engines: {node: '>=8'}524+525+ path-parse@1.0.7:526+ resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}527+528+ path-scurry@1.11.1:529+ resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}530+ engines: {node: '>=16 || 14 >=14.18'}531+532+ path-type@4.0.0:533+ resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}534+ engines: {node: '>=8'}535+536+ picomatch@2.3.2:537+ resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}538+ engines: {node: '>=8.6'}539+540+ picomatch@4.0.5:541+ resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}542+ engines: {node: '>=12'}543+544+ queue-microtask@1.2.3:545+ resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}546+547+ react-icons@5.3.0:548+ resolution: {integrity: sha512-DnUk8aFbTyQPSkCfF8dbX6kQjXA9DktMeJqfjrg6cK9vwQVMxmcA3BfP4QoiztVmEHtwlTgLFsPuH2NskKT6eg==}549+ peerDependencies:550+ react: '*'551+552+ react@19.2.8:553+ resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==}554+ engines: {node: '>=0.10.0'}555+556+ resolve@1.22.12:557+ resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==}558+ engines: {node: '>= 0.4'}559+ hasBin: true560+561+ reusify@1.1.0:562+ resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}563+ engines: {iojs: '>=1.0.0', node: '>=0.10.0'}564+565+ rimraf@3.0.2:566+ resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==}567+ deprecated: Rimraf versions prior to v4 are no longer supported568+ hasBin: true569+570+ rollup-plugin-delete@2.2.0:571+ resolution: {integrity: sha512-REKtDKWvjZlbrWpPvM9X/fadCs3E9I9ge27AK8G0e4bXwSLeABAAwtjiI1u3ihqZxk6mJeB2IVeSbH4DtOcw7A==}572+ engines: {node: '>=10'}573+ peerDependencies:574+ rollup: '*'575+576+ rollup-plugin-external-globals@0.11.0:577+ resolution: {integrity: sha512-LR+sH2WkgWMPxsA5o5rT7uW7BeWXSeygLe60QQi9qoN/ufaCuHDaVOIbndIkqDPnZt/wZugJh5DCzkZFdSWlLQ==}578+ peerDependencies:579+ rollup: ^2.25.0 || ^3.3.0 || ^4.1.4580+581+ rollup-plugin-import-assets@1.1.1:582+ resolution: {integrity: sha512-u5zJwOjguTf2N+wETq2weNKGvNkuVc1UX/fPgg215p5xPvGOaI6/BTc024E9brvFjSQTfIYqgvwogQdipknu1g==}583+ peerDependencies:584+ rollup: '>=1.9.0'585+586+ rollup-pluginutils@2.8.2:587+ resolution: {integrity: sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==}588+589+ rollup@4.53.3:590+ resolution: {integrity: sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==}591+ engines: {node: '>=18.0.0', npm: '>=8.0.0'}592+ hasBin: true593+594+ run-parallel@1.2.0:595+ resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}596+597+ shebang-command@2.0.0:598+ resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}599+ engines: {node: '>=8'}600+601+ shebang-regex@3.0.0:602+ resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}603+ engines: {node: '>=8'}604+605+ signal-exit@4.1.0:606+ resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}607+ engines: {node: '>=14'}608+609+ slash@3.0.0:610+ resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}611+ engines: {node: '>=8'}612+613+ string-width@4.2.3:614+ resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}615+ engines: {node: '>=8'}616+617+ string-width@5.1.2:618+ resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==}619+ engines: {node: '>=12'}620+621+ strip-ansi@6.0.1:622+ resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}623+ engines: {node: '>=8'}624+625+ strip-ansi@7.2.0:626+ resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==}627+ engines: {node: '>=12'}628+629+ supports-preserve-symlinks-flag@1.0.0:630+ resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}631+ engines: {node: '>= 0.4'}632+633+ to-regex-range@5.0.1:634+ resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}635+ engines: {node: '>=8.0'}636+637+ tslib@2.7.0:638+ resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==}639+640+ typescript@5.6.2:641+ resolution: {integrity: sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==}642+ engines: {node: '>=14.17'}643+ hasBin: true644+645+ url-join@4.0.1:646+ resolution: {integrity: sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==}647+648+ which@2.0.2:649+ resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}650+ engines: {node: '>= 8'}651+ hasBin: true652+653+ wrap-ansi@7.0.0:654+ resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}655+ engines: {node: '>=10'}656+657+ wrap-ansi@8.1.0:658+ resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}659+ engines: {node: '>=12'}660+661+ wrappy@1.0.2:662+ resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}663+664+snapshots:665+666+ '@decky/api@1.1.3': {}667+668+ '@decky/rollup@1.0.2':669+ dependencies:670+ '@rollup/plugin-commonjs': 26.0.3(rollup@4.53.3)671+ '@rollup/plugin-json': 6.1.0(rollup@4.53.3)672+ '@rollup/plugin-node-resolve': 15.3.1(rollup@4.53.3)673+ '@rollup/plugin-replace': 5.0.7(rollup@4.53.3)674+ '@rollup/plugin-typescript': 11.1.6(rollup@4.53.3)(tslib@2.7.0)(typescript@5.6.2)675+ merge-anything: 6.0.6676+ rollup: 4.53.3677+ rollup-plugin-delete: 2.2.0(rollup@4.53.3)678+ rollup-plugin-external-globals: 0.11.0(rollup@4.53.3)679+ rollup-plugin-import-assets: 1.1.1(rollup@4.53.3)680+ tslib: 2.7.0681+ typescript: 5.6.2682+683+ '@decky/ui@4.11.0': {}684+685+ '@isaacs/cliui@8.0.2':686+ dependencies:687+ string-width: 5.1.2688+ string-width-cjs: string-width@4.2.3689+ strip-ansi: 7.2.0690+ strip-ansi-cjs: strip-ansi@6.0.1691+ wrap-ansi: 8.1.0692+ wrap-ansi-cjs: wrap-ansi@7.0.0693+694+ '@jridgewell/sourcemap-codec@1.5.5': {}695+696+ '@nodelib/fs.scandir@2.1.5':697+ dependencies:698+ '@nodelib/fs.stat': 2.0.5699+ run-parallel: 1.2.0700+701+ '@nodelib/fs.stat@2.0.5': {}702+703+ '@nodelib/fs.walk@1.2.8':704+ dependencies:705+ '@nodelib/fs.scandir': 2.1.5706+ fastq: 1.20.1707+708+ '@pkgjs/parseargs@0.11.0':709+ optional: true710+711+ '@rollup/plugin-commonjs@26.0.3(rollup@4.53.3)':712+ dependencies:713+ '@rollup/pluginutils': 5.4.0(rollup@4.53.3)714+ commondir: 1.0.1715+ estree-walker: 2.0.2716+ glob: 10.5.0717+ is-reference: 1.2.1718+ magic-string: 0.30.21719+ optionalDependencies:720+ rollup: 4.53.3721+722+ '@rollup/plugin-json@6.1.0(rollup@4.53.3)':723+ dependencies:724+ '@rollup/pluginutils': 5.4.0(rollup@4.53.3)725+ optionalDependencies:726+ rollup: 4.53.3727+728+ '@rollup/plugin-node-resolve@15.3.1(rollup@4.53.3)':729+ dependencies:730+ '@rollup/pluginutils': 5.4.0(rollup@4.53.3)731+ '@types/resolve': 1.20.2732+ deepmerge: 4.3.1733+ is-module: 1.0.0734+ resolve: 1.22.12735+ optionalDependencies:736+ rollup: 4.53.3737+738+ '@rollup/plugin-replace@5.0.7(rollup@4.53.3)':739+ dependencies:740+ '@rollup/pluginutils': 5.4.0(rollup@4.53.3)741+ magic-string: 0.30.21742+ optionalDependencies:743+ rollup: 4.53.3744+745+ '@rollup/plugin-typescript@11.1.6(rollup@4.53.3)(tslib@2.7.0)(typescript@5.6.2)':746+ dependencies:747+ '@rollup/pluginutils': 5.4.0(rollup@4.53.3)748+ resolve: 1.22.12749+ typescript: 5.6.2750+ optionalDependencies:751+ rollup: 4.53.3752+ tslib: 2.7.0753+754+ '@rollup/pluginutils@5.4.0(rollup@4.53.3)':755+ dependencies:756+ '@types/estree': 1.0.9757+ estree-walker: 2.0.2758+ picomatch: 4.0.5759+ optionalDependencies:760+ rollup: 4.53.3761+762+ '@rollup/rollup-android-arm-eabi@4.53.3':763+ optional: true764+765+ '@rollup/rollup-android-arm64@4.53.3':766+ optional: true767+768+ '@rollup/rollup-darwin-arm64@4.53.3':769+ optional: true770+771+ '@rollup/rollup-darwin-x64@4.53.3':772+ optional: true773+774+ '@rollup/rollup-freebsd-arm64@4.53.3':775+ optional: true776+777+ '@rollup/rollup-freebsd-x64@4.53.3':778+ optional: true779+780+ '@rollup/rollup-linux-arm-gnueabihf@4.53.3':781+ optional: true782+783+ '@rollup/rollup-linux-arm-musleabihf@4.53.3':784+ optional: true785+786+ '@rollup/rollup-linux-arm64-gnu@4.53.3':787+ optional: true788+789+ '@rollup/rollup-linux-arm64-musl@4.53.3':790+ optional: true791+792+ '@rollup/rollup-linux-loong64-gnu@4.53.3':793+ optional: true794+795+ '@rollup/rollup-linux-ppc64-gnu@4.53.3':796+ optional: true797+798+ '@rollup/rollup-linux-riscv64-gnu@4.53.3':799+ optional: true800+801+ '@rollup/rollup-linux-riscv64-musl@4.53.3':802+ optional: true803+804+ '@rollup/rollup-linux-s390x-gnu@4.53.3':805+ optional: true806+807+ '@rollup/rollup-linux-x64-gnu@4.53.3':808+ optional: true809+810+ '@rollup/rollup-linux-x64-musl@4.53.3': {}811+812+ '@rollup/rollup-openharmony-arm64@4.53.3':813+ optional: true814+815+ '@rollup/rollup-win32-arm64-msvc@4.53.3':816+ optional: true817+818+ '@rollup/rollup-win32-ia32-msvc@4.53.3':819+ optional: true820+821+ '@rollup/rollup-win32-x64-gnu@4.53.3':822+ optional: true823+824+ '@rollup/rollup-win32-x64-msvc@4.53.3':825+ optional: true826+827+ '@types/estree@1.0.8': {}828+829+ '@types/estree@1.0.9': {}830+831+ '@types/history@4.7.11': {}832+833+ '@types/react-dom@19.1.1(@types/react@19.1.1)':834+ dependencies:835+ '@types/react': 19.1.1836+837+ '@types/react-router@5.1.20':838+ dependencies:839+ '@types/history': 4.7.11840+ '@types/react': 19.1.1841+842+ '@types/react@19.1.1':843+ dependencies:844+ csstype: 3.2.3845+846+ '@types/resolve@1.20.2': {}847+848+ aggregate-error@3.1.0:849+ dependencies:850+ clean-stack: 2.2.0851+ indent-string: 4.0.0852+853+ ansi-regex@5.0.1: {}854+855+ ansi-regex@6.2.2: {}856+857+ ansi-styles@4.3.0:858+ dependencies:859+ color-convert: 2.0.1860+861+ ansi-styles@6.2.3: {}862+863+ array-union@2.1.0: {}864+865+ balanced-match@1.0.2: {}866+867+ brace-expansion@1.1.16:868+ dependencies:869+ balanced-match: 1.0.2870+ concat-map: 0.0.1871+872+ brace-expansion@2.1.2:873+ dependencies:874+ balanced-match: 1.0.2875+876+ braces@3.0.3:877+ dependencies:878+ fill-range: 7.1.1879+880+ clean-stack@2.2.0: {}881+882+ color-convert@2.0.1:883+ dependencies:884+ color-name: 1.1.4885+886+ color-name@1.1.4: {}887+888+ commondir@1.0.1: {}889+890+ concat-map@0.0.1: {}891+892+ cross-spawn@7.0.6:893+ dependencies:894+ path-key: 3.1.1895+ shebang-command: 2.0.0896+ which: 2.0.2897+898+ csstype@3.2.3: {}899+900+ deepmerge@4.3.1: {}901+902+ del@6.1.1:903+ dependencies:904+ globby: 11.1.0905+ graceful-fs: 4.2.11906+ is-glob: 4.0.3907+ is-path-cwd: 2.2.0908+ is-path-inside: 3.0.3909+ p-map: 4.0.0910+ rimraf: 3.0.2911+ slash: 3.0.0912+913+ dir-glob@3.0.1:914+ dependencies:915+ path-type: 4.0.0916+917+ eastasianwidth@0.2.0: {}918+919+ emoji-regex@8.0.0: {}920+921+ emoji-regex@9.2.2: {}922+923+ es-errors@1.3.0: {}924+925+ estree-walker@0.6.1: {}926+927+ estree-walker@2.0.2: {}928+929+ estree-walker@3.0.3:930+ dependencies:931+ '@types/estree': 1.0.9932+933+ fast-glob@3.3.3:934+ dependencies:935+ '@nodelib/fs.stat': 2.0.5936+ '@nodelib/fs.walk': 1.2.8937+ glob-parent: 5.1.2938+ merge2: 1.4.1939+ micromatch: 4.0.8940+941+ fastq@1.20.1:942+ dependencies:943+ reusify: 1.1.0944+945+ fill-range@7.1.1:946+ dependencies:947+ to-regex-range: 5.0.1948+949+ foreground-child@3.3.1:950+ dependencies:951+ cross-spawn: 7.0.6952+ signal-exit: 4.1.0953+954+ fs.realpath@1.0.0: {}955+956+ fsevents@2.3.3:957+ optional: true958+959+ function-bind@1.1.2: {}960+961+ glob-parent@5.1.2:962+ dependencies:963+ is-glob: 4.0.3964+965+ glob@10.5.0:966+ dependencies:967+ foreground-child: 3.3.1968+ jackspeak: 3.4.3969+ minimatch: 9.0.9970+ minipass: 7.1.3971+ package-json-from-dist: 1.0.1972+ path-scurry: 1.11.1973+974+ glob@7.2.3:975+ dependencies:976+ fs.realpath: 1.0.0977+ inflight: 1.0.6978+ inherits: 2.0.4979+ minimatch: 3.1.5980+ once: 1.4.0981+ path-is-absolute: 1.0.1982+983+ globby@11.1.0:984+ dependencies:985+ array-union: 2.1.0986+ dir-glob: 3.0.1987+ fast-glob: 3.3.3988+ ignore: 5.3.2989+ merge2: 1.4.1990+ slash: 3.0.0991+992+ graceful-fs@4.2.11: {}993+994+ hasown@2.0.4:995+ dependencies:996+ function-bind: 1.1.2997+998+ ignore@5.3.2: {}999+1000+ indent-string@4.0.0: {}1001+1002+ inflight@1.0.6:1003+ dependencies:1004+ once: 1.4.01005+ wrappy: 1.0.21006+1007+ inherits@2.0.4: {}1008+1009+ is-core-module@2.16.2:1010+ dependencies:1011+ hasown: 2.0.41012+1013+ is-extglob@2.1.1: {}1014+1015+ is-fullwidth-code-point@3.0.0: {}1016+1017+ is-glob@4.0.3:1018+ dependencies:1019+ is-extglob: 2.1.11020+1021+ is-module@1.0.0: {}1022+1023+ is-number@7.0.0: {}1024+1025+ is-path-cwd@2.2.0: {}1026+1027+ is-path-inside@3.0.3: {}1028+1029+ is-reference@1.2.1:1030+ dependencies:1031+ '@types/estree': 1.0.91032+1033+ is-reference@3.0.3:1034+ dependencies:1035+ '@types/estree': 1.0.91036+1037+ is-what@5.5.0: {}1038+1039+ isexe@2.0.0: {}1040+1041+ jackspeak@3.4.3:1042+ dependencies:1043+ '@isaacs/cliui': 8.0.21044+ optionalDependencies:1045+ '@pkgjs/parseargs': 0.11.01046+1047+ lru-cache@10.4.3: {}1048+1049+ magic-string@0.30.21:1050+ dependencies:1051+ '@jridgewell/sourcemap-codec': 1.5.51052+1053+ merge-anything@6.0.6:1054+ dependencies:1055+ is-what: 5.5.01056+1057+ merge2@1.4.1: {}1058+1059+ micromatch@4.0.8:1060+ dependencies:1061+ braces: 3.0.31062+ picomatch: 2.3.21063+1064+ minimatch@3.1.5:1065+ dependencies:1066+ brace-expansion: 1.1.161067+1068+ minimatch@9.0.9:1069+ dependencies:1070+ brace-expansion: 2.1.21071+1072+ minipass@7.1.3: {}1073+1074+ once@1.4.0:1075+ dependencies:1076+ wrappy: 1.0.21077+1078+ p-map@4.0.0:1079+ dependencies:1080+ aggregate-error: 3.1.01081+1082+ package-json-from-dist@1.0.1: {}1083+1084+ path-is-absolute@1.0.1: {}1085+1086+ path-key@3.1.1: {}1087+1088+ path-parse@1.0.7: {}1089+1090+ path-scurry@1.11.1:1091+ dependencies:1092+ lru-cache: 10.4.31093+ minipass: 7.1.31094+1095+ path-type@4.0.0: {}1096+1097+ picomatch@2.3.2: {}1098+1099+ picomatch@4.0.5: {}1100+1101+ queue-microtask@1.2.3: {}1102+1103+ react-icons@5.3.0(react@19.2.8):1104+ dependencies:1105+ react: 19.2.81106+1107+ react@19.2.8: {}1108+1109+ resolve@1.22.12:1110+ dependencies:1111+ es-errors: 1.3.01112+ is-core-module: 2.16.21113+ path-parse: 1.0.71114+ supports-preserve-symlinks-flag: 1.0.01115+1116+ reusify@1.1.0: {}1117+1118+ rimraf@3.0.2:1119+ dependencies:1120+ glob: 7.2.31121+1122+ rollup-plugin-delete@2.2.0(rollup@4.53.3):1123+ dependencies:1124+ del: 6.1.11125+ rollup: 4.53.31126+1127+ rollup-plugin-external-globals@0.11.0(rollup@4.53.3):1128+ dependencies:1129+ '@rollup/pluginutils': 5.4.0(rollup@4.53.3)1130+ estree-walker: 3.0.31131+ is-reference: 3.0.31132+ magic-string: 0.30.211133+ rollup: 4.53.31134+1135+ rollup-plugin-import-assets@1.1.1(rollup@4.53.3):1136+ dependencies:1137+ rollup: 4.53.31138+ rollup-pluginutils: 2.8.21139+ url-join: 4.0.11140+1141+ rollup-pluginutils@2.8.2:1142+ dependencies:1143+ estree-walker: 0.6.11144+1145+ rollup@4.53.3:1146+ dependencies:1147+ '@types/estree': 1.0.81148+ optionalDependencies:1149+ '@rollup/rollup-android-arm-eabi': 4.53.31150+ '@rollup/rollup-android-arm64': 4.53.31151+ '@rollup/rollup-darwin-arm64': 4.53.31152+ '@rollup/rollup-darwin-x64': 4.53.31153+ '@rollup/rollup-freebsd-arm64': 4.53.31154+ '@rollup/rollup-freebsd-x64': 4.53.31155+ '@rollup/rollup-linux-arm-gnueabihf': 4.53.31156+ '@rollup/rollup-linux-arm-musleabihf': 4.53.31157+ '@rollup/rollup-linux-arm64-gnu': 4.53.31158+ '@rollup/rollup-linux-arm64-musl': 4.53.31159+ '@rollup/rollup-linux-loong64-gnu': 4.53.31160+ '@rollup/rollup-linux-ppc64-gnu': 4.53.31161+ '@rollup/rollup-linux-riscv64-gnu': 4.53.31162+ '@rollup/rollup-linux-riscv64-musl': 4.53.31163+ '@rollup/rollup-linux-s390x-gnu': 4.53.31164+ '@rollup/rollup-linux-x64-gnu': 4.53.31165+ '@rollup/rollup-linux-x64-musl': 4.53.31166+ '@rollup/rollup-openharmony-arm64': 4.53.31167+ '@rollup/rollup-win32-arm64-msvc': 4.53.31168+ '@rollup/rollup-win32-ia32-msvc': 4.53.31169+ '@rollup/rollup-win32-x64-gnu': 4.53.31170+ '@rollup/rollup-win32-x64-msvc': 4.53.31171+ fsevents: 2.3.31172+1173+ run-parallel@1.2.0:1174+ dependencies:1175+ queue-microtask: 1.2.31176+1177+ shebang-command@2.0.0:1178+ dependencies:1179+ shebang-regex: 3.0.01180+1181+ shebang-regex@3.0.0: {}1182+1183+ signal-exit@4.1.0: {}1184+1185+ slash@3.0.0: {}1186+1187+ string-width@4.2.3:1188+ dependencies:1189+ emoji-regex: 8.0.01190+ is-fullwidth-code-point: 3.0.01191+ strip-ansi: 6.0.11192+1193+ string-width@5.1.2:1194+ dependencies:1195+ eastasianwidth: 0.2.01196+ emoji-regex: 9.2.21197+ strip-ansi: 7.2.01198+1199+ strip-ansi@6.0.1:1200+ dependencies:1201+ ansi-regex: 5.0.11202+1203+ strip-ansi@7.2.0:1204+ dependencies:1205+ ansi-regex: 6.2.21206+1207+ supports-preserve-symlinks-flag@1.0.0: {}1208+1209+ to-regex-range@5.0.1:1210+ dependencies:1211+ is-number: 7.0.01212+1213+ tslib@2.7.0: {}1214+1215+ typescript@5.6.2: {}1216+1217+ url-join@4.0.1: {}1218+1219+ which@2.0.2:1220+ dependencies:1221+ isexe: 2.0.01222+1223+ wrap-ansi@7.0.0:1224+ dependencies:1225+ ansi-styles: 4.3.01226+ string-width: 4.2.31227+ strip-ansi: 6.0.11228+1229+ wrap-ansi@8.1.0:1230+ dependencies:1231+ ansi-styles: 6.2.31232+ string-width: 5.1.21233+ strip-ansi: 7.2.01234+1235+ wrappy@1.0.2: {}@@ -0,0 +1,530 @@
1+"""Transactional, local-only orchestration over Sigil's appliance CLI."""2+3+from __future__ import annotations4+5+import asyncio6+import re7+from pathlib import Path8+from typing import Any9+10+from .errors import ControllerError11+from .runner import Runner12+13+14+FINGERPRINT = re.compile(r"^[0-9a-f]{8}…[0-9a-f]{8}$")15+TRANSACTION = re.compile(r"^[0-9a-f]{32}$")16+READY_TIMEOUT_SECONDS = 30.017+POLL_SECONDS = 0.2518+19+20+class Controller:21+ def __init__(self, runner: Runner):22+ self.runner = runner23+ self._mutation_lock = asyncio.Lock()24+25+ async def _status(self) -> dict[str, Any]:26+ return await self.runner.sigil_json(27+ [28+ "appliance",29+ "status",30+ "--config",31+ str(self.runner.config),32+ "--json",33+ "--schema-version",34+ "2",35+ ],36+ schema_version=2,37+ )38+39+ async def _service(self) -> dict[str, str]:40+ result = await self.runner.systemctl_command("show", check=False)41+ values: dict[str, str] = {}42+ for raw_line in result.stdout.decode("utf-8", "strict").splitlines():43+ key, separator, value = raw_line.partition("=")44+ if separator and key in {45+ "LoadState",46+ "ActiveState",47+ "SubState",48+ "UnitFileState",49+ }:50+ values[key] = value[:64]51+ return {52+ "installed": values.get("LoadState") == "loaded",53+ "active": values.get("ActiveState") == "active",54+ "active_state": values.get("ActiveState", "unknown"),55+ "sub_state": values.get("SubState", "unknown"),56+ "unit_file_state": values.get("UnitFileState", "unknown"),57+ }58+59+ async def get_snapshot(self) -> dict[str, Any]:60+ service = await self._service()61+ if not self.runner.sigil.is_file():62+ return {63+ "schema_version": 1,64+ "installed": False,65+ "compatible": False,66+ "service": service,67+ "appliance": None,68+ "capabilities": {69+ "stream_diagnostics": False,70+ "factory_reset": False,71+ },72+ }73+ try:74+ appliance = await self._status()75+ compatible = True76+ error = None77+ except ControllerError as failure:78+ appliance = None79+ compatible = failure.code != "incompatible_sigil"80+ error = failure.code81+ response: dict[str, Any] = {82+ "schema_version": 1,83+ "installed": True,84+ "compatible": compatible,85+ "service": service,86+ "appliance": appliance,87+ "capabilities": {88+ "stream_diagnostics": False,89+ "factory_reset": False,90+ },91+ }92+ if error is not None:93+ response["error"] = error94+ return response95+96+ async def get_config(self) -> dict[str, Any]:97+ return await self.runner.sigil_json(98+ [99+ "appliance",100+ "config",101+ "show",102+ "--config",103+ str(self.runner.config),104+ "--json",105+ ]106+ )107+108+ async def validate_config(self, request: dict[str, Any]) -> dict[str, Any]:109+ self._validate_config_request(request)110+ return await self.runner.sigil_json(111+ [112+ "appliance",113+ "config",114+ "validate",115+ "--config",116+ str(self.runner.config),117+ "--json",118+ ],119+ stdin_value=request,120+ )121+122+ async def restart_service(self) -> dict[str, Any]:123+ async with self._mutation_lock:124+ previous_instance = await self._current_instance()125+ await self.runner.systemctl_command("restart")126+ status = await self._wait_ready(previous_instance=previous_instance)127+ return {"schema_version": 1, "operation": "restart", "status": status}128+129+ async def apply_config(self, request: dict[str, Any]) -> dict[str, Any]:130+ self._validate_config_request(request)131+ async with self._mutation_lock:132+ validation = await self.validate_config(request)133+ if not validation.get("changed"):134+ return {135+ "schema_version": 1,136+ "operation": "config_apply",137+ "changed": False,138+ "revision": validation.get("candidate_revision"),139+ }140+141+ was_active = (await self._service())["active"]142+ baseline_instance = await self._current_instance()143+ transaction: str | None = None144+ candidate_instance: str | None = None145+ candidate_revision: str | None = None146+ commit_response_failure: BaseException | None = None147+ try:148+ await self._stop_and_wait()149+ installed = await self.runner.sigil_json(150+ [151+ "appliance",152+ "config",153+ "set",154+ "--config",155+ str(self.runner.config),156+ "--json",157+ ],158+ stdin_value=request,159+ )160+ transaction = installed.get("transaction")161+ if not isinstance(transaction, str) or not TRANSACTION.fullmatch(transaction):162+ raise ControllerError("invalid_response")163+ candidate_revision = self._revision(installed.get("candidate_revision"))164+ await self.runner.systemctl_command("start")165+ ready = await self._wait_ready(166+ expected_revision=candidate_revision,167+ previous_instance=baseline_instance,168+ )169+ instance = ready.get("runtime", {}).get("instance_id")170+ if not isinstance(instance, str) or not TRANSACTION.fullmatch(instance):171+ raise ControllerError("health_not_proven")172+ candidate_instance = instance173+ await self._stop_and_wait()174+ try:175+ committed = await self.runner.sigil_json(176+ [177+ "appliance",178+ "config",179+ "commit",180+ "--config",181+ str(self.runner.config),182+ "--transaction",183+ transaction,184+ "--expected-instance",185+ instance,186+ "--json",187+ ]188+ )189+ committed_revision = self._revision(committed.get("revision"))190+ if committed_revision != candidate_revision:191+ raise ControllerError("invalid_response")192+ except BaseException as failure:193+ committed_without_response = await asyncio.shield(194+ self._config_is_committed(candidate_revision)195+ )196+ if not committed_without_response:197+ raise198+ committed_revision = candidate_revision199+ commit_response_failure = failure200+ except BaseException as failure:201+ rollback_ok = await asyncio.shield(202+ self._recover_apply(203+ transaction,204+ was_active,205+ baseline_instance,206+ request["expected_revision"],207+ )208+ )209+ if isinstance(failure, asyncio.CancelledError):210+ raise211+ if not rollback_ok:212+ raise ControllerError("rollback_failed") from failure213+ raise ControllerError("apply_rolled_back") from failure214+215+ if was_active:216+ try:217+ await self._start_and_wait(candidate_instance, committed_revision)218+ except BaseException as failure:219+ # The transaction is already committed. Never claim that it rolled back220+ # or stop the proven candidate after a UI/service-health failure.221+ try:222+ await asyncio.shield(self.runner.systemctl_command("start"))223+ except Exception:224+ pass225+ if isinstance(failure, asyncio.CancelledError):226+ raise227+ raise ControllerError("post_commit_service_unhealthy") from failure228+ if isinstance(commit_response_failure, asyncio.CancelledError):229+ raise commit_response_failure230+ return {231+ "schema_version": 1,232+ "operation": "config_apply",233+ "changed": True,234+ "revision": committed_revision,235+ "commit_response_recovered": commit_response_failure is not None,236+ }237+238+ async def rollback_pending(self, transaction: str) -> dict[str, Any]:239+ if not TRANSACTION.fullmatch(transaction):240+ raise ControllerError("invalid_request")241+ async with self._mutation_lock:242+ was_active = (await self._service())["active"]243+ previous_instance = await self._current_instance()244+ before = await self.get_config()245+ pending = before.get("pending_transaction")246+ if not isinstance(pending, dict) or pending.get("transaction") != transaction:247+ raise ControllerError("transaction_not_found")248+ base_revision = self._revision(pending.get("base_revision"))249+ await self._stop_and_wait()250+ try:251+ result = await self.runner.sigil_json(252+ [253+ "appliance",254+ "config",255+ "rollback",256+ "--config",257+ str(self.runner.config),258+ "--transaction",259+ transaction,260+ "--json",261+ ]262+ )263+ restored_revision = self._revision(result.get("restored_revision"))264+ if restored_revision != base_revision:265+ raise ControllerError("invalid_response")266+ except BaseException as failure:267+ completed = await asyncio.shield(268+ self._rollback_completed(transaction, base_revision)269+ )270+ if completed and was_active:271+ try:272+ await asyncio.shield(273+ self._start_and_wait(previous_instance, base_revision)274+ )275+ except Exception as recovery_error:276+ raise ControllerError("rollback_service_unhealthy") from recovery_error277+ if isinstance(failure, asyncio.CancelledError):278+ raise279+ if completed:280+ raise ControllerError("rollback_response_lost") from failure281+ # The candidate may still be live in the config file. Staying stopped is282+ # the only fail-closed outcome until the user retries explicit rollback.283+ raise ControllerError("rollback_failed_safe") from failure284+ if was_active:285+ await self._start_and_wait(previous_instance, restored_revision)286+ return result287+288+ async def reset_enrollment(self, expected_host_fingerprint: str) -> dict[str, Any]:289+ if not FINGERPRINT.fullmatch(expected_host_fingerprint):290+ raise ControllerError("invalid_request")291+ async with self._mutation_lock:292+ was_active = (await self._service())["active"]293+ previous_instance = await self._current_instance()294+ failure: BaseException | None = None295+ result: dict[str, Any] | None = None296+ try:297+ await self._stop_and_wait()298+ result = await self.runner.sigil_json(299+ [300+ "appliance",301+ "enrollment-reset",302+ "--config",303+ str(self.runner.config),304+ "--expected-host-fingerprint",305+ expected_host_fingerprint,306+ "--json",307+ ]308+ )309+ except BaseException as error:310+ failure = error311+ restore_ok = True312+ if was_active:313+ try:314+ await asyncio.shield(315+ self._start_and_wait(previous_instance, None)316+ )317+ except Exception:318+ restore_ok = False319+ if failure is not None:320+ if isinstance(failure, asyncio.CancelledError):321+ raise failure322+ if not restore_ok:323+ raise ControllerError("reset_restore_failed") from failure324+ raise failure325+ if not restore_ok:326+ raise ControllerError("reset_restore_failed")327+ if result is None:328+ raise ControllerError("invalid_response")329+ return result330+331+ async def _recover_apply(332+ self,333+ transaction: str | None,334+ was_active: bool,335+ previous_instance: str | None,336+ base_revision: str,337+ ) -> bool:338+ try:339+ await self._stop_and_wait()340+ if transaction is None:341+ shown = await self.get_config()342+ pending = shown.get("pending_transaction")343+ if isinstance(pending, dict):344+ candidate = pending.get("transaction")345+ if isinstance(candidate, str) and TRANSACTION.fullmatch(candidate):346+ transaction = candidate347+ if transaction is not None:348+ rolled_back = await self.runner.sigil_json(349+ [350+ "appliance",351+ "config",352+ "rollback",353+ "--config",354+ str(self.runner.config),355+ "--transaction",356+ transaction,357+ "--json",358+ ]359+ )360+ base_revision = self._revision(rolled_back.get("restored_revision"))361+ if was_active:362+ await self._start_and_wait(previous_instance, base_revision)363+ return True364+ except BaseException:365+ return False366+367+ async def _rollback_completed(self, transaction: str, base_revision: str) -> bool:368+ try:369+ shown = await self.get_config()370+ return (371+ shown.get("revision") == base_revision372+ and shown.get("pending_transaction") is None373+ )374+ except ControllerError:375+ return False376+377+ async def _config_is_committed(self, candidate_revision: str) -> bool:378+ try:379+ shown = await self.get_config()380+ return (381+ shown.get("revision") == candidate_revision382+ and shown.get("pending_transaction") is None383+ )384+ except ControllerError:385+ return False386+387+ async def _current_instance(self) -> str | None:388+ try:389+ instance = (await self._status()).get("runtime", {}).get("instance_id")390+ if instance is None:391+ # A successful status read with no instance proves there is no stale392+ # daemon snapshot to confuse with the process about to start.393+ return ""394+ return (395+ instance396+ if isinstance(instance, str) and TRANSACTION.fullmatch(instance)397+ else None398+ )399+ except ControllerError:400+ return None401+402+ async def _start_and_wait(403+ self, previous_instance: str | None, expected_revision: str | None404+ ) -> dict[str, Any]:405+ await self.runner.systemctl_command("start")406+ return await self._wait_ready(407+ expected_revision=expected_revision,408+ previous_instance=previous_instance,409+ )410+411+ async def _stop_and_wait(self) -> None:412+ await self.runner.systemctl_command("stop")413+ deadline = asyncio.get_running_loop().time() + READY_TIMEOUT_SECONDS414+ while asyncio.get_running_loop().time() < deadline:415+ if not (await self._service())["active"]:416+ return417+ await asyncio.sleep(POLL_SECONDS)418+ raise ControllerError("service_stop_timeout")419+420+ async def _wait_ready(421+ self,422+ *,423+ expected_revision: Any = None,424+ previous_instance: Any = None,425+ ) -> dict[str, Any]:426+ deadline = asyncio.get_running_loop().time() + READY_TIMEOUT_SECONDS427+ while asyncio.get_running_loop().time() < deadline:428+ try:429+ status = await self._status()430+ runtime = status.get("runtime", {})431+ instance = runtime.get("instance_id")432+ revision_ok = (433+ expected_revision is None434+ or runtime.get("loaded_config_revision") == expected_revision435+ )436+ instance_ok = (437+ isinstance(previous_instance, str) and instance != previous_instance438+ )439+ if (440+ status.get("overall") in {"ready", "active"}441+ and runtime.get("state") == "fresh"442+ and runtime.get("daemon") == "ready"443+ and isinstance(instance, str)444+ and revision_ok445+ and instance_ok446+ ):447+ return status448+ except ControllerError:449+ pass450+ await asyncio.sleep(POLL_SECONDS)451+ raise ControllerError("service_ready_timeout")452+453+454+ def _revision(value: Any) -> str:455+ if not isinstance(value, str) or not re.fullmatch(r"sha256:[0-9a-f]{64}", value):456+ raise ControllerError("invalid_response")457+ return value458+459+460+ def _validate_config_request(request: dict[str, Any]) -> None:461+ if not isinstance(request, dict) or set(request) != {462+ "schema_version",463+ "expected_revision",464+ "settings",465+ }:466+ raise ControllerError("invalid_request")467+ if request["schema_version"] != 1:468+ raise ControllerError("invalid_request")469+ revision = request["expected_revision"]470+ if not isinstance(revision, str) or not re.fullmatch(471+ r"sha256:[0-9a-f]{64}", revision472+ ):473+ raise ControllerError("invalid_request")474+ settings = request["settings"]475+ if not isinstance(settings, dict) or set(settings) != {476+ "resolution",477+ "framerate",478+ "rate_control",479+ }:480+ raise ControllerError("invalid_request")481+ framerate = settings["framerate"]482+ if (483+ not isinstance(framerate, int)484+ or isinstance(framerate, bool)485+ or not 1 <= framerate <= 240486+ ):487+ raise ControllerError("invalid_request")488+ resolution = settings["resolution"]489+ if not isinstance(resolution, dict) or resolution.get("mode") not in {490+ "native",491+ "fixed",492+ }:493+ raise ControllerError("invalid_request")494+ if resolution["mode"] == "native":495+ if set(resolution) != {"mode"}:496+ raise ControllerError("invalid_request")497+ else:498+ if set(resolution) != {"mode", "width", "height"}:499+ raise ControllerError("invalid_request")500+ for field in ("width", "height"):501+ value = resolution[field]502+ if (503+ not isinstance(value, int)504+ or isinstance(value, bool)505+ or value < 64506+ or value % 2507+ ):508+ raise ControllerError("invalid_request")509+ if resolution["width"] > 7680 or resolution["height"] > 4320:510+ raise ControllerError("invalid_request")511+ rate_control = settings["rate_control"]512+ if rate_control is None:513+ return514+ if not isinstance(rate_control, dict):515+ raise ControllerError("invalid_request")516+ mode = rate_control.get("mode")517+ if mode == "cbr":518+ if set(rate_control) != {"mode", "bitrate_kbps"}:519+ raise ControllerError("invalid_request")520+ value = rate_control["bitrate_kbps"]521+ if not isinstance(value, int) or isinstance(value, bool) or not 1000 <= value <= 100000:522+ raise ControllerError("invalid_request")523+ elif mode == "cqp":524+ if set(rate_control) != {"mode", "quantizer"}:525+ raise ControllerError("invalid_request")526+ value = rate_control["quantizer"]527+ if not isinstance(value, int) or isinstance(value, bool) or not 1 <= value <= 51:528+ raise ControllerError("invalid_request")529+ else:530+ raise ControllerError("invalid_request")@@ -0,0 +1,194 @@
1+"""Bounded subprocess execution for the fixed Sigil and systemd interfaces."""2+3+from __future__ import annotations4+5+import asyncio6+import json7+import os8+import stat9+from dataclasses import dataclass10+from pathlib import Path11+from typing import Any, Sequence12+13+from .errors import ControllerError14+15+16+MAX_OUTPUT_BYTES = 128 * 102417+MAX_REQUEST_BYTES = 16 * 102418+19+20+21+class CommandResult:22+ returncode: int23+ stdout: bytes24+ stderr: bytes25+26+27+def _strict_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:28+ result: dict[str, Any] = {}29+ for key, value in pairs:30+ if key in result:31+ raise ValueError("duplicate JSON key")32+ result[key] = value33+ return result34+35+36+def strict_json(raw: bytes, schema_version: int) -> dict[str, Any]:37+ if not raw or len(raw) > MAX_OUTPUT_BYTES:38+ raise ControllerError("invalid_response")39+ try:40+ value = json.loads(raw, object_pairs_hook=_strict_object)41+ except (UnicodeDecodeError, ValueError, json.JSONDecodeError) as error:42+ raise ControllerError("invalid_response") from error43+ if not isinstance(value, dict) or value.get("schema_version") != schema_version:44+ raise ControllerError("incompatible_sigil")45+ return value46+47+48+class Runner:49+ """Runs only absolute, caller-assembled argv without invoking a shell."""50+51+ def __init__(self, user_home: str | Path, *, uid: int | None = None):52+ self.home = Path(user_home)53+ self.uid = os.geteuid() if uid is None else uid54+ self.sigil = self.home / ".local/libexec/sigil-spark/current/sigil"55+ self.config = self.home / ".config/sigil-spark/host.toml"56+ self.unit = "sigil-host.service"57+ self.systemctl = Path("/usr/bin/systemctl")58+59+ def environment(self) -> dict[str, str]:60+ if self.uid == 0:61+ raise ControllerError("unsafe_user")62+ runtime = Path(f"/run/user/{self.uid}")63+ bus = runtime / "bus"64+ try:65+ runtime_stat = runtime.stat()66+ bus_stat = bus.stat()67+ except OSError as error:68+ raise ControllerError("user_session_unavailable") from error69+ if (70+ not stat.S_ISDIR(runtime_stat.st_mode)71+ or runtime_stat.st_uid != self.uid72+ or runtime_stat.st_mode & 0o07773+ or not stat.S_ISSOCK(bus_stat.st_mode)74+ or bus_stat.st_uid != self.uid75+ ):76+ raise ControllerError("unsafe_user_session")77+ return {78+ "HOME": str(self.home),79+ "XDG_RUNTIME_DIR": str(runtime),80+ "DBUS_SESSION_BUS_ADDRESS": f"unix:path={bus}",81+ "LANG": "C.UTF-8",82+ }83+84+ async def run(85+ self,86+ argv: Sequence[str | Path],87+ *,88+ stdin: bytes | None = None,89+ timeout: float = 10.0,90+ check: bool = True,91+ ) -> CommandResult:92+ if not argv or not Path(argv[0]).is_absolute():93+ raise ControllerError("unsafe_command")94+ if stdin is not None and len(stdin) > MAX_REQUEST_BYTES:95+ raise ControllerError("invalid_request")96+ process = await asyncio.create_subprocess_exec(97+ *(str(value) for value in argv),98+ stdin=asyncio.subprocess.PIPE if stdin is not None else asyncio.subprocess.DEVNULL,99+ stdout=asyncio.subprocess.PIPE,100+ stderr=asyncio.subprocess.PIPE,101+ env=self.environment(),102+ )103+104+ async def stop_child() -> None:105+ if process.returncode is not None:106+ return107+ process.terminate()108+ try:109+ await asyncio.wait_for(process.wait(), timeout=1.0)110+ except TimeoutError:111+ process.kill()112+ await process.wait()113+114+ async def read_bounded(stream: asyncio.StreamReader | None) -> bytes:115+ if stream is None:116+ return b""117+ chunks: list[bytes] = []118+ total = 0119+ while True:120+ chunk = await stream.read(min(4096, MAX_OUTPUT_BYTES + 1 - total))121+ if not chunk:122+ return b"".join(chunks)123+ chunks.append(chunk)124+ total += len(chunk)125+ if total > MAX_OUTPUT_BYTES:126+ raise ControllerError("output_limit")127+128+ try:129+ if stdin is not None and process.stdin is not None:130+ process.stdin.write(stdin)131+ await process.stdin.drain()132+ process.stdin.close()133+ stdout, stderr, _ = await asyncio.wait_for(134+ asyncio.gather(135+ read_bounded(process.stdout),136+ read_bounded(process.stderr),137+ process.wait(),138+ ),139+ timeout=timeout,140+ )141+ except (TimeoutError, ControllerError) as error:142+ await stop_child()143+ if isinstance(error, ControllerError):144+ raise145+ raise ControllerError("command_timeout") from error146+ except BaseException:147+ await stop_child()148+ raise149+150+ result = CommandResult(process.returncode or 0, stdout, stderr)151+ if check and result.returncode != 0:152+ raise ControllerError("command_failed")153+ return result154+155+ async def sigil_json(156+ self,157+ arguments: Sequence[str],158+ *,159+ stdin_value: dict[str, Any] | None = None,160+ schema_version: int = 1,161+ timeout: float = 10.0,162+ ) -> dict[str, Any]:163+ if not self.sigil.is_file() or not os.access(self.sigil, os.X_OK):164+ raise ControllerError("sigil_not_installed")165+ stdin = None166+ if stdin_value is not None:167+ try:168+ stdin = json.dumps(169+ stdin_value, separators=(",", ":"), ensure_ascii=True170+ ).encode("ascii")171+ except (TypeError, ValueError) as error:172+ raise ControllerError("invalid_request") from error173+ result = await self.run(174+ [self.sigil, *arguments], stdin=stdin, timeout=timeout175+ )176+ return strict_json(result.stdout, schema_version)177+178+ async def systemctl_command(179+ self, action: str, *, check: bool = True, timeout: float = 15.0180+ ) -> CommandResult:181+ if action not in {"start", "stop", "restart", "is-active", "show"}:182+ raise ControllerError("unsafe_command")183+ arguments = [self.systemctl, "--user", action, self.unit]184+ if action == "show":185+ arguments.extend(186+ [187+ "--property=LoadState",188+ "--property=ActiveState",189+ "--property=SubState",190+ "--property=UnitFileState",191+ "--no-pager",192+ ]193+ )194+ return await self.run(arguments, timeout=timeout, check=check)@@ -0,0 +1,13 @@
1+import { callable } from "@decky/api";2+import type { ConfigRequest, RpcEnvelope } from "./model.mjs";3+4+export const getSnapshot = callable<[], RpcEnvelope<unknown>>("get_snapshot");5+export const getConfig = callable<[], RpcEnvelope<unknown>>("get_config");6+export const validateConfig = callable<[request: ConfigRequest], RpcEnvelope<unknown>>("validate_config");7+export const applyConfig = callable<[request: ConfigRequest], RpcEnvelope<unknown>>("apply_config");8+export const restartService = callable<[], RpcEnvelope<unknown>>("restart_service");9+export const rollbackPending = callable<[transaction: string], RpcEnvelope<unknown>>("rollback_pending");10+export const resetEnrollment = callable<11+ [expectedHostFingerprint: string],12+ RpcEnvelope<unknown>13+>("reset_enrollment");@@ -0,0 +1,401 @@
1+import {2+ ButtonItem,3+ ConfirmModal,4+ DropdownItem,5+ PanelSection,6+ PanelSectionRow,7+ TextField,8+ showModal,9+ staticClasses,10+} from "@decky/ui";11+import { definePlugin, toaster } from "@decky/api";12+import { useCallback, useEffect, useMemo, useState } from "react";13+import type { CSSProperties } from "react";14+import { FaMagic } from "react-icons/fa";15+16+import {17+ applyConfig,18+ getConfig,19+ getSnapshot,20+ resetEnrollment,21+ restartService,22+ rollbackPending,23+ validateConfig,24+} from "./api";25+import {26+ buildConfigRequest,27+ isValidHostFingerprint,28+ normalizeConfig,29+ normalizeSnapshot,30+ transactionId,31+ unwrapRpc,32+} from "./model.mjs";33+import type { ConfigDraft, RpcEnvelope, SnapshotView } from "./model.mjs";34+35+const rowStyle: CSSProperties = {36+ display: "flex",37+ justifyContent: "space-between",38+ alignItems: "baseline",39+ width: "100%",40+ gap: "12px",41+};42+43+const secondaryStyle: CSSProperties = { opacity: 0.72, fontSize: "0.86em" };44+45+function errorMessage(error: unknown): string {46+ return error instanceof Error && error.message ? error.message : "Sigil management request failed";47+}48+49+function StatusRow({ label, value }: { label: string; value: string }) {50+ return (51+ <PanelSectionRow>52+ <div style={rowStyle}>53+ <span>{label}</span>54+ <span style={{ textAlign: "right" }}>{value}</span>55+ </div>56+ </PanelSectionRow>57+ );58+}59+60+function NumericField({61+ label,62+ value,63+ disabled,64+ onChange,65+}: {66+ label: string;67+ value: string;68+ disabled: boolean;69+ onChange(value: string): void;70+}) {71+ return (72+ <PanelSectionRow>73+ <TextField74+ label={label}75+ mustBeNumeric76+ disabled={disabled}77+ value={value}78+ onChange={(event) => onChange(event.currentTarget.value)}79+ />80+ </PanelSectionRow>81+ );82+}83+84+function Content() {85+ const [snapshot, setSnapshot] = useState<SnapshotView | null>(null);86+ const [revision, setRevision] = useState("");87+ const [draft, setDraft] = useState<ConfigDraft | null>(null);88+ const [configPending, setConfigPending] = useState<unknown>(null);89+ const [busy, setBusy] = useState<string | null>("Loading");90+ const [loadError, setLoadError] = useState<string | null>(null);91+92+ const refresh = useCallback(async () => {93+ setBusy("Refreshing");94+ setLoadError(null);95+ try {96+ const snapshotValue = unwrapRpc(await getSnapshot());97+ setSnapshot(normalizeSnapshot(snapshotValue));98+ const configValue = unwrapRpc(await getConfig());99+ const config = normalizeConfig(configValue);100+ setRevision(config.revision);101+ setDraft(config.draft);102+ setConfigPending(config.pendingTransaction);103+ } catch (error) {104+ setRevision("");105+ setDraft(null);106+ setConfigPending(null);107+ setLoadError(errorMessage(error));108+ } finally {109+ setBusy(null);110+ }111+ }, []);112+113+ useEffect(() => {114+ void refresh();115+ }, [refresh]);116+117+ const pendingId = useMemo(118+ () => transactionId(configPending) ?? transactionId(snapshot?.pendingTransaction),119+ [configPending, snapshot],120+ );121+ const controlsDisabled = busy !== null || draft === null || snapshot?.compatible === false;122+ const resetAllowed =123+ busy === null &&124+ snapshot?.compatible === true &&125+ isValidHostFingerprint(snapshot.hostFingerprint);126+127+ const run = useCallback(128+ async (label: string, action: () => Promise<RpcEnvelope<unknown>>, success: string) => {129+ setBusy(label);130+ try {131+ unwrapRpc(await action());132+ toaster.toast({ title: "Sigil", body: success });133+ await refresh();134+ } catch (error) {135+ const message = errorMessage(error);136+ setLoadError(message);137+ toaster.toast({ title: `${label} failed`, body: message });138+ } finally {139+ setBusy(null);140+ }141+ },142+ [refresh],143+ );144+145+ const apply = async () => {146+ if (!draft) return;147+ let request;148+ try {149+ request = buildConfigRequest(revision, draft);150+ } catch (error) {151+ const message = errorMessage(error);152+ setLoadError(message);153+ toaster.toast({ title: "Invalid configuration", body: message });154+ return;155+ }156+ setBusy("Applying configuration");157+ try {158+ unwrapRpc(await validateConfig(request));159+ unwrapRpc(await applyConfig(request));160+ toaster.toast({ title: "Sigil", body: "Configuration validated and applied" });161+ await refresh();162+ } catch (error) {163+ const message = errorMessage(error);164+ setLoadError(message);165+ toaster.toast({ title: "Configuration failed", body: message });166+ } finally {167+ setBusy(null);168+ }169+ };170+171+ const confirmRestart = (parent?: EventTarget) => {172+ showModal(173+ <ConfirmModal174+ strTitle="Restart Sigil?"175+ strDescription="The active Portal session will disconnect while the daemon restarts."176+ strOKButtonText="Restart"177+ strCancelButtonText="Cancel"178+ onOK={() => void run("Restart", restartService, "Daemon restarted")}179+ />,180+ parent,181+ );182+ };183+184+ const confirmApply = (parent?: EventTarget) => {185+ const description = snapshot?.sessionActive186+ ? "Applying this configuration will disconnect the active Portal session while Sigil validates and restarts."187+ : "Sigil will validate this configuration, restart to prove it, and roll back automatically if health checks fail.";188+ showModal(189+ <ConfirmModal190+ strTitle="Validate and apply configuration?"191+ strDescription={description}192+ strOKButtonText={snapshot?.sessionActive ? "Apply and disconnect" : "Validate and apply"}193+ strCancelButtonText="Cancel"194+ onOK={() => void apply()}195+ />,196+ parent,197+ );198+ };199+200+ const confirmEnrollmentReset = (parent?: EventTarget) => {201+ if (202+ !snapshot ||203+ snapshot.compatible !== true ||204+ !isValidHostFingerprint(snapshot.hostFingerprint)205+ ) {206+ return;207+ }208+ showModal(209+ <ConfirmModal210+ strTitle="Reset Portal pairing?"211+ strDescription={`Revoke the paired Portal and invalidate outstanding invitations for Sigil ${snapshot.hostFingerprint}. The host identity will not change.`}212+ strOKButtonText="Reset pairing"213+ strCancelButtonText="Cancel"214+ bDestructiveWarning215+ onOK={() =>216+ void run(217+ "Pairing reset",218+ () => resetEnrollment(snapshot.hostFingerprint),219+ "Portal pairing reset",220+ )221+ }222+ />,223+ parent,224+ );225+ };226+227+ const setDraftField = <K extends keyof ConfigDraft>(key: K, value: ConfigDraft[K]) => {228+ setDraft((current) => (current ? { ...current, [key]: value } : current));229+ };230+231+ return (232+ <>233+ <PanelSection title="Sigil">234+ <StatusRow label="Status" value={busy ?? snapshot?.summary ?? "Unavailable"} />235+ <StatusRow label="Service" value={snapshot?.serviceLabel ?? "Unknown"} />236+ <StatusRow label="Version" value={snapshot?.version ?? "Unknown"} />237+ <StatusRow label="Uptime" value={snapshot?.uptime ?? "Unavailable"} />238+ <StatusRow label="Host" value={snapshot?.hostFingerprint ?? "Unavailable"} />239+ {snapshot?.lastError && <StatusRow label="Last error" value={snapshot.lastError} />}240+ {(snapshot?.managementError || loadError) && (241+ <PanelSectionRow>242+ <div style={{ color: "#ff8f8f" }}>{snapshot?.managementError ?? loadError}</div>243+ </PanelSectionRow>244+ )}245+ <PanelSectionRow>246+ <ButtonItem layout="below" disabled={busy !== null} onClick={() => void refresh()}>247+ Refresh248+ </ButtonItem>249+ </PanelSectionRow>250+ </PanelSection>251+252+ <PanelSection title="Paired Portal">253+ <StatusRow label="Device" value={snapshot?.peerFingerprint ?? "None"} />254+ <StatusRow255+ label="Grants"256+ value={snapshot?.grants.length ? snapshot.grants.join(", ") : "None"}257+ />258+ <StatusRow label="Session" value={snapshot?.sessionActive ? "Active" : "Inactive"} />259+ <StatusRow label="Enrollment epoch" value={String(snapshot?.epoch ?? 0)} />260+ </PanelSection>261+262+ {draft && (263+ <PanelSection title="Stream configuration">264+ <DropdownItem265+ label="Resolution"266+ description="Native follows the Gamescope surface; fixed preserves the chosen aspect ratio."267+ rgOptions={[268+ { data: "native", label: "Native" },269+ { data: "fixed", label: "Fixed" },270+ ]}271+ selectedOption={draft.resolutionMode}272+ disabled={controlsDisabled}273+ onChange={(option) => setDraftField("resolutionMode", option.data)}274+ />275+ {draft.resolutionMode === "fixed" && (276+ <>277+ <NumericField278+ label="Width"279+ value={draft.width}280+ disabled={controlsDisabled}281+ onChange={(value) => setDraftField("width", value)}282+ />283+ <NumericField284+ label="Height"285+ value={draft.height}286+ disabled={controlsDisabled}287+ onChange={(value) => setDraftField("height", value)}288+ />289+ </>290+ )}291+ <NumericField292+ label="Maximum frame rate"293+ value={draft.framerate}294+ disabled={controlsDisabled}295+ onChange={(value) => setDraftField("framerate", value)}296+ />297+ {draft.rateMode === "unavailable" ? (298+ <StatusRow label="Rate control" value="Unavailable for this source" />299+ ) : (300+ <>301+ <DropdownItem302+ label="Rate control"303+ rgOptions={[304+ { data: "cbr", label: "CBR" },305+ { data: "cqp", label: "CQP" },306+ ]}307+ selectedOption={draft.rateMode}308+ disabled={controlsDisabled}309+ onChange={(option) => setDraftField("rateMode", option.data)}310+ />311+ {draft.rateMode === "cbr" ? (312+ <NumericField313+ label="Bitrate (kbit/s)"314+ value={draft.bitrateKbps}315+ disabled={controlsDisabled}316+ onChange={(value) => setDraftField("bitrateKbps", value)}317+ />318+ ) : (319+ <NumericField320+ label="Quantizer (1–51)"321+ value={draft.quantizer}322+ disabled={controlsDisabled}323+ onChange={(value) => setDraftField("quantizer", value)}324+ />325+ )}326+ </>327+ )}328+ <PanelSectionRow>329+ <ButtonItem330+ layout="below"331+ disabled={controlsDisabled}332+ onClick={(event) => confirmApply(event.currentTarget ?? undefined)}333+ >334+ Validate and apply335+ </ButtonItem>336+ </PanelSectionRow>337+ {pendingId && (338+ <PanelSectionRow>339+ <ButtonItem340+ layout="below"341+ disabled={busy !== null}342+ onClick={() =>343+ void run(344+ "Rollback",345+ () => rollbackPending(pendingId),346+ "Pending configuration rolled back",347+ )348+ }349+ >350+ Roll back pending change351+ </ButtonItem>352+ </PanelSectionRow>353+ )}354+ </PanelSection>355+ )}356+357+ <PanelSection title="Service">358+ <PanelSectionRow>359+ <ButtonItem360+ layout="below"361+ disabled={busy !== null || snapshot?.installed === false}362+ onClick={(event) => confirmRestart(event.currentTarget ?? undefined)}363+ >364+ Restart daemon365+ </ButtonItem>366+ </PanelSectionRow>367+ <PanelSectionRow>368+ <ButtonItem369+ layout="below"370+ disabled={!resetAllowed}371+ onClick={(event) => confirmEnrollmentReset(event.currentTarget ?? undefined)}372+ >373+ Reset Portal pairing…374+ </ButtonItem>375+ </PanelSectionRow>376+ </PanelSection>377+378+ <PanelSection title="Diagnostics and recovery">379+ <StatusRow380+ label="Stream diagnostics"381+ value={snapshot?.streamDiagnosticsAvailable ? "Available" : "Unavailable in this build"}382+ />383+ <StatusRow label="Identity factory reset" value="Unavailable" />384+ <PanelSectionRow>385+ <div style={secondaryStyle}>386+ Pairing reset preserves this Sigil’s identity. Factory reset is intentionally not exposed387+ until Sigil provides a separate guarded contract.388+ </div>389+ </PanelSectionRow>390+ </PanelSection>391+ </>392+ );393+}394+395+export default definePlugin(() => ({396+ name: "Sigil",397+ titleView: <div className={staticClasses.Title}>Sigil appliance</div>,398+ content: <Content />,399+ icon: <FaMagic />,400+ onDismount() {},401+}));@@ -0,0 +1,59 @@
1+export interface RpcEnvelope<T> {2+ ok: boolean;3+ value?: T;4+ error?: string;5+}6+7+export interface ConfigDraft {8+ resolutionMode: "native" | "fixed";9+ width: string;10+ height: string;11+ framerate: string;12+ rateMode: "cbr" | "cqp" | "unavailable";13+ bitrateKbps: string;14+ quantizer: string;15+}16+17+export interface ConfigRequest {18+ schema_version: 1;19+ expected_revision: string;20+ settings: {21+ resolution: { mode: "native" } | { mode: "fixed"; width: number; height: number };22+ framerate: number;23+ rate_control: null | { mode: "cbr"; bitrate_kbps: number } | { mode: "cqp"; quantizer: number };24+ };25+}26+27+export interface SnapshotView {28+ installed: boolean;29+ compatible: boolean;30+ serviceActive: boolean;31+ serviceState: string;32+ serviceLabel: string;33+ summary: string;34+ overall: string;35+ version: string;36+ hostFingerprint: string;37+ uptime: string;38+ sessionActive: boolean;39+ lastError: string | null;40+ managementError: string | null;41+ peerFingerprint: string | null;42+ grants: string[];43+ epoch: number;44+ pendingTransaction: unknown;45+ streamDiagnosticsAvailable: boolean;46+ factoryResetAvailable: boolean;47+}48+49+export function unwrapRpc<T>(envelope: RpcEnvelope<T>): T;50+export function formatUptime(milliseconds: unknown): string;51+export function normalizeSnapshot(rawValue: unknown): SnapshotView;52+export function normalizeConfig(rawValue: unknown): {53+ revision: string;54+ pendingTransaction: unknown;55+ draft: ConfigDraft;56+};57+export function buildConfigRequest(revision: string, draft: ConfigDraft): ConfigRequest;58+export function transactionId(pending: unknown): string | null;59+export function isValidHostFingerprint(fingerprint: unknown): fingerprint is string;@@ -0,0 +1,178 @@
1+const OVERALL_STATES = new Set(["ready", "active", "degraded", "unavailable"]);2+const GRANT_LABELS = {3+ view: "View",4+ pointer_keyboard: "Pointer + keyboard",5+ gamepad: "Gamepad",6+};7+8+function record(value) {9+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};10+}11+12+function text(value, fallback = "") {13+ return typeof value === "string" && value.length > 0 ? value : fallback;14+}15+16+function boolean(value) {17+ return value === true;18+}19+20+function integer(value, fallback = 0) {21+ return Number.isSafeInteger(value) ? value : fallback;22+}23+24+export function unwrapRpc(envelope) {25+ const response = record(envelope);26+ if (response.ok === true && Object.hasOwn(response, "value")) {27+ return response.value;28+ }29+ const message = text(response.error, "Sigil management request failed");30+ throw new Error(message);31+}32+33+export function formatUptime(milliseconds) {34+ if (!Number.isFinite(milliseconds) || milliseconds < 0) return "Unavailable";35+ const totalMinutes = Math.floor(milliseconds / 60_000);36+ const days = Math.floor(totalMinutes / 1_440);37+ const hours = Math.floor((totalMinutes % 1_440) / 60);38+ const minutes = totalMinutes % 60;39+ if (days > 0) return `${days}d ${hours}h`;40+ if (hours > 0) return `${hours}h ${minutes}m`;41+ return `${minutes}m`;42+}43+44+export function normalizeSnapshot(rawValue) {45+ const root = record(rawValue);46+ const service = record(root.service);47+ const appliance = record(root.appliance ?? root.status ?? root.sigil ?? rawValue);48+ const identity = record(appliance.identity);49+ const enrollment = record(appliance.enrollment);50+ const runtime = record(appliance.runtime);51+ const capabilities = record(root.capabilities);52+53+ const installed = root.installed === false ? false : service.installed !== false;54+ const compatible = root.compatible !== false;55+ const serviceState = text(56+ service.state ?? service.active_state,57+ boolean(service.active) ? "active" : "unknown",58+ );59+ const serviceActive = boolean(service.active) || serviceState === "active";60+ const serviceSubState = text(service.sub_state);61+ const overallCandidate = text(appliance.overall, "unavailable");62+ const overall = OVERALL_STATES.has(overallCandidate) ? overallCandidate : "unavailable";63+ const peerFingerprint = text(enrollment.peer_fingerprint) || null;64+ const grants = Array.isArray(enrollment.grants)65+ ? enrollment.grants66+ .filter((grant) => typeof grant === "string")67+ .map((grant) => GRANT_LABELS[grant] ?? grant)68+ : [];69+70+ let summary = "Unavailable";71+ if (!installed) summary = "Not installed";72+ else if (!compatible) summary = "Upgrade required";73+ else if (overall === "active") summary = "Streaming";74+ else if (overall === "ready" && serviceActive) summary = "Ready";75+ else if (overall === "degraded") summary = "Degraded";76+ else if (serviceActive) summary = "Starting";77+ else if (serviceState === "failed") summary = "Failed";78+ else summary = "Stopped";79+80+ const lastError = record(runtime.last_error);81+ return {82+ installed,83+ compatible,84+ serviceActive,85+ serviceState,86+ serviceLabel: serviceSubState ? `${serviceState} (${serviceSubState})` : serviceState,87+ summary,88+ overall,89+ version: text(appliance.sigil_version, "Unknown"),90+ hostFingerprint: text(identity.host_fingerprint, "Unavailable"),91+ uptime: formatUptime(runtime.uptime_ms),92+ sessionActive: runtime.session === "active",93+ lastError: text(lastError.code) || null,94+ managementError: text(root.error) || null,95+ peerFingerprint,96+ grants,97+ epoch: integer(enrollment.epoch),98+ pendingTransaction: record(appliance.config).pending_transaction ?? null,99+ streamDiagnosticsAvailable: capabilities.stream_diagnostics === true,100+ factoryResetAvailable: capabilities.factory_reset === true,101+ };102+}103+104+export function normalizeConfig(rawValue) {105+ const value = record(rawValue);106+ const settings = record(value.settings);107+ const resolution = record(settings.resolution);108+ const rateControl = settings.rate_control === null ? null : record(settings.rate_control);109+ const resolutionMode = resolution.mode === "fixed" ? "fixed" : "native";110+ const rateMode = rateControl === null ? "unavailable" : rateControl.mode === "cqp" ? "cqp" : "cbr";111+ return {112+ revision: text(value.revision),113+ pendingTransaction: value.pending_transaction ?? null,114+ draft: {115+ resolutionMode,116+ width: String(integer(resolution.width, 1280)),117+ height: String(integer(resolution.height, 800)),118+ framerate: String(integer(settings.framerate, 60)),119+ rateMode,120+ bitrateKbps: String(integer(rateControl?.bitrate_kbps, 12_000)),121+ quantizer: String(integer(rateControl?.quantizer, 24)),122+ },123+ };124+}125+126+function boundedInteger(value, label, minimum, maximum, requireEven = false) {127+ if (!/^[0-9]+$/.test(value)) throw new Error(`${label} must be a whole number`);128+ const parsed = Number(value);129+ if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {130+ throw new Error(`${label} must be between ${minimum} and ${maximum}`);131+ }132+ if (requireEven && parsed % 2 !== 0) throw new Error(`${label} must be even`);133+ return parsed;134+}135+136+export function buildConfigRequest(revision, draft) {137+ if (!/^sha256:[0-9a-f]{64}$/.test(revision)) {138+ throw new Error("Reload configuration before applying changes");139+ }140+ const framerate = boundedInteger(draft.framerate, "Frame rate", 1, 240);141+ const resolution =142+ draft.resolutionMode === "native"143+ ? { mode: "native" }144+ : {145+ mode: "fixed",146+ width: boundedInteger(draft.width, "Width", 64, 7680, true),147+ height: boundedInteger(draft.height, "Height", 64, 4320, true),148+ };149+150+ let rateControl = null;151+ if (draft.rateMode === "cbr") {152+ rateControl = {153+ mode: "cbr",154+ bitrate_kbps: boundedInteger(draft.bitrateKbps, "Bitrate", 1000, 100000),155+ };156+ } else if (draft.rateMode === "cqp") {157+ rateControl = {158+ mode: "cqp",159+ quantizer: boundedInteger(draft.quantizer, "Quantizer", 1, 51),160+ };161+ } else if (draft.rateMode !== "unavailable") {162+ throw new Error("Choose CBR or CQP rate control");163+ }164+165+ return {166+ schema_version: 1,167+ expected_revision: revision,168+ settings: { resolution, framerate, rate_control: rateControl },169+ };170+}171+172+export function transactionId(pending) {173+ return text(record(pending).transaction) || null;174+}175+176+export function isValidHostFingerprint(fingerprint) {177+ return typeof fingerprint === "string" && /^[0-9a-f]{8}…[0-9a-f]{8}$/.test(fingerprint);178+}@@ -0,0 +1,377 @@
1+from __future__ import annotations2+3+import asyncio4+import tempfile5+import unittest6+from pathlib import Path7+from typing import Any8+from unittest import mock9+10+from goq_sigil.controller import Controller11+from goq_sigil.errors import ControllerError12+from goq_sigil.runner import CommandResult, Runner, strict_json13+14+15+REVISION_A = "sha256:" + "a" * 6416+REVISION_B = "sha256:" + "b" * 6417+TRANSACTION = "1" * 3218+FINGERPRINT = "12345678…90abcdef"19+20+21+def request() -> dict[str, Any]:22+ return {23+ "schema_version": 1,24+ "expected_revision": REVISION_A,25+ "settings": {26+ "resolution": {"mode": "native"},27+ "framerate": 60,28+ "rate_control": {"mode": "cbr", "bitrate_kbps": 12000},29+ },30+ }31+32+33+class FakeRunner:34+ def __init__(self, root: Path):35+ self.home = root36+ self.sigil = root / "sigil"37+ self.sigil.write_text("fixture", encoding="utf-8")38+ self.config = root / "host.toml"39+ self.systemctl = Path("/usr/bin/systemctl")40+ self.unit = "sigil-host.service"41+ self.active = True42+ self.revision = REVISION_A43+ self.instance = "2" * 3244+ self.next_instance = 245+ self.calls: list[tuple[str, Any]] = []46+ self.fail_ready = False47+ self.keep_stale_instance = False48+ self.fail_start_number: int | None = None49+ self.start_count = 050+ self.pending: dict[str, Any] | None = None51+ self.fail_rollback = False52+ self.lose_commit_response = False53+54+ async def systemctl_command(55+ self, action: str, *, check: bool = True, timeout: float = 15.056+ ) -> CommandResult:57+ self.calls.append(("systemctl", action))58+ if action == "show":59+ state = "active" if self.active else "inactive"60+ output = (61+ "LoadState=loaded\n"62+ f"ActiveState={state}\n"63+ f"SubState={'running' if self.active else 'dead'}\n"64+ "UnitFileState=enabled\n"65+ ).encode()66+ return CommandResult(0, output, b"")67+ if action in {"start", "restart"}:68+ self.start_count += 169+ if self.fail_start_number == self.start_count:70+ raise ControllerError("command_failed")71+ self.active = True72+ if not self.keep_stale_instance:73+ self.next_instance += 174+ self.instance = f"{self.next_instance:032x}"75+ elif action == "stop":76+ self.active = False77+ return CommandResult(0, b"", b"")78+79+ async def sigil_json(80+ self,81+ arguments: list[str],82+ *,83+ stdin_value: dict[str, Any] | None = None,84+ schema_version: int = 1,85+ timeout: float = 10.0,86+ ) -> dict[str, Any]:87+ operation = tuple(arguments[:3])88+ self.calls.append(("sigil", operation))89+ if operation == ("appliance", "status", "--config"):90+ ready = self.active and not (91+ self.fail_ready and self.revision == REVISION_B92+ )93+ return {94+ "schema_version": 2,95+ "sigil_version": "0.1.0",96+ "overall": "ready" if ready else "degraded",97+ "identity": {"host_fingerprint": FINGERPRINT},98+ "enrollment": {"state": "none", "grants": [], "epoch": 0},99+ "runtime": {100+ "state": "fresh",101+ "daemon": "ready" if ready else "degraded",102+ "session": "inactive",103+ "instance_id": self.instance,104+ "loaded_config_revision": self.revision,105+ },106+ "config": {"revision": self.revision, "pending_transaction": None},107+ }108+ if operation == ("appliance", "config", "show"):109+ return {110+ "schema_version": 1,111+ "revision": self.revision,112+ "settings": request()["settings"],113+ "pending_transaction": self.pending,114+ }115+ if operation == ("appliance", "config", "validate"):116+ return {117+ "schema_version": 1,118+ "base_revision": REVISION_A,119+ "candidate_revision": REVISION_B,120+ "changed": True,121+ "settings": stdin_value["settings"],122+ }123+ if operation == ("appliance", "config", "set"):124+ self.revision = REVISION_B125+ self.pending = {126+ "transaction": TRANSACTION,127+ "base_revision": REVISION_A,128+ "candidate_revision": REVISION_B,129+ "state": "pending_validation",130+ }131+ return {132+ "schema_version": 1,133+ "transaction": TRANSACTION,134+ "candidate_revision": REVISION_B,135+ "changed": True,136+ "restart_required": True,137+ }138+ if operation == ("appliance", "config", "commit"):139+ self.pending = None140+ if self.lose_commit_response:141+ raise ControllerError("command_timeout")142+ return {143+ "schema_version": 1,144+ "operation": "config_commit",145+ "transaction": TRANSACTION,146+ "revision": REVISION_B,147+ }148+ if operation == ("appliance", "config", "rollback"):149+ if self.fail_rollback:150+ raise ControllerError("command_failed")151+ self.revision = REVISION_A152+ self.pending = None153+ return {154+ "schema_version": 1,155+ "operation": "config_rollback",156+ "transaction": TRANSACTION,157+ "restored_revision": REVISION_A,158+ }159+ if operation == ("appliance", "enrollment-reset", "--config"):160+ return {161+ "schema_version": 1,162+ "operation": "enrollment_reset",163+ "host_fingerprint": FINGERPRINT,164+ "had_enrollment": True,165+ "previous_epoch": 4,166+ "current_epoch": 5,167+ "invitations_invalidated": True,168+ }169+ raise AssertionError(arguments)170+171+172+class FastController(Controller):173+ async def _wait_ready(self, **kwargs):174+ if self.runner.fail_ready and self.runner.revision == REVISION_B:175+ raise ControllerError("service_ready_timeout")176+ if (177+ self.runner.keep_stale_instance178+ and kwargs.get("previous_instance") == self.runner.instance179+ ):180+ raise ControllerError("service_ready_timeout")181+ return await super()._wait_ready(**kwargs)182+183+184+class ControllerTests(unittest.IsolatedAsyncioTestCase):185+ def setUp(self):186+ self.temporary = tempfile.TemporaryDirectory()187+ self.runner = FakeRunner(Path(self.temporary.name))188+ self.controller = FastController(self.runner)189+190+ def tearDown(self):191+ self.temporary.cleanup()192+193+ async def test_snapshot_combines_fixed_service_and_redacted_appliance_contract(self):194+ snapshot = await self.controller.get_snapshot()195+ self.assertTrue(snapshot["installed"])196+ self.assertTrue(snapshot["service"]["active"])197+ self.assertEqual(198+ snapshot["appliance"]["identity"]["host_fingerprint"], FINGERPRINT199+ )200+ self.assertFalse(snapshot["capabilities"]["factory_reset"])201+202+ async def test_apply_uses_health_bound_commit_and_restores_active_service(self):203+ result = await self.controller.apply_config(request())204+ self.assertTrue(result["changed"])205+ self.assertEqual(result["revision"], REVISION_B)206+ significant = [207+ value208+ for kind, value in self.runner.calls209+ if kind == "systemctl" and value != "show"210+ ]211+ self.assertEqual(significant, ["stop", "start", "stop", "start"])212+ sigil_operations = [213+ value for kind, value in self.runner.calls if kind == "sigil"214+ ]215+ self.assertIn(("appliance", "config", "validate"), sigil_operations)216+ self.assertIn(("appliance", "config", "set"), sigil_operations)217+ self.assertIn(("appliance", "config", "commit"), sigil_operations)218+ self.assertNotIn(("appliance", "config", "rollback"), sigil_operations)219+220+ async def test_candidate_health_failure_rolls_back_and_restores_service(self):221+ self.runner.fail_ready = True222+ with self.assertRaisesRegex(ControllerError, "apply_rolled_back"):223+ await self.controller.apply_config(request())224+ self.assertEqual(self.runner.revision, REVISION_A)225+ self.assertTrue(self.runner.active)226+ self.assertIn(227+ ("sigil", ("appliance", "config", "rollback")), self.runner.calls228+ )229+230+ async def test_stale_snapshot_cannot_prove_restart(self):231+ self.runner.keep_stale_instance = True232+ with self.assertRaisesRegex(ControllerError, "service_ready_timeout"):233+ await self.controller.restart_service()234+235+ async def test_restart_from_proven_absent_runtime_accepts_first_instance(self):236+ self.runner.instance = None237+ self.runner.active = False238+ result = await self.controller.restart_service()239+ self.assertEqual(result["operation"], "restart")240+ self.assertTrue(self.runner.active)241+242+ async def test_lost_commit_response_is_recovered_without_rollback(self):243+ self.runner.lose_commit_response = True244+ result = await self.controller.apply_config(request())245+ self.assertTrue(result["commit_response_recovered"])246+ self.assertEqual(result["revision"], REVISION_B)247+ self.assertTrue(self.runner.active)248+ self.assertNotIn(249+ ("sigil", ("appliance", "config", "rollback")), self.runner.calls250+ )251+252+ async def test_post_commit_start_failure_does_not_attempt_impossible_rollback(self):253+ self.runner.fail_start_number = 2254+ with self.assertRaisesRegex(ControllerError, "post_commit_service_unhealthy"):255+ await self.controller.apply_config(request())256+ self.assertEqual(self.runner.revision, REVISION_B)257+ self.assertNotIn(258+ ("sigil", ("appliance", "config", "rollback")), self.runner.calls259+ )260+ self.assertTrue(self.runner.active)261+262+ async def test_failed_explicit_rollback_leaves_unproven_candidate_stopped(self):263+ self.runner.revision = REVISION_B264+ self.runner.pending = {265+ "transaction": TRANSACTION,266+ "base_revision": REVISION_A,267+ "candidate_revision": REVISION_B,268+ "state": "pending_validation",269+ }270+ self.runner.fail_rollback = True271+ with self.assertRaisesRegex(ControllerError, "rollback_failed_safe"):272+ await self.controller.rollback_pending(TRANSACTION)273+ self.assertFalse(self.runner.active)274+275+ async def test_reset_stops_before_mutation_and_restores_prior_state(self):276+ result = await self.controller.reset_enrollment(FINGERPRINT)277+ self.assertEqual(result["current_epoch"], 5)278+ stop = self.runner.calls.index(("systemctl", "stop"))279+ reset = self.runner.calls.index(280+ ("sigil", ("appliance", "enrollment-reset", "--config"))281+ )282+ start = self.runner.calls.index(("systemctl", "start"))283+ self.assertLess(stop, reset)284+ self.assertLess(reset, start)285+286+ async def test_mutations_are_serialized(self):287+ entered = asyncio.Event()288+ release = asyncio.Event()289+290+ async def hold_restart():291+ async with self.controller._mutation_lock:292+ entered.set()293+ await release.wait()294+295+ first = asyncio.create_task(hold_restart())296+ await entered.wait()297+ second = asyncio.create_task(self.controller.reset_enrollment(FINGERPRINT))298+ await asyncio.sleep(0)299+ self.assertFalse(second.done())300+ release.set()301+ await first302+ await second303+304+ async def test_invalid_frontend_shapes_never_reach_sigil(self):305+ invalid = request()306+ invalid["settings"]["resolution"] = {307+ "mode": "fixed",308+ "width": 1279,309+ "height": 800,310+ }311+ with self.assertRaisesRegex(ControllerError, "invalid_request"):312+ await self.controller.validate_config(invalid)313+314+315+class JsonTests(unittest.TestCase):316+ def test_duplicate_keys_are_rejected(self):317+ with self.assertRaisesRegex(ControllerError, "invalid_response"):318+ strict_json(b'{"schema_version":1,"schema_version":1}', 1)319+320+ def test_wrong_schema_is_rejected(self):321+ with self.assertRaisesRegex(ControllerError, "incompatible_sigil"):322+ strict_json(b'{"schema_version":2}', 1)323+324+325+class HangingStream:326+ async def read(self, _size):327+ await asyncio.Future()328+329+330+class FakeProcess:331+ def __init__(self):332+ self.returncode = None333+ self.stdin = None334+ self.stdout = HangingStream()335+ self.stderr = HangingStream()336+ self.terminated = False337+ self._done = asyncio.Event()338+339+ def terminate(self):340+ self.terminated = True341+ self.returncode = -15342+ self._done.set()343+344+ def kill(self):345+ self.returncode = -9346+ self._done.set()347+348+ async def wait(self):349+ await self._done.wait()350+ return self.returncode351+352+353+class TestRunner(Runner):354+ def environment(self):355+ return {}356+357+358+class RunnerTests(unittest.IsolatedAsyncioTestCase):359+ async def test_cancellation_terminates_and_reaps_child(self):360+ process = FakeProcess()361+362+ async def create(*_args, **_kwargs):363+ return process364+365+ runner = TestRunner("/fixture", uid=1000)366+ with mock.patch("asyncio.create_subprocess_exec", create):367+ task = asyncio.create_task(runner.run(["/fixed-command"], timeout=60))368+ await asyncio.sleep(0)369+ task.cancel()370+ with self.assertRaises(asyncio.CancelledError):371+ await task372+ self.assertTrue(process.terminated)373+ self.assertIsNotNone(process.returncode)374+375+376+if __name__ == "__main__":377+ unittest.main()@@ -0,0 +1,121 @@
1+import assert from "node:assert/strict";2+import test from "node:test";3+4+import {5+ buildConfigRequest,6+ formatUptime,7+ isValidHostFingerprint,8+ normalizeConfig,9+ normalizeSnapshot,10+ transactionId,11+ unwrapRpc,12+} from "../src/model.mjs";13+14+const revision = `sha256:${"a".repeat(64)}`;15+16+test("unwrapRpc accepts only a successful envelope", () => {17+ assert.deepEqual(unwrapRpc({ ok: true, value: { ready: true } }), { ready: true });18+ assert.throws(() => unwrapRpc({ ok: false, error: "service timeout" }), /service timeout/);19+ assert.throws(() => unwrapRpc({ ok: true }), /request failed/);20+});21+22+test("normalizes the backend snapshot without exposing endpoint identity", () => {23+ const snapshot = normalizeSnapshot({24+ schema_version: 1,25+ installed: true,26+ compatible: true,27+ service: { installed: true, active: true, active_state: "active", sub_state: "running" },28+ capabilities: { stream_diagnostics: false, factory_reset: false },29+ appliance: {30+ sigil_version: "0.4.0",31+ overall: "active",32+ identity: { host_fingerprint: "12345678…90abcdef" },33+ enrollment: {34+ peer_fingerprint: "abcdef12…34567890",35+ grants: ["view", "pointer_keyboard", "gamepad"],36+ epoch: 7,37+ },38+ config: { pending_transaction: { transaction: "tx-1" } },39+ runtime: { uptime_ms: 3_720_000, session: "active", last_error: null },40+ },41+ });42+43+ assert.equal(snapshot.summary, "Streaming");44+ assert.equal(snapshot.serviceLabel, "active (running)");45+ assert.equal(snapshot.uptime, "1h 2m");46+ assert.equal(snapshot.peerFingerprint, "abcdef12…34567890");47+ assert.deepEqual(snapshot.grants, ["View", "Pointer + keyboard", "Gamepad"]);48+ assert.equal(snapshot.streamDiagnosticsAvailable, false);49+ assert.equal(snapshot.factoryResetAvailable, false);50+ assert.equal(transactionId(snapshot.pendingTransaction), "tx-1");51+});52+53+test("normalizes native CBR configuration and builds its strict request", () => {54+ const config = normalizeConfig({55+ schema_version: 1,56+ revision,57+ settings: {58+ resolution: { mode: "native" },59+ framerate: 60,60+ rate_control: { mode: "cbr", bitrate_kbps: 12000 },61+ },62+ pending_transaction: null,63+ });64+65+ assert.equal(config.draft.resolutionMode, "native");66+ assert.equal(config.draft.rateMode, "cbr");67+ assert.deepEqual(buildConfigRequest(config.revision, config.draft), {68+ schema_version: 1,69+ expected_revision: revision,70+ settings: {71+ resolution: { mode: "native" },72+ framerate: 60,73+ rate_control: { mode: "cbr", bitrate_kbps: 12000 },74+ },75+ });76+});77+78+test("builds fixed CQP configuration without pinning a product resolution", () => {79+ const request = buildConfigRequest(revision, {80+ resolutionMode: "fixed",81+ width: "2560",82+ height: "1600",83+ framerate: "144",84+ rateMode: "cqp",85+ bitrateKbps: "12000",86+ quantizer: "24",87+ });88+ assert.deepEqual(request.settings.resolution, { mode: "fixed", width: 2560, height: 1600 });89+ assert.deepEqual(request.settings.rate_control, { mode: "cqp", quantizer: 24 });90+});91+92+test("rejects unsafe or invalid local edits before backend validation", () => {93+ const base = {94+ resolutionMode: "fixed",95+ width: "1281",96+ height: "800",97+ framerate: "60",98+ rateMode: "cbr",99+ bitrateKbps: "12000",100+ quantizer: "24",101+ };102+ assert.throws(() => buildConfigRequest(revision, base), /Width must be even/);103+ assert.throws(() => buildConfigRequest("not-a-revision", { ...base, width: "1280" }), /Reload/);104+ assert.throws(105+ () => buildConfigRequest(revision, { ...base, width: "1280", framerate: "241" }),106+ /Frame rate must be between 1 and 240/,107+ );108+});109+110+test("formats short and absent uptime safely", () => {111+ assert.equal(formatUptime(59_999), "0m");112+ assert.equal(formatUptime(undefined), "Unavailable");113+});114+115+test("accepts only the redacted lowercase host fingerprint interlock", () => {116+ assert.equal(isValidHostFingerprint("12345678…90abcdef"), true);117+ assert.equal(isValidHostFingerprint("12345678...90abcdef"), false);118+ assert.equal(isValidHostFingerprint("12345678…90abcdeF"), false);119+ assert.equal(isValidHostFingerprint("Unavailable"), false);120+ assert.equal(isValidHostFingerprint(undefined), false);121+});@@ -0,0 +1,21 @@
1+{2+ "compilerOptions": {3+ "outDir": "dist",4+ "module": "ESNext",5+ "target": "ES2020",6+ "jsx": "react-jsx",7+ "declaration": false,8+ "moduleResolution": "node",9+ "noUnusedLocals": true,10+ "noUnusedParameters": true,11+ "esModuleInterop": true,12+ "noImplicitReturns": true,13+ "noImplicitThis": true,14+ "noImplicitAny": true,15+ "strict": true,16+ "skipLibCheck": true,17+ "allowSyntheticDefaultImports": true18+ },19+ "include": ["src"],20+ "exclude": ["node_modules"]21+}@@ -0,0 +1,185 @@
1+#!/usr/bin/env python32+"""Build a deterministic, provenance-bound Decky plugin archive."""3+4+from __future__ import annotations5+6+import hashlib7+import json8+import os9+import pathlib10+import shutil11+import stat12+import subprocess13+import sys14+import zipfile15+from typing import NoReturn16+17+18+REPOSITORY = "https://github.com/FelineStateMachine/goq"19+PLUGIN_DIRECTORY = "goq-sigil"20+FIXED_FILES = (21+ "LICENSE",22+ "README.md",23+ "compatibility.json",24+ "dist/index.js",25+ "main.py",26+ "package.json",27+ "plugin.json",28+)29+30+31+def fail(message: str) -> NoReturn:32+ raise SystemExit(message)33+34+35+def read_json(path: pathlib.Path) -> dict:36+ try:37+ value = json.loads(path.read_text(encoding="utf-8"))38+ except (OSError, UnicodeError, json.JSONDecodeError) as error:39+ fail(f"invalid JSON metadata: {path.name}: {error}")40+ if not isinstance(value, dict):41+ fail(f"JSON metadata must be an object: {path.name}")42+ return value43+44+45+def source_commit(repository: pathlib.Path) -> str:46+ override = os.environ.get("GOQ_SOURCE_COMMIT")47+ if override is not None:48+ if os.environ.get("GOQ_PACKAGE_TESTING") != "1":49+ fail("GOQ_SOURCE_COMMIT is reserved for package tests")50+ commit = override51+ else:52+ try:53+ commit = subprocess.run(54+ ["git", "rev-parse", "HEAD"],55+ cwd=repository,56+ check=True,57+ stdout=subprocess.PIPE,58+ stderr=subprocess.DEVNULL,59+ text=True,60+ timeout=5,61+ ).stdout.strip()62+ except (OSError, subprocess.SubprocessError) as error:63+ fail(f"unable to resolve source commit: {error}")64+ if len(commit) != 40 or any(character not in "0123456789abcdef" for character in commit):65+ fail("source commit must be 40 lowercase hexadecimal characters")66+ return commit67+68+69+def prepare_frontend(repository: pathlib.Path, plugin: pathlib.Path) -> None:70+ if os.environ.get("GOQ_PACKAGE_TESTING") == "1":71+ return72+ status = subprocess.run(73+ ["git", "status", "--porcelain", "--untracked-files=all", "--", "decky"],74+ cwd=repository,75+ check=True,76+ stdout=subprocess.PIPE,77+ stderr=subprocess.PIPE,78+ text=True,79+ timeout=5,80+ ).stdout81+ if status:82+ fail("Decky package inputs must match the source commit exactly")83+ pnpm = shutil.which("pnpm")84+ if pnpm is None:85+ fail("pnpm 9.4.0 is required to build the Decky package")86+ version = subprocess.run(87+ [pnpm, "--version"],88+ check=True,89+ stdout=subprocess.PIPE,90+ stderr=subprocess.PIPE,91+ text=True,92+ timeout=5,93+ ).stdout.strip()94+ if version != "9.4.0":95+ fail("pnpm 9.4.0 is required to build the Decky package")96+ subprocess.run(97+ [pnpm, "run", "build"],98+ cwd=plugin,99+ check=True,100+ stdin=subprocess.DEVNULL,101+ timeout=120,102+ )103+104+105+def collect_files(plugin: pathlib.Path) -> list[pathlib.Path]:106+ files = [plugin / relative for relative in FIXED_FILES]107+ module_root = plugin / "py_modules/goq_sigil"108+ files.extend(sorted(module_root.glob("*.py")))109+ for path in files:110+ try:111+ metadata = path.lstat()112+ except OSError as error:113+ fail(f"required plugin file is missing: {path.relative_to(plugin)}: {error}")114+ if not stat.S_ISREG(metadata.st_mode) or path.is_symlink():115+ fail(f"plugin input must be a regular non-symlink: {path.relative_to(plugin)}")116+ return files117+118+119+def main() -> int:120+ repository = pathlib.Path(__file__).resolve().parent.parent121+ plugin = repository / "decky"122+ package = read_json(plugin / "package.json")123+ compatibility = read_json(plugin / "compatibility.json")124+ version = package.get("version")125+ if not isinstance(version, str) or compatibility.get("plugin_version") != version:126+ fail("package and compatibility plugin versions must match")127+ if package.get("name") != PLUGIN_DIRECTORY:128+ fail("unexpected Decky package name")129+ prepare_frontend(repository, plugin)130+131+ output_root = pathlib.Path(sys.argv[1]) if len(sys.argv) == 2 else repository / "artifacts"132+ if len(sys.argv) > 2:133+ fail("usage: package-decky-plugin.py [output-directory]")134+ output_root.mkdir(parents=True, exist_ok=True)135+ output = output_root / f"{PLUGIN_DIRECTORY}-v{version}.zip"136+ commit = source_commit(repository)137+ files = collect_files(plugin)138+ entries: list[tuple[str, bytes]] = []139+ content_digests: dict[str, str] = {}140+ for path in files:141+ relative = path.relative_to(plugin).as_posix()142+ contents = path.read_bytes()143+ entries.append((f"{PLUGIN_DIRECTORY}/{relative}", contents))144+ content_digests[relative] = hashlib.sha256(contents).hexdigest()145+ provenance = json.dumps(146+ {147+ "schema_version": 1,148+ "repository": REPOSITORY,149+ "source_commit": commit,150+ "package_inputs_clean": True,151+ "plugin_version": version,152+ "compatibility": compatibility["sigil"],153+ "files": content_digests,154+ },155+ sort_keys=True,156+ separators=(",", ":"),157+ ).encode("utf-8") + b"\n"158+159+ entries.append((f"{PLUGIN_DIRECTORY}/provenance.json", provenance))160+ entries.sort(key=lambda entry: entry[0])161+162+ temporary = output.with_suffix(".zip.tmp")163+ try:164+ with zipfile.ZipFile(165+ temporary, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9166+ ) as archive:167+ for name, contents in entries:168+ info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0))169+ info.compress_type = zipfile.ZIP_DEFLATED170+ info.create_system = 3171+ info.external_attr = 0o100644 << 16172+ archive.writestr(info, contents)173+ os.replace(temporary, output)174+ finally:175+ temporary.unlink(missing_ok=True)176+177+ digest = hashlib.sha256(output.read_bytes()).hexdigest()178+ print(f"archive={output}")179+ print(f"sha256={digest}")180+ print(f"source_commit={commit}")181+ return 0182+183+184+if __name__ == "__main__":185+ raise SystemExit(main())@@ -0,0 +1,67 @@
1+from __future__ import annotations2+3+import hashlib4+import json5+import os6+import pathlib7+import subprocess8+import sys9+import tempfile10+import unittest11+import zipfile12+13+14+REPOSITORY = pathlib.Path(__file__).resolve().parents[2]15+PACKAGER = REPOSITORY / "scripts/package-decky-plugin.py"16+COMMIT = "a" * 4017+18+19+class DeckyPackageTests(unittest.TestCase):20+ def build(self, output: pathlib.Path) -> pathlib.Path:21+ environment = dict(os.environ)22+ environment["GOQ_SOURCE_COMMIT"] = COMMIT23+ environment["GOQ_PACKAGE_TESTING"] = "1"24+ subprocess.run(25+ [sys.executable, str(PACKAGER), str(output)],26+ cwd=REPOSITORY,27+ env=environment,28+ check=True,29+ stdout=subprocess.PIPE,30+ stderr=subprocess.PIPE,31+ timeout=10,32+ )33+ return output / "goq-sigil-v0.1.0.zip"34+35+ def test_archive_is_deterministic_bounded_and_provenance_bound(self):36+ with tempfile.TemporaryDirectory() as first, tempfile.TemporaryDirectory() as second:37+ first_archive = self.build(pathlib.Path(first))38+ second_archive = self.build(pathlib.Path(second))39+ self.assertEqual(40+ hashlib.sha256(first_archive.read_bytes()).digest(),41+ hashlib.sha256(second_archive.read_bytes()).digest(),42+ )43+ with zipfile.ZipFile(first_archive) as archive:44+ names = archive.namelist()45+ self.assertEqual(names, sorted(names))46+ self.assertIn("goq-sigil/dist/index.js", names)47+ self.assertIn("goq-sigil/main.py", names)48+ self.assertFalse(any("tests/" in name for name in names))49+ self.assertFalse(any(name.endswith((".toml", ".key")) for name in names))50+ provenance = json.loads(51+ archive.read("goq-sigil/provenance.json").decode("utf-8")52+ )53+ self.assertEqual(provenance["source_commit"], COMMIT)54+ self.assertEqual(55+ provenance["compatibility"]["compatibility_basis"], "schema_probe"56+ )57+ self.assertEqual(provenance["compatibility"]["appliance_status_schema"], 2)58+ self.assertTrue(provenance["package_inputs_clean"])59+ self.assertNotIn("source_tree_clean", provenance)60+ self.assertEqual(61+ provenance["files"]["dist/index.js"],62+ hashlib.sha256(archive.read("goq-sigil/dist/index.js")).hexdigest(),63+ )64+65+66+if __name__ == "__main__":67+ unittest.main()Binary file.