From a014ce555a26c847d324c1facd0096f6bb95a905 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Thu, 22 Jan 2026 15:12:32 +0000 Subject: [PATCH] feat(server): auto-update UI via remote manifest --- packages/server/package-lock.json | 770 ++++++++++++++++++ packages/server/package.json | 2 + packages/server/src/api-types.ts | 20 +- packages/server/src/events/bus.ts | 2 - packages/server/src/index.ts | 66 +- packages/server/src/ui/remote-ui.ts | 535 ++++++++++++ .../src/components/folder-selection-view.tsx | 4 + packages/ui/src/components/version-pill.tsx | 38 + packages/ui/src/stores/releases.ts | 60 +- 9 files changed, 1441 insertions(+), 56 deletions(-) create mode 100644 packages/server/src/ui/remote-ui.ts create mode 100644 packages/ui/src/components/version-pill.tsx diff --git a/packages/server/package-lock.json b/packages/server/package-lock.json index b7b5b2b0..e7e9d36b 100644 --- a/packages/server/package-lock.json +++ b/packages/server/package-lock.json @@ -9,12 +9,22 @@ "version": "0.7.5", "dependencies": { "@fastify/cors": "^8.5.0", + "@fastify/reply-from": "^9.8.0", + "@fastify/static": "^7.0.4", "commander": "^12.1.0", "fastify": "^4.28.1", + "fuzzysort": "^2.0.4", "pino": "^9.4.0", + "undici": "^6.19.8", + "yauzl": "^2.10.0", "zod": "^3.23.8" }, + "bin": { + "codenomad": "dist/bin.js" + }, "devDependencies": { + "@types/yauzl": "^2.10.0", + "cross-env": "^7.0.3", "ts-node": "^10.9.2", "tsx": "^4.20.6", "typescript": "^5.6.3" @@ -475,6 +485,15 @@ "node": ">=18" } }, + "node_modules/@fastify/accept-negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-1.1.0.tgz", + "integrity": "sha512-OIHZrb2ImZ7XG85HXOONLcJWGosv7sIvM2ifAPQVhg9Lv7qdmMBNVaai4QTdyuaqbKM5eO6sLSQOYI7wEQeCJQ==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/@fastify/ajv-compiler": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-3.6.0.tgz", @@ -486,6 +505,15 @@ "fast-uri": "^2.0.0" } }, + "node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/@fastify/cors": { "version": "8.5.0", "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-8.5.0.tgz", @@ -520,6 +548,77 @@ "fast-deep-equal": "^3.1.3" } }, + "node_modules/@fastify/reply-from": { + "version": "9.8.0", + "resolved": "https://registry.npmjs.org/@fastify/reply-from/-/reply-from-9.8.0.tgz", + "integrity": "sha512-bPNVaFhEeNI0Lyl6404YZaPFokudCplidE3QoOcr78yOy6H9sYw97p5KPYvY/NJNUHfFtvxOaSAHnK+YSiv/Mg==", + "license": "MIT", + "dependencies": { + "@fastify/error": "^3.0.0", + "end-of-stream": "^1.4.4", + "fast-content-type-parse": "^1.1.0", + "fast-querystring": "^1.0.0", + "fastify-plugin": "^4.0.0", + "toad-cache": "^3.7.0", + "undici": "^5.19.1" + } + }, + "node_modules/@fastify/reply-from/node_modules/undici": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, + "node_modules/@fastify/send": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@fastify/send/-/send-2.1.0.tgz", + "integrity": "sha512-yNYiY6sDkexoJR0D8IDy3aRP3+L4wdqCpvx5WP+VtEU58sn7USmKynBzDQex5X42Zzvw2gNzzYgP90UfWShLFA==", + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.1", + "escape-html": "~1.0.3", + "fast-decode-uri-component": "^1.0.1", + "http-errors": "2.0.0", + "mime": "^3.0.0" + } + }, + "node_modules/@fastify/static": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/@fastify/static/-/static-7.0.4.tgz", + "integrity": "sha512-p2uKtaf8BMOZWLs6wu+Ihg7bWNBdjNgCwDza4MJtTqg+5ovKmcbgbR9Xs5/smZ1YISfzKOCNYmZV8LaCj+eJ1Q==", + "license": "MIT", + "dependencies": { + "@fastify/accept-negotiator": "^1.0.0", + "@fastify/send": "^2.0.0", + "content-disposition": "^0.5.3", + "fastify-plugin": "^4.0.0", + "fastq": "^1.17.0", + "glob": "^10.3.4" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -548,12 +647,31 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", + "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", "license": "MIT" }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", @@ -593,6 +711,16 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/abstract-logging": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", @@ -674,6 +802,30 @@ ], "license": "BSD-3-Clause" }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -700,6 +852,48 @@ "fastq": "^1.17.1" } }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/commander": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", @@ -709,6 +903,18 @@ "node": ">=18" } }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -725,6 +931,48 @@ "dev": true, "license": "MIT" }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/diff": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", @@ -735,6 +983,27 @@ "node": ">=0.3.1" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -777,6 +1046,12 @@ "@esbuild/win32-x64": "0.25.12" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/fast-content-type-parse": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-1.1.0.tgz", @@ -891,6 +1166,15 @@ "reusify": "^1.0.4" } }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, "node_modules/find-my-way": { "version": "8.2.2", "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-8.2.2.tgz", @@ -905,6 +1189,22 @@ "node": ">=14" } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -929,6 +1229,12 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/fuzzysort": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-2.0.4.tgz", + "integrity": "sha512-Api1mJL+Ad7W7vnDZnWq5pGaXJjyencT+iKGia2PlHUcSsSzWwIQ3S1isiMpwpavjYtGd2FzhUIhnnhOULZgDw==", + "license": "MIT" + }, "node_modules/get-tsconfig": { "version": "4.13.0", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", @@ -942,6 +1248,48 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -951,6 +1299,36 @@ "node": ">= 0.10" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/json-schema-ref-resolver": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-1.0.1.tgz", @@ -977,6 +1355,12 @@ "set-cookie-parser": "^2.4.1" } }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", @@ -984,6 +1368,42 @@ "dev": true, "license": "ISC" }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/mnemonist": { "version": "0.39.6", "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.6.tgz", @@ -1008,6 +1428,52 @@ "node": ">=14.0.0" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT" + }, "node_modules/pino": { "version": "9.14.0", "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", @@ -1139,6 +1605,26 @@ "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", "license": "MIT" }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-regex2": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-3.1.0.tgz", @@ -1181,6 +1667,45 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/sonic-boom": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz", @@ -1199,6 +1724,111 @@ "node": ">= 10.x" } }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/thread-stream": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", @@ -1217,6 +1847,15 @@ "node": ">=12" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/ts-node": { "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", @@ -1296,6 +1935,15 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz", + "integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -1310,6 +1958,128 @@ "dev": true, "license": "MIT" }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", diff --git a/packages/server/package.json b/packages/server/package.json index 0ee91437..694335a1 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -32,9 +32,11 @@ "fuzzysort": "^2.0.4", "pino": "^9.4.0", "undici": "^6.19.8", + "yauzl": "^2.10.0", "zod": "^3.23.8" }, "devDependencies": { + "@types/yauzl": "^2.10.0", "cross-env": "^7.0.3", "ts-node": "^10.9.2", "tsx": "^4.20.6", diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index b889cac3..38cc1992 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -167,7 +167,6 @@ export type WorkspaceEventType = | "instance.dataChanged" | "instance.event" | "instance.eventStatus" - | "app.releaseAvailable" export type WorkspaceEventPayload = | { type: "workspace.created"; workspace: WorkspaceDescriptor } @@ -180,7 +179,6 @@ export type WorkspaceEventPayload = | { type: "instance.dataChanged"; instanceId: string; data: InstanceData } | { type: "instance.event"; instanceId: string; event: InstanceStreamEvent } | { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string } - | { type: "app.releaseAvailable"; release: LatestReleaseInfo } export interface NetworkAddress { ip: string @@ -198,6 +196,19 @@ export interface LatestReleaseInfo { notes?: string } +export interface UiMeta { + version?: string + source: "bundled" | "downloaded" | "previous" | "override" | "dev-proxy" | "missing" +} + +export interface SupportMeta { + supported: boolean + message?: string + minServerVersion?: string + latestServerVersion?: string + latestServerUrl?: string +} + export interface ServerMeta { /** Base URL clients should target for REST calls (useful for Electron embedding). */ httpBaseUrl: string @@ -215,8 +226,9 @@ export interface ServerMeta { workspaceRoot: string /** Reachable addresses for this server, external first. */ addresses: NetworkAddress[] - /** Optional metadata about the most recent public release. */ - latestRelease?: LatestReleaseInfo + serverVersion?: string + ui?: UiMeta + support?: SupportMeta } export type BackgroundProcessStatus = "running" | "stopped" | "error" diff --git a/packages/server/src/events/bus.ts b/packages/server/src/events/bus.ts index 3d417ce8..61453024 100644 --- a/packages/server/src/events/bus.ts +++ b/packages/server/src/events/bus.ts @@ -29,7 +29,6 @@ export class EventBus extends EventEmitter { this.on("instance.dataChanged", handler) this.on("instance.event", handler) this.on("instance.eventStatus", handler) - this.on("app.releaseAvailable", handler) return () => { this.off("workspace.created", handler) this.off("workspace.started", handler) @@ -41,7 +40,6 @@ export class EventBus extends EventEmitter { this.off("instance.dataChanged", handler) this.off("instance.event", handler) this.off("instance.eventStatus", handler) - this.off("app.releaseAvailable", handler) } } } diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index c2085a0a..ac0c34e0 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -17,7 +17,7 @@ import { InstanceStore } from "./storage/instance-store" import { InstanceEventBridge } from "./workspaces/instance-events" import { createLogger } from "./logger" import { launchInBrowser } from "./launcher" -import { startReleaseMonitor } from "./releases/release-monitor" +import { resolveUi } from "./ui/remote-ui" import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } from "./auth/manager" const require = createRequire(import.meta.url) @@ -37,6 +37,9 @@ interface CliOptions { logDestination?: string uiStaticDir: string uiDevServer?: string + uiAutoUpdate: boolean + uiNoUpdate: boolean + uiManifestUrl?: string launch: boolean authUsername: string authPassword?: string @@ -66,6 +69,9 @@ function parseCliOptions(argv: string[]): CliOptions { new Option("--ui-dir ", "Directory containing the built UI bundle").env("CLI_UI_DIR").default(DEFAULT_UI_STATIC_DIR), ) .addOption(new Option("--ui-dev-server ", "Proxy UI requests to a running dev server").env("CLI_UI_DEV_SERVER")) + .addOption(new Option("--ui-no-update", "Disable remote UI updates").env("CLI_UI_NO_UPDATE").default(false)) + .addOption(new Option("--ui-auto-update ", "Enable remote UI updates (true|false)").env("CLI_UI_AUTO_UPDATE").default("true")) + .addOption(new Option("--ui-manifest-url ", "Remote UI manifest URL").env("CLI_UI_MANIFEST_URL")) .addOption(new Option("--launch", "Launch the UI in a browser after start").env("CLI_LAUNCH").default(false)) .addOption( new Option("--username ", "Username for server authentication") @@ -91,6 +97,9 @@ function parseCliOptions(argv: string[]): CliOptions { logDestination?: string uiDir: string uiDevServer?: string + uiNoUpdate?: boolean + uiAutoUpdate?: string + uiManifestUrl?: string launch?: boolean username: string password?: string @@ -101,6 +110,9 @@ function parseCliOptions(argv: string[]): CliOptions { const normalizedHost = resolveHost(parsed.host) + const autoUpdateString = (parsed.uiAutoUpdate ?? "true").trim().toLowerCase() + const uiAutoUpdate = autoUpdateString === "1" || autoUpdateString === "true" || autoUpdateString === "yes" + return { port: parsed.port, host: normalizedHost, @@ -111,6 +123,9 @@ function parseCliOptions(argv: string[]): CliOptions { logDestination: parsed.logDestination, uiStaticDir: parsed.uiDir, uiDevServer: parsed.uiDevServer, + uiAutoUpdate, + uiNoUpdate: Boolean(parsed.uiNoUpdate), + uiManifestUrl: parsed.uiManifestUrl, launch: Boolean(parsed.launch), authUsername: parsed.username, authPassword: parsed.password, @@ -141,6 +156,10 @@ function resolveHost(input: string | undefined): string { return trimmed } +function programHasArg(argv: string[], flag: string): boolean { + return argv.includes(flag) +} + async function main() { const options = parseCliOptions(process.argv.slice(2)) const logger = createLogger({ level: options.logLevel, destination: options.logDestination, component: "app" }) @@ -205,19 +224,36 @@ async function main() { logger: logger.child({ component: "instance-events" }), }) - const releaseMonitor = startReleaseMonitor({ - currentVersion: packageJson.version, - logger: logger.child({ component: "release-monitor" }), - onUpdate: (release) => { - if (release) { - serverMeta.latestRelease = release - eventBus.publish({ type: "app.releaseAvailable", release }) - } else { - delete serverMeta.latestRelease - } - }, + const uiDirEnvOverride = Boolean(process.env.CLI_UI_DIR) + const uiDirCliOverride = programHasArg(process.argv.slice(2), "--ui-dir") + const uiOverrideIsExplicit = uiDirEnvOverride || uiDirCliOverride + const uiDirOverride = uiOverrideIsExplicit ? options.uiStaticDir : undefined + + const autoUpdateEnabled = options.uiAutoUpdate && !options.uiNoUpdate + + const uiResolution = await resolveUi({ + serverVersion: packageJson.version, + bundledUiDir: DEFAULT_UI_STATIC_DIR, + autoUpdate: autoUpdateEnabled, + overrideUiDir: uiDirOverride, + uiDevServerUrl: options.uiDevServer, + manifestUrl: options.uiManifestUrl, + logger: logger.child({ component: "ui" }), }) + serverMeta.serverVersion = packageJson.version + serverMeta.ui = { + version: uiResolution.uiVersion, + source: uiResolution.source, + } + serverMeta.support = { + supported: uiResolution.supported, + message: uiResolution.message, + latestServerVersion: uiResolution.latestServerVersion, + latestServerUrl: uiResolution.latestServerUrl, + minServerVersion: uiResolution.minServerVersion, + } + const server = createHttpServer({ host: options.host, port: options.port, @@ -229,8 +265,8 @@ async function main() { serverMeta, instanceStore, authManager, - uiStaticDir: options.uiStaticDir, - uiDevServerUrl: options.uiDevServer, + uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR, + uiDevServerUrl: uiResolution.uiDevServerUrl, logger, }) @@ -266,7 +302,7 @@ async function main() { logger.error({ err: error }, "Workspace manager shutdown failed") } - releaseMonitor.stop() + // no-op: remote UI manifest replaces GitHub release monitor logger.info("Exiting process") process.exit(0) diff --git a/packages/server/src/ui/remote-ui.ts b/packages/server/src/ui/remote-ui.ts new file mode 100644 index 00000000..45e0e544 --- /dev/null +++ b/packages/server/src/ui/remote-ui.ts @@ -0,0 +1,535 @@ +import { createHash } from "crypto" +import fs from "fs" +import { promises as fsp } from "fs" +import os from "os" +import path from "path" +import { Readable } from "stream" +import { fetch } from "undici" +import yauzl from "yauzl" +import type { Logger } from "../logger" + +export interface RemoteUiManifest { + minServerVersion: string + latestUIVersion: string + uiPackageURL: string + sha256: string + latestServerVersion?: string + latestServerUrl?: string +} + +export type UiSource = "bundled" | "downloaded" | "previous" | "override" | "dev-proxy" | "missing" + +export interface UiResolution { + uiStaticDir?: string + uiDevServerUrl?: string + source: UiSource + uiVersion?: string + supported: boolean + message?: string + latestServerVersion?: string + latestServerUrl?: string + minServerVersion?: string +} + +export interface RemoteUiOptions { + serverVersion: string + bundledUiDir: string + autoUpdate: boolean + overrideUiDir?: string + uiDevServerUrl?: string + manifestUrl?: string + configDir?: string + logger: Logger +} + +const DEFAULT_MANIFEST_URL = "https://ui.codenomad.neuralnomads.ai/version.json" + +const MANIFEST_TIMEOUT_MS = 5_000 +const ZIP_TIMEOUT_MS = 30_000 + +export async function resolveUi(options: RemoteUiOptions): Promise { + const manifestUrl = options.manifestUrl ?? DEFAULT_MANIFEST_URL + + if (options.uiDevServerUrl) { + return { + uiDevServerUrl: options.uiDevServerUrl, + source: "dev-proxy", + supported: true, + } + } + + if (options.overrideUiDir) { + const resolved = await resolveStaticUiDir(options.overrideUiDir) + return { + uiStaticDir: resolved ?? options.overrideUiDir, + source: "override", + uiVersion: await readUiVersion(resolved ?? options.overrideUiDir), + supported: true, + } + } + + const uiRoot = resolveUiCacheRoot(options.configDir) + const currentDir = path.join(uiRoot, "current") + const previousDir = path.join(uiRoot, "previous") + + if (!options.autoUpdate) { + const local = await resolveStaticUiDir(currentDir) + if (local) { + return { + uiStaticDir: local, + source: "downloaded", + uiVersion: await readUiVersion(local), + supported: true, + } + } + + const bundled = await resolveStaticUiDir(options.bundledUiDir) + return { + uiStaticDir: bundled ?? options.bundledUiDir, + source: bundled ? "bundled" : "missing", + uiVersion: bundled ? await readUiVersion(bundled) : undefined, + supported: true, + } + } + + let manifest: RemoteUiManifest | null = null + try { + manifest = await fetchManifest(manifestUrl, options.logger) + } catch (error) { + options.logger.debug({ err: error }, "Remote UI manifest unavailable; using cached/bundled UI") + } + + if (!manifest) { + return await resolveFromCacheOrBundled({ + logger: options.logger, + bundledUiDir: options.bundledUiDir, + currentDir, + previousDir, + supported: true, + }) + } + + const supported = compareSemverCore(options.serverVersion, manifest.minServerVersion) >= 0 + if (!supported) { + const message = "Upgrade App to use latest features" + return await resolveFromCacheOrBundled({ + logger: options.logger, + bundledUiDir: options.bundledUiDir, + currentDir, + previousDir, + supported: false, + message, + latestServerVersion: manifest.latestServerVersion, + latestServerUrl: manifest.latestServerUrl, + minServerVersion: manifest.minServerVersion, + }) + } + + const currentVersion = await readUiVersion(currentDir) + if (currentVersion && currentVersion === manifest.latestUIVersion) { + const currentResolved = await resolveStaticUiDir(currentDir) + if (currentResolved) { + return { + uiStaticDir: currentResolved, + source: "downloaded", + uiVersion: currentVersion, + supported: true, + latestServerVersion: manifest.latestServerVersion, + latestServerUrl: manifest.latestServerUrl, + minServerVersion: manifest.minServerVersion, + } + } + } + + try { + await installRemoteUi({ + manifest, + uiRoot, + currentDir, + previousDir, + logger: options.logger, + }) + } catch (error) { + options.logger.warn({ err: error }, "Failed to install remote UI; falling back") + return await resolveFromCacheOrBundled({ + logger: options.logger, + bundledUiDir: options.bundledUiDir, + currentDir, + previousDir, + supported: true, + latestServerVersion: manifest.latestServerVersion, + latestServerUrl: manifest.latestServerUrl, + minServerVersion: manifest.minServerVersion, + }) + } + + const installed = await resolveStaticUiDir(currentDir) + if (installed) { + return { + uiStaticDir: installed, + source: "downloaded", + uiVersion: await readUiVersion(installed), + supported: true, + latestServerVersion: manifest.latestServerVersion, + latestServerUrl: manifest.latestServerUrl, + minServerVersion: manifest.minServerVersion, + } + } + + return await resolveFromCacheOrBundled({ + logger: options.logger, + bundledUiDir: options.bundledUiDir, + currentDir, + previousDir, + supported: true, + latestServerVersion: manifest.latestServerVersion, + latestServerUrl: manifest.latestServerUrl, + minServerVersion: manifest.minServerVersion, + }) +} + +function resolveUiCacheRoot(configDir?: string): string { + if (configDir) { + return path.join(configDir, "ui") + } + return path.join(os.homedir(), ".config", "codenomad", "ui") +} + +async function resolveFromCacheOrBundled(args: { + logger: Logger + bundledUiDir: string + currentDir: string + previousDir: string + supported: boolean + message?: string + latestServerVersion?: string + latestServerUrl?: string + minServerVersion?: string +}): Promise { + const currentResolved = await resolveStaticUiDir(args.currentDir) + if (currentResolved) { + return { + uiStaticDir: currentResolved, + source: "downloaded", + uiVersion: await readUiVersion(currentResolved), + supported: args.supported, + message: args.message, + latestServerVersion: args.latestServerVersion, + latestServerUrl: args.latestServerUrl, + minServerVersion: args.minServerVersion, + } + } + + const previousResolved = await resolveStaticUiDir(args.previousDir) + if (previousResolved) { + return { + uiStaticDir: previousResolved, + source: "previous", + uiVersion: await readUiVersion(previousResolved), + supported: args.supported, + message: args.message, + latestServerVersion: args.latestServerVersion, + latestServerUrl: args.latestServerUrl, + minServerVersion: args.minServerVersion, + } + } + + const bundledResolved = await resolveStaticUiDir(args.bundledUiDir) + if (bundledResolved) { + return { + uiStaticDir: bundledResolved, + source: "bundled", + uiVersion: await readUiVersion(bundledResolved), + supported: args.supported, + message: args.message, + latestServerVersion: args.latestServerVersion, + latestServerUrl: args.latestServerUrl, + minServerVersion: args.minServerVersion, + } + } + + args.logger.warn({ bundledUiDir: args.bundledUiDir }, "No UI assets found") + return { + uiStaticDir: args.bundledUiDir, + source: "missing", + supported: args.supported, + message: args.message, + latestServerVersion: args.latestServerVersion, + latestServerUrl: args.latestServerUrl, + minServerVersion: args.minServerVersion, + } +} + +async function resolveStaticUiDir(uiDir: string): Promise { + try { + const indexPath = path.join(uiDir, "index.html") + await fsp.access(indexPath, fs.constants.R_OK) + return uiDir + } catch { + return null + } +} + +interface UiVersionFile { + uiVersion?: string + version?: string +} + +async function readUiVersion(uiDir: string): Promise { + try { + const content = await fsp.readFile(path.join(uiDir, "ui-version.json"), "utf-8") + const parsed = JSON.parse(content) as UiVersionFile + return parsed.uiVersion ?? parsed.version + } catch { + return undefined + } +} + +async function fetchManifest(url: string, logger: Logger): Promise { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), MANIFEST_TIMEOUT_MS) + try { + const response = await fetch(url, { + signal: controller.signal, + headers: { + Accept: "application/json", + "User-Agent": "CodeNomad-CLI", + }, + }) + if (!response.ok) { + throw new Error(`Manifest responded with ${response.status}`) + } + const json = (await response.json()) as RemoteUiManifest + validateManifest(json) + return json + } catch (error) { + logger.debug({ err: error, url }, "Failed to fetch remote UI manifest") + throw error + } finally { + clearTimeout(timeout) + } +} + +function validateManifest(manifest: RemoteUiManifest) { + const required: Array = ["minServerVersion", "latestUIVersion", "uiPackageURL", "sha256"] + for (const key of required) { + const value = manifest[key] + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error(`Manifest missing ${key}`) + } + } + if (!/^https:\/\//i.test(manifest.uiPackageURL)) { + throw new Error("uiPackageURL must be https") + } + if (!/^[a-f0-9]{64}$/i.test(manifest.sha256.trim())) { + throw new Error("sha256 must be 64 hex chars") + } +} + +async function installRemoteUi(args: { + manifest: RemoteUiManifest + uiRoot: string + currentDir: string + previousDir: string + logger: Logger +}) { + await fsp.mkdir(args.uiRoot, { recursive: true }) + + const tmpDir = path.join(args.uiRoot, `tmp-${Date.now()}`) + const zipPath = path.join(args.uiRoot, `ui-${args.manifest.latestUIVersion}.zip`) + + try { + await downloadFile(args.manifest.uiPackageURL, zipPath, args.logger) + const digest = await sha256File(zipPath) + if (digest.toLowerCase() !== args.manifest.sha256.toLowerCase()) { + throw new Error(`sha256 mismatch for UI zip (expected ${args.manifest.sha256}, got ${digest})`) + } + + await extractZip(zipPath, tmpDir) + + const indexPath = path.join(tmpDir, "index.html") + if (!fs.existsSync(indexPath)) { + throw new Error("Extracted UI missing index.html") + } + + await rotateDirs({ currentDir: args.currentDir, previousDir: args.previousDir, logger: args.logger }) + + fs.rmSync(args.currentDir, { recursive: true, force: true }) + fs.renameSync(tmpDir, args.currentDir) + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }) + fs.rmSync(zipPath, { force: true }) + } +} + +async function rotateDirs(args: { currentDir: string; previousDir: string; logger: Logger }) { + try { + if (fs.existsSync(args.previousDir)) { + fs.rmSync(args.previousDir, { recursive: true, force: true }) + } + if (fs.existsSync(args.currentDir)) { + fs.renameSync(args.currentDir, args.previousDir) + } + } catch (error) { + args.logger.warn({ err: error }, "Failed to rotate UI cache directories") + } +} + +async function downloadFile(url: string, targetPath: string, logger: Logger) { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), ZIP_TIMEOUT_MS) + try { + const response = await fetch(url, { + signal: controller.signal, + headers: { + Accept: "application/octet-stream", + "User-Agent": "CodeNomad-CLI", + }, + }) + if (!response.ok || !response.body) { + throw new Error(`UI zip download failed with ${response.status}`) + } + + await fsp.mkdir(path.dirname(targetPath), { recursive: true }) + const fileStream = fs.createWriteStream(targetPath) + + const body = response.body + if (!body) { + throw new Error("UI zip response missing body") + } + + const nodeStream = Readable.fromWeb(body as any) + + await new Promise((resolve, reject) => { + nodeStream.pipe(fileStream) + nodeStream.on("error", reject) + fileStream.on("error", reject) + fileStream.on("finish", () => resolve()) + }) + + logger.debug({ url, targetPath }, "Downloaded remote UI bundle") + } finally { + clearTimeout(timeout) + } +} + +async function sha256File(filePath: string): Promise { + const hash = createHash("sha256") + const stream = fs.createReadStream(filePath) + await new Promise((resolve, reject) => { + stream.on("data", (chunk) => hash.update(chunk)) + stream.on("error", reject) + stream.on("end", () => resolve()) + }) + return hash.digest("hex") +} + +async function extractZip(zipPath: string, targetDir: string): Promise { + await fsp.mkdir(targetDir, { recursive: true }) + + await new Promise((resolve, reject) => { + yauzl.open(zipPath, { lazyEntries: true }, (openErr, zipfile) => { + if (openErr || !zipfile) { + reject(openErr ?? new Error("Unable to open zip")) + return + } + + const root = path.resolve(targetDir) + + const closeWithError = (error: unknown) => { + try { + zipfile.close() + } catch { + // ignore + } + reject(error) + } + + zipfile.readEntry() + + zipfile.on("entry", (entry) => { + // Normalize and guard against zip-slip. + const entryPath = entry.fileName.replace(/\\/g, "/") + + const segments = entryPath.split("/").filter(Boolean) + if (segments.some((segment: string) => segment === "..") || path.isAbsolute(entryPath)) { + closeWithError(new Error(`Invalid zip entry path: ${entry.fileName}`)) + return + } + + const destination = path.resolve(targetDir, entryPath) + if (!destination.startsWith(root + path.sep) && destination !== root) { + closeWithError(new Error(`Zip entry escapes target dir: ${entry.fileName}`)) + return + } + + const isDirectory = entry.fileName.endsWith("/") + + if (isDirectory) { + fsp + .mkdir(destination, { recursive: true }) + .then(() => zipfile.readEntry()) + .catch((error) => closeWithError(error)) + return + } + + fsp + .mkdir(path.dirname(destination), { recursive: true }) + .then(() => { + zipfile.openReadStream(entry, (streamErr, readStream) => { + if (streamErr || !readStream) { + closeWithError(streamErr ?? new Error("Unable to read zip entry")) + return + } + + const writeStream = fs.createWriteStream(destination) + const cleanup = (error?: unknown) => { + readStream.destroy() + writeStream.destroy() + if (error) { + closeWithError(error) + } + } + + readStream.on("error", cleanup) + writeStream.on("error", cleanup) + writeStream.on("finish", () => zipfile.readEntry()) + + readStream.pipe(writeStream) + }) + }) + .catch((error) => closeWithError(error)) + }) + + zipfile.on("end", () => { + zipfile.close() + resolve() + }) + + zipfile.on("error", (error) => closeWithError(error)) + }) + }) +} + +function compareSemverCore(a: string, b: string): number { + const pa = parseSemverCore(a) + const pb = parseSemverCore(b) + if (pa.major !== pb.major) return pa.major > pb.major ? 1 : -1 + if (pa.minor !== pb.minor) return pa.minor > pb.minor ? 1 : -1 + if (pa.patch !== pb.patch) return pa.patch > pb.patch ? 1 : -1 + return 0 +} + +function parseSemverCore(value: string): { major: number; minor: number; patch: number } { + const core = value.trim().replace(/^v/i, "").split("-", 1)[0] ?? "0.0.0" + const parts = core.split(".") + const parsePart = (input: string | undefined) => { + const n = Number.parseInt((input ?? "0").replace(/[^0-9]/g, ""), 10) + return Number.isFinite(n) ? n : 0 + } + return { + major: parsePart(parts[0]), + minor: parsePart(parts[1]), + patch: parsePart(parts[2]), + } +} diff --git a/packages/ui/src/components/folder-selection-view.tsx b/packages/ui/src/components/folder-selection-view.tsx index c495457a..4391d845 100644 --- a/packages/ui/src/components/folder-selection-view.tsx +++ b/packages/ui/src/components/folder-selection-view.tsx @@ -5,6 +5,7 @@ import AdvancedSettingsModal from "./advanced-settings-modal" import DirectoryBrowserDialog from "./directory-browser-dialog" import Kbd from "./kbd" import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions" +import VersionPill from "./version-pill" const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href @@ -248,6 +249,9 @@ const FolderSelectionView: Component = (props) => {

