From 59e4966dda87c12fde1288168b4c1ea229922da6 Mon Sep 17 00:00:00 2001 From: Aresdgi <115067719+Aresdgi@users.noreply.github.com> Date: Fri, 1 May 2026 11:26:54 +0200 Subject: [PATCH] feat(version): add app version awareness (#204) * feat(version): add app version awareness * fix(version): detect packaged sidecars across platforms --- apps/daemon/src/app-version.ts | 104 +++++++++++++++++++++ apps/daemon/src/server.ts | 11 ++- apps/daemon/tests/app-version.test.ts | 60 ++++++++++++ apps/daemon/tests/version-route.test.ts | 48 ++++++++++ apps/web/src/App.tsx | 8 +- apps/web/src/components/SettingsDialog.tsx | 58 +++++++++++- apps/web/src/i18n/locales/de.ts | 10 ++ apps/web/src/i18n/locales/en.ts | 10 ++ apps/web/src/i18n/locales/es-ES.ts | 10 ++ apps/web/src/i18n/locales/fa.ts | 10 ++ apps/web/src/i18n/locales/ja.ts | 10 ++ apps/web/src/i18n/locales/pt-BR.ts | 10 ++ apps/web/src/i18n/locales/ru.ts | 10 ++ apps/web/src/i18n/locales/zh-CN.ts | 10 ++ apps/web/src/i18n/locales/zh-TW.ts | 10 ++ apps/web/src/i18n/types.ts | 10 ++ apps/web/src/index.css | 28 ++++++ apps/web/src/providers/registry.test.ts | 35 ++++++- apps/web/src/providers/registry.ts | 25 +++++ apps/web/src/types.ts | 4 + packages/contracts/src/api/version.ts | 11 +++ packages/contracts/src/index.ts | 1 + 22 files changed, 487 insertions(+), 6 deletions(-) create mode 100644 apps/daemon/src/app-version.ts create mode 100644 apps/daemon/tests/app-version.test.ts create mode 100644 apps/daemon/tests/version-route.test.ts create mode 100644 packages/contracts/src/api/version.ts diff --git a/apps/daemon/src/app-version.ts b/apps/daemon/src/app-version.ts new file mode 100644 index 000000000..2d71b5765 --- /dev/null +++ b/apps/daemon/src/app-version.ts @@ -0,0 +1,104 @@ +import { readFile } from 'node:fs/promises'; + +export const APP_VERSION_FALLBACK = '0.0.0'; + +// Keep this structurally aligned with `@open-design/contracts` AppVersionInfo. +// Daemon cannot import the package root type directly yet because its NodeNext +// test typecheck follows the contracts source re-exports and requires explicit +// `.js` extensions across that package. +export interface AppVersionInfo { + version: string; + channel: string; + packaged: boolean; + platform: string; + arch: string; +} + +interface PackageMetadata { + version?: unknown; +} + +export interface ResolveAppVersionInfoOptions { + env?: NodeJS.ProcessEnv | undefined; + packageMetadata?: PackageMetadata | null; + resourcesPath?: string | undefined; + execPath?: string | undefined; + platform?: NodeJS.Platform | undefined; + arch?: NodeJS.Architecture | undefined; +} + +export interface ReadAppVersionInfoOptions extends ResolveAppVersionInfoOptions { + packageJsonUrl?: URL | undefined; +} + +const DEFAULT_PACKAGE_JSON_URL = new URL('../package.json', import.meta.url); +const processWithResources = process as NodeJS.Process & { resourcesPath?: string }; + +function cleanString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; +} + +export function isPackagedRuntime({ + resourcesPath = processWithResources.resourcesPath, + execPath = process.execPath, + platform = process.platform, +}: Pick = {}): boolean { + if (cleanString(resourcesPath)) return true; + const normalizedExecPath = cleanString(execPath)?.replace(/\\/g, '/').toLowerCase(); + if (!normalizedExecPath) return false; + + switch (platform) { + case 'darwin': + return normalizedExecPath.includes('/contents/resources/'); + case 'win32': + return normalizedExecPath.includes('/resources/') || normalizedExecPath.includes('/app.asar'); + case 'linux': + return normalizedExecPath.includes('/usr/share/') + || normalizedExecPath.includes('/opt/') + || normalizedExecPath.includes('/resources/'); + default: + return normalizedExecPath.includes('/resources/') || normalizedExecPath.includes('/app.asar'); + } +} + +export function resolveAppVersionInfo({ + env = process.env, + packageMetadata, + resourcesPath, + execPath, + platform = process.platform, + arch = process.arch, +}: ResolveAppVersionInfoOptions = {}): AppVersionInfo { + const packaged = isPackagedRuntime({ resourcesPath, execPath, platform }); + const version = cleanString(packageMetadata?.version) ?? APP_VERSION_FALLBACK; + const prereleaseChannel = version.match(/^\d+\.\d+\.\d+-([0-9A-Za-z-]+)/)?.[1]?.split('.')[0] ?? null; + const channel = cleanString(env.OD_RELEASE_CHANNEL) + ?? cleanString(env.OD_APP_CHANNEL) + ?? prereleaseChannel + ?? (packaged ? 'stable' : 'development'); + + return { version, channel, packaged, platform, arch }; +} + +async function readPackageMetadata(packageJsonUrl: URL): Promise { + try { + const raw = await readFile(packageJsonUrl, 'utf8'); + const parsed = JSON.parse(raw) as unknown; + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null; + } catch { + return null; + } +} + +export async function readCurrentAppVersionInfo({ + packageJsonUrl = DEFAULT_PACKAGE_JSON_URL, + packageMetadata, + env, + resourcesPath, + execPath, + platform, + arch, +}: ReadAppVersionInfoOptions = {}): Promise { + const metadata = packageMetadata ?? await readPackageMetadata(packageJsonUrl); + return resolveAppVersionInfo({ env, packageMetadata: metadata, resourcesPath, execPath, platform, arch }); +} diff --git a/apps/daemon/src/server.ts b/apps/daemon/src/server.ts index 26a9302ad..fa731031d 100644 --- a/apps/daemon/src/server.ts +++ b/apps/daemon/src/server.ts @@ -52,6 +52,7 @@ import { writeProjectFile, } from './projects.js'; import { validateArtifactManifestInput } from './artifact-manifest.js'; +import { readCurrentAppVersionInfo } from './app-version.js'; import { deleteConversation, deleteProject as dbDeleteProject, @@ -481,8 +482,14 @@ export async function startServer({ port = 7456, returnServer = false } = {}) { app.use(express.static(STATIC_DIR)); } - app.get('/api/health', (_req, res) => { - res.json({ ok: true, version: '0.1.0' }); + app.get('/api/health', async (_req, res) => { + const versionInfo = await readCurrentAppVersionInfo(); + res.json({ ok: true, version: versionInfo.version }); + }); + + app.get('/api/version', async (_req, res) => { + const version = await readCurrentAppVersionInfo(); + res.json({ version }); }); // ---- Projects (DB-backed) ------------------------------------------------- diff --git a/apps/daemon/tests/app-version.test.ts b/apps/daemon/tests/app-version.test.ts new file mode 100644 index 000000000..4435dc41f --- /dev/null +++ b/apps/daemon/tests/app-version.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import { + APP_VERSION_FALLBACK, + isPackagedRuntime, + resolveAppVersionInfo, +} from '../src/app-version.js'; + +describe('app version helpers', () => { + it('resolves version info from package metadata', () => { + expect(resolveAppVersionInfo({ + packageMetadata: { version: '1.2.3' }, + env: {}, + resourcesPath: undefined, + execPath: '/usr/local/bin/node', + platform: 'linux', + arch: 'x64', + })).toEqual({ + version: '1.2.3', + channel: 'development', + packaged: false, + platform: 'linux', + arch: 'x64', + }); + }); + + it('uses a safe fallback when package metadata is missing', () => { + expect(resolveAppVersionInfo({ packageMetadata: null, env: {} }).version).toBe(APP_VERSION_FALLBACK); + }); + + it('detects packaged runtimes without sidecar protocol knowledge', () => { + expect(isPackagedRuntime({ resourcesPath: '/Applications/Open Design.app/Contents/Resources' })).toBe(true); + expect(isPackagedRuntime({ + execPath: '/Applications/Open Design.app/Contents/Resources/open-design/bin/node', + platform: 'darwin', + })).toBe(true); + expect(isPackagedRuntime({ + execPath: 'C:\\Users\\Ada\\AppData\\Local\\Programs\\Open Design\\resources\\open-design\\bin\\node.exe', + platform: 'win32', + })).toBe(true); + expect(isPackagedRuntime({ + execPath: '/opt/Open Design/resources/open-design/bin/node', + platform: 'linux', + })).toBe(true); + expect(isPackagedRuntime({ execPath: '/usr/local/bin/node', platform: 'linux' })).toBe(false); + }); + + it('honors an explicit release channel', () => { + expect(resolveAppVersionInfo({ + packageMetadata: { version: '1.2.3' }, + env: { OD_RELEASE_CHANNEL: 'beta' }, + }).channel).toBe('beta'); + }); + + it('infers prerelease channel from semver metadata', () => { + expect(resolveAppVersionInfo({ + packageMetadata: { version: '0.1.0-beta.6' }, + env: {}, + }).channel).toBe('beta'); + }); +}); diff --git a/apps/daemon/tests/version-route.test.ts b/apps/daemon/tests/version-route.test.ts new file mode 100644 index 000000000..ecae6775f --- /dev/null +++ b/apps/daemon/tests/version-route.test.ts @@ -0,0 +1,48 @@ +import type http from 'node:http'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { startServer } from '../src/server.js'; + +describe('/api/version', () => { + let server: http.Server; + let baseUrl: string; + + beforeAll(async () => { + const started = await startServer({ port: 0, returnServer: true }) as { + url: string; + server: http.Server; + }; + baseUrl = started.url; + server = started.server; + }); + + afterAll(() => new Promise((resolve) => server.close(() => resolve()))); + + it('returns current app version info', async () => { + const res = await fetch(`${baseUrl}/api/version`); + const json = await res.json() as unknown; + + expect(res.ok).toBe(true); + expect(json).toEqual({ + version: { + version: expect.any(String), + channel: expect.any(String), + packaged: expect.any(Boolean), + platform: expect.any(String), + arch: expect.any(String), + }, + }); + }); + + it('keeps health version aligned with version endpoint', async () => { + const [healthRes, versionRes] = await Promise.all([ + fetch(`${baseUrl}/api/health`), + fetch(`${baseUrl}/api/version`), + ]); + const health = await healthRes.json() as { ok?: unknown; version?: unknown }; + const version = await versionRes.json() as { version?: { version?: unknown } }; + + expect(healthRes.ok).toBe(true); + expect(versionRes.ok).toBe(true); + expect(health).toEqual({ ok: true, version: version.version?.version }); + }); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 5b045aa56..1f4455edf 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -5,6 +5,7 @@ import { ProjectView } from './components/ProjectView'; import { SettingsDialog } from './components/SettingsDialog'; import { daemonIsLive, + fetchAppVersionInfo, fetchAgents, fetchDesignSystems, fetchPromptTemplates, @@ -28,6 +29,7 @@ import { import type { AgentInfo, AppConfig, + AppVersionInfo, DesignSystemSummary, Project, ProjectTemplate, @@ -46,6 +48,7 @@ export function App() { const [projects, setProjects] = useState([]); const [templates, setTemplates] = useState([]); const [promptTemplates, setPromptTemplates] = useState([]); + const [appVersionInfo, setAppVersionInfo] = useState(null); // Goes false once the bootstrap effect has finished its initial round of // fetches. The entry view uses this to show shimmer / skeleton states // instead of an "empty" page that flickers before data lands. @@ -59,7 +62,7 @@ export function App() { const alive = await daemonIsLive(); if (cancelled) return; setDaemonLive(alive); - const [agentList, skillList, dsList, projectList, templateList, promptTemplateList] = + const [agentList, skillList, dsList, projectList, templateList, promptTemplateList, versionInfo] = await Promise.all([ alive ? fetchAgents() : Promise.resolve([] as AgentInfo[]), alive ? fetchSkills() : Promise.resolve([] as SkillSummary[]), @@ -69,6 +72,7 @@ export function App() { alive ? listProjects() : Promise.resolve([] as Project[]), alive ? listTemplates() : Promise.resolve([] as ProjectTemplate[]), alive ? fetchPromptTemplates() : Promise.resolve([] as PromptTemplateSummary[]), + alive ? fetchAppVersionInfo() : Promise.resolve(null), ]); if (cancelled) return; setAgents(agentList); @@ -77,6 +81,7 @@ export function App() { setProjects(projectList); setTemplates(templateList); setPromptTemplates(promptTemplateList); + setAppVersionInfo(versionInfo); setConfig((prev) => { const next = { ...prev }; @@ -343,6 +348,7 @@ export function App() { initial={config} agents={agents} daemonLive={daemonLive} + appVersionInfo={appVersionInfo} welcome={settingsWelcome} onSave={handleConfigSave} onClose={() => { diff --git a/apps/web/src/components/SettingsDialog.tsx b/apps/web/src/components/SettingsDialog.tsx index 5009a6d02..3d288b066 100644 --- a/apps/web/src/components/SettingsDialog.tsx +++ b/apps/web/src/components/SettingsDialog.tsx @@ -10,7 +10,7 @@ import { renderModelOptions, } from './modelOptions'; import { KNOWN_PROVIDERS } from '../state/config'; -import type { AgentInfo, AppConfig, ExecMode } from '../types'; +import type { AgentInfo, AppConfig, AppVersionInfo, ExecMode } from '../types'; import { MEDIA_PROVIDERS } from '../media/models'; import type { MediaProvider } from '../media/models'; @@ -18,6 +18,7 @@ interface Props { initial: AppConfig; agents: AgentInfo[]; daemonLive: boolean; + appVersionInfo: AppVersionInfo | null; welcome?: boolean; onSave: (cfg: AppConfig) => void; onClose: () => void; @@ -35,6 +36,7 @@ export function SettingsDialog({ initial, agents, daemonLive, + appVersionInfo, welcome, onSave, onClose, @@ -44,7 +46,7 @@ export function SettingsDialog({ const [cfg, setCfg] = useState(initial); const [showApiKey, setShowApiKey] = useState(false); const [languageOpen, setLanguageOpen] = useState(false); - const [activeSection, setActiveSection] = useState<'execution' | 'media' | 'language'>('execution'); + const [activeSection, setActiveSection] = useState<'execution' | 'media' | 'language' | 'about'>('execution'); const [languageMenuRect, setLanguageMenuRect] = useState(null); const languageRef = useRef(null); @@ -153,6 +155,17 @@ export function SettingsDialog({ {t('settings.languageHint')} +
{activeSection === 'execution' ? ( @@ -508,6 +521,47 @@ export function SettingsDialog({
) : null} + + {activeSection === 'about' ? ( +
+
+
+

{t('settings.about')}

+

{t('settings.aboutHint')}

+
+
+ {appVersionInfo ? ( +
+
+
{t('settings.appVersion')}
+
{appVersionInfo.version}
+
+
+
{t('settings.appChannel')}
+
{appVersionInfo.channel}
+
+
+
{t('settings.appRuntime')}
+
+ {appVersionInfo.packaged + ? t('settings.runtimePackaged') + : t('settings.runtimeDevelopment')} +
+
+
+
{t('settings.appPlatform')}
+
{appVersionInfo.platform}
+
+
+
{t('settings.appArchitecture')}
+
{appVersionInfo.arch}
+
+
+ ) : ( +
{t('settings.versionUnavailable')}
+ )} +
+ ) : null} diff --git a/apps/web/src/i18n/locales/de.ts b/apps/web/src/i18n/locales/de.ts index 50c018796..67f97ba4d 100644 --- a/apps/web/src/i18n/locales/de.ts +++ b/apps/web/src/i18n/locales/de.ts @@ -100,6 +100,16 @@ export const de: Dict = { 'settings.mediaProviderClear': 'Leeren', 'settings.mediaProviderPlaceholder': 'API-Key einfügen', 'settings.mediaProviderBaseUrlPlaceholder': 'Standard-Base-URL überschreiben', + 'settings.about': 'Info', + 'settings.aboutHint': 'Version und Laufzeitdetails', + 'settings.appVersion': 'Version', + 'settings.appChannel': 'Kanal', + 'settings.appRuntime': 'Laufzeit', + 'settings.appPlatform': 'Plattform', + 'settings.appArchitecture': 'Architektur', + 'settings.runtimePackaged': 'Paketierte App', + 'settings.runtimeDevelopment': 'Entwicklung', + 'settings.versionUnavailable': 'Versionsdetails sind nicht verfügbar, solange der Daemon offline ist.', 'entry.tabDesigns': 'Designs', 'entry.tabExamples': 'Beispiele', diff --git a/apps/web/src/i18n/locales/en.ts b/apps/web/src/i18n/locales/en.ts index e32b1c523..024875d6d 100644 --- a/apps/web/src/i18n/locales/en.ts +++ b/apps/web/src/i18n/locales/en.ts @@ -100,6 +100,16 @@ export const en: Dict = { 'settings.mediaProviderClear': 'Clear', 'settings.mediaProviderPlaceholder': 'Paste API key', 'settings.mediaProviderBaseUrlPlaceholder': 'Override default base URL', + 'settings.about': 'About', + 'settings.aboutHint': 'Version and runtime details', + 'settings.appVersion': 'Version', + 'settings.appChannel': 'Channel', + 'settings.appRuntime': 'Runtime', + 'settings.appPlatform': 'Platform', + 'settings.appArchitecture': 'Architecture', + 'settings.runtimePackaged': 'Packaged app', + 'settings.runtimeDevelopment': 'Development', + 'settings.versionUnavailable': 'Version details are unavailable while the daemon is offline.', 'entry.tabDesigns': 'Designs', 'entry.tabExamples': 'Examples', diff --git a/apps/web/src/i18n/locales/es-ES.ts b/apps/web/src/i18n/locales/es-ES.ts index 2114abaf8..91fb82948 100644 --- a/apps/web/src/i18n/locales/es-ES.ts +++ b/apps/web/src/i18n/locales/es-ES.ts @@ -100,6 +100,16 @@ export const esES: Dict = { 'settings.mediaProviderClear': 'Limpiar', 'settings.mediaProviderPlaceholder': 'Pega la clave de API', 'settings.mediaProviderBaseUrlPlaceholder': 'Sobrescribir URL base por defecto', + 'settings.about': 'Acerca de', + 'settings.aboutHint': 'Versión y detalles de ejecución', + 'settings.appVersion': 'Versión', + 'settings.appChannel': 'Canal', + 'settings.appRuntime': 'Ejecución', + 'settings.appPlatform': 'Plataforma', + 'settings.appArchitecture': 'Arquitectura', + 'settings.runtimePackaged': 'App empaquetada', + 'settings.runtimeDevelopment': 'Desarrollo', + 'settings.versionUnavailable': 'Los detalles de versión no están disponibles mientras el daemon está offline.', 'entry.tabDesigns': 'Diseños', 'entry.tabExamples': 'Ejemplos', diff --git a/apps/web/src/i18n/locales/fa.ts b/apps/web/src/i18n/locales/fa.ts index 80a1b905a..61e8a9228 100644 --- a/apps/web/src/i18n/locales/fa.ts +++ b/apps/web/src/i18n/locales/fa.ts @@ -100,6 +100,16 @@ export const fa: Dict = { 'settings.mediaProviderClear': 'پاک کردن', 'settings.mediaProviderPlaceholder': 'کلید API را وارد کنید', 'settings.mediaProviderBaseUrlPlaceholder': 'بازنویسی آدرس پایه پیش‌فرض', + 'settings.about': 'درباره', + 'settings.aboutHint': 'جزئیات نسخه و اجرا', + 'settings.appVersion': 'نسخه', + 'settings.appChannel': 'کانال', + 'settings.appRuntime': 'محیط اجرا', + 'settings.appPlatform': 'سکو', + 'settings.appArchitecture': 'معماری', + 'settings.runtimePackaged': 'برنامه بسته‌بندی‌شده', + 'settings.runtimeDevelopment': 'توسعه', + 'settings.versionUnavailable': 'تا وقتی daemon آفلاین است جزئیات نسخه در دسترس نیست.', 'entry.tabDesigns': 'طرح‌ها', 'entry.tabExamples': 'نمونه‌ها', diff --git a/apps/web/src/i18n/locales/ja.ts b/apps/web/src/i18n/locales/ja.ts index 58066f319..7ce5f61a8 100644 --- a/apps/web/src/i18n/locales/ja.ts +++ b/apps/web/src/i18n/locales/ja.ts @@ -100,6 +100,16 @@ export const ja: Dict = { 'settings.mediaProviderClear': 'クリア', 'settings.mediaProviderPlaceholder': 'APIキーを貼り付け', 'settings.mediaProviderBaseUrlPlaceholder': 'デフォルトのベース URL を上書き', + 'settings.about': 'About', + 'settings.aboutHint': 'バージョンと実行環境の詳細', + 'settings.appVersion': 'バージョン', + 'settings.appChannel': 'チャンネル', + 'settings.appRuntime': '実行環境', + 'settings.appPlatform': 'プラットフォーム', + 'settings.appArchitecture': 'アーキテクチャ', + 'settings.runtimePackaged': 'パッケージ版アプリ', + 'settings.runtimeDevelopment': '開発環境', + 'settings.versionUnavailable': 'daemon がオフラインの間はバージョン詳細を取得できません。', 'entry.tabDesigns': 'デザイン', 'entry.tabExamples': 'サンプル', diff --git a/apps/web/src/i18n/locales/pt-BR.ts b/apps/web/src/i18n/locales/pt-BR.ts index adcbd729b..a602be10f 100644 --- a/apps/web/src/i18n/locales/pt-BR.ts +++ b/apps/web/src/i18n/locales/pt-BR.ts @@ -99,6 +99,16 @@ export const ptBR: Dict = { 'settings.mediaProviderClear': 'Limpar', 'settings.mediaProviderPlaceholder': 'Cole a API key', 'settings.mediaProviderBaseUrlPlaceholder': 'Sobrescrever Base URL padrão', + 'settings.about': 'Sobre', + 'settings.aboutHint': 'Versão e detalhes de execução', + 'settings.appVersion': 'Versão', + 'settings.appChannel': 'Canal', + 'settings.appRuntime': 'Runtime', + 'settings.appPlatform': 'Plataforma', + 'settings.appArchitecture': 'Arquitetura', + 'settings.runtimePackaged': 'App empacotado', + 'settings.runtimeDevelopment': 'Desenvolvimento', + 'settings.versionUnavailable': 'Os detalhes de versão ficam indisponíveis enquanto o daemon está offline.', 'entry.tabDesigns': 'Designs', 'entry.tabExamples': 'Exemplos', diff --git a/apps/web/src/i18n/locales/ru.ts b/apps/web/src/i18n/locales/ru.ts index 8fdc9b79a..835357dd5 100644 --- a/apps/web/src/i18n/locales/ru.ts +++ b/apps/web/src/i18n/locales/ru.ts @@ -99,6 +99,16 @@ export const ru: Dict = { 'settings.mediaProviderClear': 'Очистить', 'settings.mediaProviderPlaceholder': 'Вставьте API key', 'settings.mediaProviderBaseUrlPlaceholder': 'Переопределить Base URL', + 'settings.about': 'О приложении', + 'settings.aboutHint': 'Версия и сведения о запуске', + 'settings.appVersion': 'Версия', + 'settings.appChannel': 'Канал', + 'settings.appRuntime': 'Среда запуска', + 'settings.appPlatform': 'Платформа', + 'settings.appArchitecture': 'Архитектура', + 'settings.runtimePackaged': 'Упакованное приложение', + 'settings.runtimeDevelopment': 'Разработка', + 'settings.versionUnavailable': 'Сведения о версии недоступны, пока daemon не запущен.', 'entry.tabDesigns': 'Дизайны', 'entry.tabExamples': 'Примеры', diff --git a/apps/web/src/i18n/locales/zh-CN.ts b/apps/web/src/i18n/locales/zh-CN.ts index 1ddb51c69..1f5d977e1 100644 --- a/apps/web/src/i18n/locales/zh-CN.ts +++ b/apps/web/src/i18n/locales/zh-CN.ts @@ -98,6 +98,16 @@ export const zhCN: Dict = { 'settings.mediaProviderClear': '清除', 'settings.mediaProviderPlaceholder': '粘贴 API key', 'settings.mediaProviderBaseUrlPlaceholder': '覆盖默认 Base URL', + 'settings.about': '关于', + 'settings.aboutHint': '版本和运行时详情', + 'settings.appVersion': '版本', + 'settings.appChannel': '渠道', + 'settings.appRuntime': '运行时', + 'settings.appPlatform': '平台', + 'settings.appArchitecture': '架构', + 'settings.runtimePackaged': '已打包应用', + 'settings.runtimeDevelopment': '开发环境', + 'settings.versionUnavailable': '守护进程离线时无法获取版本详情。', 'entry.tabDesigns': '我的设计', 'entry.tabExamples': '示例', diff --git a/apps/web/src/i18n/locales/zh-TW.ts b/apps/web/src/i18n/locales/zh-TW.ts index 177bcb55f..66bb1a308 100644 --- a/apps/web/src/i18n/locales/zh-TW.ts +++ b/apps/web/src/i18n/locales/zh-TW.ts @@ -98,6 +98,16 @@ export const zhTW: Dict = { 'settings.mediaProviderClear': '清除', 'settings.mediaProviderPlaceholder': '貼上 API key', 'settings.mediaProviderBaseUrlPlaceholder': '覆蓋預設 Base URL', + 'settings.about': '關於', + 'settings.aboutHint': '版本與執行環境詳情', + 'settings.appVersion': '版本', + 'settings.appChannel': '通道', + 'settings.appRuntime': '執行環境', + 'settings.appPlatform': '平台', + 'settings.appArchitecture': '架構', + 'settings.runtimePackaged': '已封裝應用程式', + 'settings.runtimeDevelopment': '開發環境', + 'settings.versionUnavailable': '守護程式離線時無法取得版本詳情。', 'entry.tabDesigns': '我的設計', 'entry.tabExamples': '範例', diff --git a/apps/web/src/i18n/types.ts b/apps/web/src/i18n/types.ts index dd6db2825..c21a23c8e 100644 --- a/apps/web/src/i18n/types.ts +++ b/apps/web/src/i18n/types.ts @@ -115,6 +115,16 @@ export interface Dict { 'settings.mediaProviderClear': string; 'settings.mediaProviderPlaceholder': string; 'settings.mediaProviderBaseUrlPlaceholder': string; + 'settings.about': string; + 'settings.aboutHint': string; + 'settings.appVersion': string; + 'settings.appChannel': string; + 'settings.appRuntime': string; + 'settings.appPlatform': string; + 'settings.appArchitecture': string; + 'settings.runtimePackaged': string; + 'settings.runtimeDevelopment': string; + 'settings.versionUnavailable': string; // Entry view / tabs 'entry.tabDesigns': string; diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 3b1defd09..a46361d63 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -917,6 +917,34 @@ code { .settings-section { display: flex; flex-direction: column; gap: 12px; } +.settings-about-list { + margin: 0; + display: flex; + flex-direction: column; + gap: 8px; +} +.settings-about-list > div { + display: flex; + justify-content: space-between; + gap: 16px; + padding: 12px; + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: var(--radius-sm); +} +.settings-about-list dt { + font-size: 12px; + color: var(--text-muted); +} +.settings-about-list dd { + margin: 0; + color: var(--text); + font-size: 13px; + font-weight: 600; + text-align: right; + overflow-wrap: anywhere; +} + .media-provider-list { display: flex; flex-direction: column; diff --git a/apps/web/src/providers/registry.test.ts b/apps/web/src/providers/registry.test.ts index 9f28eb359..55a1d1c8a 100644 --- a/apps/web/src/providers/registry.test.ts +++ b/apps/web/src/providers/registry.test.ts @@ -1,6 +1,39 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { fetchProjectFileText } from './registry'; +import { fetchAppVersionInfo, fetchProjectFileText } from './registry'; + +describe('fetchAppVersionInfo', () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('returns version info from the daemon response', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify({ + version: { version: '1.2.3', channel: 'beta', packaged: true, platform: 'darwin', arch: 'arm64' }, + }), { status: 200 })), + ); + + await expect(fetchAppVersionInfo()).resolves.toEqual({ + version: '1.2.3', + channel: 'beta', + packaged: true, + platform: 'darwin', + arch: 'arm64', + }); + }); + + it('returns null when version info is unavailable or malformed', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify({ version: { version: '1.2.3' } }), { status: 200 })), + ); + + await expect(fetchAppVersionInfo()).resolves.toBeNull(); + }); +}); describe('fetchProjectFileText', () => { afterEach(() => { diff --git a/apps/web/src/providers/registry.ts b/apps/web/src/providers/registry.ts index 6127a9951..2dd01a925 100644 --- a/apps/web/src/providers/registry.ts +++ b/apps/web/src/providers/registry.ts @@ -1,5 +1,7 @@ import type { AgentInfo, + AppVersionInfo, + AppVersionResponse, ChatAttachment, DeployConfigResponse, DeployProjectFileResponse, @@ -104,6 +106,29 @@ export async function daemonIsLive(): Promise { } } +function isAppVersionInfo(value: unknown): value is AppVersionInfo { + if (!value || typeof value !== 'object') return false; + const candidate = value as Partial; + return ( + typeof candidate.version === 'string' && + typeof candidate.channel === 'string' && + typeof candidate.packaged === 'boolean' && + typeof candidate.platform === 'string' && + typeof candidate.arch === 'string' + ); +} + +export async function fetchAppVersionInfo(): Promise { + try { + const resp = await fetch('/api/version'); + if (!resp.ok) return null; + const json = (await resp.json()) as Partial; + return isAppVersionInfo(json.version) ? json.version : null; + } catch { + return null; + } +} + export async function fetchSkillExample(id: string): Promise { try { const resp = await fetch(`/api/skills/${encodeURIComponent(id)}/example`); diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index ca824535c..65a0c9477 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -1,5 +1,7 @@ import type { AgentInfo, + AppVersionInfo, + AppVersionResponse, AudioKind, ChatAttachment, ChatMessage, @@ -110,6 +112,8 @@ export interface PromptTemplateDetail extends PromptTemplateSummary { export type { AgentInfo, + AppVersionInfo, + AppVersionResponse, AudioKind, Conversation, DeployConfigResponse, diff --git a/packages/contracts/src/api/version.ts b/packages/contracts/src/api/version.ts new file mode 100644 index 000000000..5f04f1a96 --- /dev/null +++ b/packages/contracts/src/api/version.ts @@ -0,0 +1,11 @@ +export interface AppVersionInfo { + version: string; + channel: string; + packaged: boolean; + platform: string; + arch: string; +} + +export interface AppVersionResponse { + version: AppVersionInfo; +} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 6d8dd1f57..c6abe8b5e 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -7,6 +7,7 @@ export * from './api/files'; export * from './api/projects'; export * from './api/proxy'; export * from './api/registry'; +export * from './api/version'; export * from './sse/common'; export * from './sse/chat'; export * from './sse/proxy';