CodeNomad

Select a folder to start coding with AI

+
+ +
diff --git a/packages/ui/src/components/version-pill.tsx b/packages/ui/src/components/version-pill.tsx new file mode 100644 index 00000000..2f75fd3f --- /dev/null +++ b/packages/ui/src/components/version-pill.tsx @@ -0,0 +1,38 @@ +import { Show, createEffect, createSignal } from "solid-js" +import type { ServerMeta } from "../../../server/src/api-types" +import { getServerMeta } from "../lib/server-meta" + +export default function VersionPill() { + const [meta, setMeta] = createSignal(null) + + createEffect(() => { + void getServerMeta() + .then((result) => setMeta(result)) + .catch(() => setMeta(null)) + }) + + const serverVersion = () => meta()?.serverVersion + const uiVersion = () => meta()?.ui?.version + const uiSource = () => meta()?.ui?.source + + return ( + +
+ + {(v) => App {v()}} + + + {(v) => ( + <> + ยท + + UI {v()} + {(s) => ({s()})} + + + )} + +
+
+ ) +} diff --git a/packages/ui/src/stores/releases.ts b/packages/ui/src/stores/releases.ts index f55bae70..e0566ede 100644 --- a/packages/ui/src/stores/releases.ts +++ b/packages/ui/src/stores/releases.ts @@ -1,25 +1,24 @@ import { createEffect, createSignal } from "solid-js" -import type { LatestReleaseInfo, WorkspaceEventPayload } from "../../../server/src/api-types" +import type { SupportMeta } from "../../../server/src/api-types" import { getServerMeta } from "../lib/server-meta" -import { serverEvents } from "../lib/server-events" import { showToastNotification, ToastHandle } from "../lib/notifications" import { getLogger } from "../lib/logger" import { hasInstances, showFolderSelection } from "./ui" const log = getLogger("actions") -const [availableRelease, setAvailableRelease] = createSignal(null) +const [supportInfo, setSupportInfo] = createSignal(null) let initialized = false let visibilityEffectInitialized = false let activeToast: ToastHandle | null = null -let activeToastVersion: string | null = null +let activeToastKey: string | null = null function dismissActiveToast() { if (activeToast) { activeToast.dismiss() activeToast = null - activeToastVersion = null + activeToastKey = null } } @@ -30,28 +29,34 @@ function ensureVisibilityEffect() { visibilityEffectInitialized = true createEffect(() => { - const release = availableRelease() - const shouldShow = Boolean(release) && (!hasInstances() || showFolderSelection()) + const support = supportInfo() + const shouldShow = Boolean(support && support.supported === false) && (!hasInstances() || showFolderSelection()) - if (!shouldShow || !release) { + if (!shouldShow || !support || support.supported !== false) { dismissActiveToast() return } - if (!activeToast || activeToastVersion !== release.version) { + const key = `${support.minServerVersion ?? "unknown"}:${support.latestServerVersion ?? "unknown"}` + + if (!activeToast || activeToastKey !== key) { dismissActiveToast() activeToast = showToastNotification({ - title: `CodeNomad ${release.version}`, - message: release.channel === "dev" ? "Dev release build available." : "New stable build on GitHub.", + title: support.message ?? "Upgrade required", + message: support.latestServerVersion + ? `Update to CodeNomad ${support.latestServerVersion} to use the latest UI.` + : "Update CodeNomad to use the latest UI.", variant: "info", duration: Number.POSITIVE_INFINITY, position: "bottom-right", - action: { - label: "View release", - href: release.url, - }, + action: support.latestServerUrl + ? { + label: "Get update", + href: support.latestServerUrl, + } + : undefined, }) - activeToastVersion = release.version + activeToastKey = key } }) } @@ -64,32 +69,17 @@ export function initReleaseNotifications() { ensureVisibilityEffect() void refreshFromMeta() - - serverEvents.on("app.releaseAvailable", (event) => { - const typedEvent = event as Extract - applyRelease(typedEvent.release) - }) } async function refreshFromMeta() { try { const meta = await getServerMeta(true) - if (meta.latestRelease) { - applyRelease(meta.latestRelease) - } + setSupportInfo(meta.support ?? null) } catch (error) { - log.warn("Unable to load server metadata for release info", error) + log.warn("Unable to load server metadata for support info", error) } } -function applyRelease(release: LatestReleaseInfo | null | undefined) { - if (!release) { - setAvailableRelease(null) - return - } - setAvailableRelease(release) -} - -export function useAvailableRelease() { - return availableRelease +export function useSupportInfo() { + return supportInfo }