From 47d6d3c6b16bdf86e33c69e0ccefd41b98e964f2 Mon Sep 17 00:00:00 2001 From: Dekode1859 Date: Sun, 16 Aug 2026 00:53:48 +0530 Subject: [PATCH 1/3] fix: migrate bundled agents during app upgrades - Merge shipped Persona agent definitions into existing writable app data\n- Verify profile-pdf is present in frozen bundles\n- Add upgrade, bundle declaration, and release smoke regression coverage\n- Bump Persona to 0.1.6 --- main.py | 69 ++++++++++++++++++++++++++++++- pyproject.toml | 2 +- spiritus.bundle.toml | 2 +- tests/test_profile_import_runs.py | 48 +++++++++++++++++++++ uv.lock | 2 +- 5 files changed, 118 insertions(+), 5 deletions(-) diff --git a/main.py b/main.py index 195e76a..575eb34 100644 --- a/main.py +++ b/main.py @@ -11,6 +11,7 @@ """ from __future__ import annotations +import json import os import sys from pathlib import Path @@ -30,6 +31,57 @@ def _bundle_root() -> Path: return Path(__file__).resolve().parent +def _sync_bundled_agent_config( + bundle_root: Path | None = None, + app_data_root: Path | None = None, +) -> bool: + """Merge the packaged Persona agents into writable app data. + + Spiritus seed files are intentionally copied only when they do not exist, + so an upgrade can otherwise keep an old ``opencode.json`` forever. Persona + owns the agents shipped in that file; merge those definitions on startup + while preserving user-owned agents and unrelated configuration. + """ + source_path = (bundle_root or _bundle_root()) / "opencode.json" + target_root = app_data_root or project_root(Path(__file__).resolve().parent, "persona") + target_path = target_root / "opencode.json" + if not source_path.is_file(): + return False + + source = json.loads(source_path.read_text(encoding="utf-8-sig")) + if not isinstance(source, dict): + raise ValueError(f"bundled OpenCode config must be an object: {source_path}") + source_agents = source.get("agent") or {} + if not isinstance(source_agents, dict): + raise ValueError(f"bundled OpenCode agents must be an object: {source_path}") + + if target_path.is_file(): + target = json.loads(target_path.read_text(encoding="utf-8-sig")) + if not isinstance(target, dict): + raise ValueError(f"writable OpenCode config must be an object: {target_path}") + else: + target = {} + + target_agents = target.get("agent") + if not isinstance(target_agents, dict): + target_agents = {} + target["agent"] = target_agents + + changed = False + for name, definition in source_agents.items(): + if target_agents.get(name) != definition: + target_agents[name] = definition + changed = True + + if changed or not target_path.is_file(): + target_root.mkdir(parents=True, exist_ok=True) + target_path.write_text( + json.dumps(target, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + return changed + + def _verify_bundle() -> None: """Smoke-test resources used by CI without opening a native window.""" if not is_bundled(): @@ -62,8 +114,18 @@ def _verify_bundle() -> None: "Spiritus did not configure the bundled Playwright browser path" ) seeded_config = project_root(Path(__file__).resolve().parent, "persona") / "opencode.json" + try: + _sync_bundled_agent_config(root, seeded_config.parent) + except (OSError, ValueError, json.JSONDecodeError) as exc: + raise SystemExit(f"could not prepare writable OpenCode config: {exc}") from exc if not seeded_config.is_file(): raise SystemExit(f"missing seeded writable OpenCode config: {seeded_config}") + try: + config = json.loads(seeded_config.read_text(encoding="utf-8-sig")) + except (OSError, json.JSONDecodeError) as exc: + raise SystemExit(f"invalid writable OpenCode config: {seeded_config}: {exc}") from exc + if "profile-pdf" not in (config.get("agent") or {}): + raise SystemExit(f"writable OpenCode config is missing profile-pdf: {seeded_config}") print(f"Persona bundle OK (OpenCode {version})") @@ -87,8 +149,11 @@ def _verify_bundle() -> None: if "--check-bundle" in sys.argv: _verify_bundle() elif "--check-updates" in sys.argv: - import json - print(json.dumps(check_for_updates(), sort_keys=True)) else: + if is_bundled(): + try: + _sync_bundled_agent_config() + except (OSError, ValueError, json.JSONDecodeError) as exc: + raise SystemExit(f"could not prepare writable OpenCode config: {exc}") from exc run(APP) diff --git a/pyproject.toml b/pyproject.toml index 6628c85..c8aed23 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "persona" -version = "0.1.5" +version = "0.1.6" description = "Persona - a Spiritus application for profile, job tracking, and tailored applications" requires-python = ">=3.11" dependencies = [ diff --git a/spiritus.bundle.toml b/spiritus.bundle.toml index cf130d1..3d3bed7 100644 --- a/spiritus.bundle.toml +++ b/spiritus.bundle.toml @@ -4,7 +4,7 @@ platforms = ["windows", "macos"] entrypoint = "main.py" name = "Persona" app_id = "persona" -version = "0.1.5" +version = "0.1.6" console = false bundle_identifier = "com.dekode.persona" diff --git a/tests/test_profile_import_runs.py b/tests/test_profile_import_runs.py index 0ae7873..8735047 100644 --- a/tests/test_profile_import_runs.py +++ b/tests/test_profile_import_runs.py @@ -10,6 +10,7 @@ from unittest.mock import patch from app_bridge import PersonaBridge +from main import _sync_bundled_agent_config from spiritus.config import AppConfig, WorkspaceFolder @@ -110,6 +111,53 @@ def test_profile_schema_is_declared_as_a_bundle_resource(self) -> None: spec = tomllib.loads(Path("spiritus.bundle.toml").read_text(encoding="utf-8")) self.assertIn("schemas=schemas", spec["datas"]) + def test_profile_pdf_agent_is_declared_in_the_packaged_config(self) -> None: + config = json.loads(Path("opencode.json").read_text(encoding="utf-8")) + self.assertIn("profile-pdf", config["agent"]) + spec = tomllib.loads(Path("spiritus.bundle.toml").read_text(encoding="utf-8")) + self.assertIn("opencode.json=.", spec["datas"]) + self.assertEqual(spec["seed_files"]["opencode.json"], "opencode.json") + + def test_bundled_agents_migrate_into_existing_app_data(self) -> None: + bundle = self.root / "bundle" + app_data = self.root / "app-data" + bundle.mkdir() + app_data.mkdir() + (bundle / "opencode.json").write_text(json.dumps({ + "agent": { + "profile": {"prompt": "new prompt"}, + "profile-pdf": {"mode": "primary", "prompt": "verbatim"}, + }, + "model": "keep-the-user-setting", + }), encoding="utf-8") + (app_data / "opencode.json").write_text(json.dumps({ + "agent": { + "profile": {"prompt": "old prompt"}, + "custom": {"prompt": "user agent"}, + }, + "model": "user-selected-model", + }), encoding="utf-8") + + self.assertTrue(_sync_bundled_agent_config(bundle, app_data)) + migrated = json.loads((app_data / "opencode.json").read_text(encoding="utf-8")) + self.assertEqual(migrated["agent"]["profile"], {"prompt": "new prompt"}) + self.assertEqual(migrated["agent"]["profile-pdf"]["mode"], "primary") + self.assertEqual(migrated["agent"]["custom"], {"prompt": "user agent"}) + self.assertEqual(migrated["model"], "user-selected-model") + self.assertFalse(_sync_bundled_agent_config(bundle, app_data)) + + def test_bundled_agents_are_seeded_when_app_data_is_new(self) -> None: + bundle = self.root / "bundle" + app_data = self.root / "new-app-data" + bundle.mkdir() + (bundle / "opencode.json").write_text(json.dumps({ + "agent": {"profile-pdf": {"mode": "primary"}}, + }), encoding="utf-8") + + self.assertTrue(_sync_bundled_agent_config(bundle, app_data)) + migrated = json.loads((app_data / "opencode.json").read_text(encoding="utf-8")) + self.assertIn("profile-pdf", migrated["agent"]) + def test_schema_failure_is_a_durable_profile_diagnostic(self) -> None: near_miss = _empty_profile() near_miss["skill_buckets"] = [{"name": "Tools", "skills": ["Python"]}] diff --git a/uv.lock b/uv.lock index a5ca53e..e4ad777 100644 --- a/uv.lock +++ b/uv.lock @@ -312,7 +312,7 @@ wheels = [ [[package]] name = "persona" -version = "0.1.5" +version = "0.1.6" source = { virtual = "." } dependencies = [ { name = "jsonschema" }, From 244ef5fb96366f2c0bc5868fffa2c679e311612d Mon Sep 17 00:00:00 2001 From: Dekode1859 Date: Sun, 16 Aug 2026 01:17:30 +0530 Subject: [PATCH 2/3] feat: show startup update notifications - Check for updates when Persona finishes opening\n- Add top-right available, downloading, ready, and installer states\n- Persist verified downloads so later launches can offer them again\n- Add a development-only update simulation and lifecycle tests --- persona_updates.py | 180 +++++++++++++++++++++++++++++++++- tests/test_persona_updates.py | 125 +++++++++++++++++++++++ ui/app.js | 78 ++++++++++++++- ui/index.html | 1 + ui/style.css | 14 +++ 5 files changed, 392 insertions(+), 6 deletions(-) create mode 100644 tests/test_persona_updates.py diff --git a/persona_updates.py b/persona_updates.py index 0931658..9a0e614 100644 --- a/persona_updates.py +++ b/persona_updates.py @@ -1,7 +1,9 @@ """Persona's small integration surface for Spiritus update discovery.""" from __future__ import annotations +import hashlib import json +import os import platform import subprocess import sys @@ -13,6 +15,7 @@ StagedUpdate, SubprocessInstallerHandoff, UpdateCheck, + UpdateArtifact, UpdateClient, UpdateConfig, UpdateError, @@ -22,6 +25,7 @@ _STAGED_UPDATE: StagedUpdate | None = None +_STAGED_VERSION: str | None = None def _resource_root() -> Path: @@ -70,6 +74,111 @@ def _staging_dir(root: Path) -> Path: return root / ".spiritus-update-staging" +def _staged_manifest_path(root: Path) -> Path: + return _staging_dir(root) / "staged.json" + + +def _staged_payload(staged: StagedUpdate, version: str, current_version: str) -> dict[str, object]: + return { + "status": "ready", + "available": False, + "current_version": current_version, + "version": version, + "staged_path": str(staged.path), + "staged_bytes": staged.bytes, + "staged_sha256": staged.sha256, + } + + +def _load_staged_update(root: Path) -> tuple[StagedUpdate, str] | None: + manifest_path = _staged_manifest_path(root) + if not manifest_path.is_file(): + return None + try: + payload = json.loads(manifest_path.read_text(encoding="utf-8")) + artifact_payload = payload["artifact"] + artifact = UpdateArtifact( + filename=str(artifact_payload["filename"]), + url=str(artifact_payload["url"]), + platform=artifact_payload.get("platform"), + architecture=artifact_payload.get("architecture"), + kind=artifact_payload.get("kind"), + sha256=artifact_payload.get("sha256"), + signature_url=artifact_payload.get("signature_url"), + size=artifact_payload.get("size"), + ) + staged_path = Path(str(payload["path"])).resolve() + staging_dir = _staging_dir(root).resolve() + if staged_path.parent != staging_dir or not staged_path.is_file(): + raise ValueError("staged update file is missing") + staged = StagedUpdate( + artifact=artifact, + path=staged_path, + bytes=int(payload["bytes"]), + sha256=str(payload["sha256"]), + ) + version = str(payload["version"]).strip() + if not version: + raise ValueError("staged update version is missing") + return staged, version + except (OSError, KeyError, TypeError, ValueError, json.JSONDecodeError): + manifest_path.unlink(missing_ok=True) + return None + + +def _remember_staged_update(root: Path, staged: StagedUpdate, version: str) -> None: + manifest_path = _staged_manifest_path(root) + manifest_path.parent.mkdir(parents=True, exist_ok=True) + payload = { + "version": version, + "path": str(staged.path.resolve()), + "bytes": staged.bytes, + "sha256": staged.sha256, + "artifact": { + "filename": staged.artifact.filename, + "url": staged.artifact.url, + "platform": staged.artifact.platform, + "architecture": staged.artifact.architecture, + "kind": staged.artifact.kind, + "sha256": staged.artifact.sha256, + "signature_url": staged.artifact.signature_url, + "size": staged.artifact.size, + }, + } + temporary = manifest_path.with_suffix(".json.tmp") + temporary.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + os.replace(temporary, manifest_path) + + +def _test_mode() -> str: + """Return the explicit development-only update simulation mode.""" + if is_bundled(): + return "" + return os.environ.get("PERSONA_UPDATE_TEST_MODE", "").strip().lower() + + +def _test_version() -> str: + return os.environ.get("PERSONA_UPDATE_TEST_VERSION", "99.0.0").strip() or "99.0.0" + + +def _create_test_staged_update(root: Path, version: str) -> StagedUpdate: + payload = b"Persona development update fixture; do not install." + staging = _staging_dir(root) + staging.mkdir(parents=True, exist_ok=True) + path = staging / f"Persona-Setup-{version}.exe" + path.write_bytes(payload) + digest = hashlib.sha256(payload).hexdigest() + artifact = UpdateArtifact( + filename=path.name, + url="https://example.test/persona-development-update.exe", + sha256=digest, + size=len(payload), + ) + staged = StagedUpdate(artifact, path, len(payload), digest) + _remember_staged_update(root, staged, version) + return staged + + def _result_payload(result: UpdateCheck) -> dict[str, object]: payload: dict[str, object] = { "status": result.status.value, @@ -97,7 +206,29 @@ def _result_payload(result: UpdateCheck) -> dict[str, object]: def check_for_updates(*, session: Any = None, root: Path | None = None) -> dict[str, object]: """Return JSON-safe update status for the Persona bridge/UI.""" + global _STAGED_UPDATE, _STAGED_VERSION + root = root or _resource_root() + mode = _test_mode() + current_version = _current_version(root) + if mode == "available": + return { + "status": "available", + "available": True, + "current_version": current_version, + "version": _test_version(), + } + if mode == "ready": + staged = _load_staged_update(root) + if staged is None: + version = _test_version() + staged = (_create_test_staged_update(root, version), version) + _STAGED_UPDATE, _STAGED_VERSION = staged + return _staged_payload(_STAGED_UPDATE, _STAGED_VERSION, current_version) + existing = _load_staged_update(root) + if existing: + _STAGED_UPDATE, _STAGED_VERSION = existing + return _staged_payload(_STAGED_UPDATE, _STAGED_VERSION, _current_version(root)) return _result_payload(_client(root, session=session).check()) @@ -108,9 +239,30 @@ def stage_update( destination: Path | None = None, ) -> dict[str, object]: """Download and verify the selected installer without launching it.""" - global _STAGED_UPDATE + global _STAGED_UPDATE, _STAGED_VERSION root = root or _resource_root() + mode = _test_mode() + current_version = _current_version(root) + if mode in {"available", "ready"}: + staged = _load_staged_update(root) + if staged is None: + version = _test_version() + staged = (_create_test_staged_update(root, version), version) + _STAGED_UPDATE, _STAGED_VERSION = staged + return { + "status": "available", + "available": True, + "current_version": current_version, + "version": _STAGED_VERSION, + "staged_path": str(_STAGED_UPDATE.path), + "staged_bytes": _STAGED_UPDATE.bytes, + "staged_sha256": _STAGED_UPDATE.sha256, + } + existing = _load_staged_update(root) + if existing: + _STAGED_UPDATE, _STAGED_VERSION = existing + return _staged_payload(_STAGED_UPDATE, _STAGED_VERSION, _current_version(root)) client = _client(root, session=session) result = client.check() payload = _result_payload(result) @@ -122,6 +274,17 @@ def stage_update( payload.update({"status": "error", "error": str(exc)}) return payload _STAGED_UPDATE = staged + _STAGED_VERSION = payload.get("version") if isinstance(payload.get("version"), str) else None + if not _STAGED_VERSION: + _STAGED_UPDATE = None + return {"status": "error", "error": "The update did not include a release version."} + try: + _remember_staged_update(root, staged, _STAGED_VERSION) + except OSError as exc: + _STAGED_UPDATE = None + _STAGED_VERSION = None + payload.update({"status": "error", "error": f"Could not save the downloaded update: {exc}"}) + return payload payload.update( { "staged_path": str(staged.path), @@ -134,21 +297,34 @@ def stage_update( def launch_staged_update(path: str | Path) -> dict[str, object]: """Launch the verified installer and close Persona so it can update safely.""" - global _STAGED_UPDATE + global _STAGED_UPDATE, _STAGED_VERSION staged = _STAGED_UPDATE + if staged is None: + loaded = _load_staged_update(_resource_root()) + if loaded: + staged, _STAGED_VERSION = loaded + _STAGED_UPDATE = staged if staged is None: raise UpdateInstallerError("no verified installer is ready to launch") candidate = Path(path).resolve() if candidate != staged.path.resolve(): raise UpdateInstallerError("installer path does not match the verified update") + if _test_mode() in {"available", "ready"}: + result = {"status": "launched", "staged_path": str(candidate), "test_mode": True} + _STAGED_UPDATE = None + _STAGED_VERSION = None + _staged_manifest_path(_resource_root()).unlink(missing_ok=True) + return result if sys.platform == "darwin": subprocess.Popen(["open", str(staged.path)], shell=False) else: SubprocessInstallerHandoff().launch(staged) _STAGED_UPDATE = None + _STAGED_VERSION = None + _staged_manifest_path(_resource_root()).unlink(missing_ok=True) try: import webview diff --git a/tests/test_persona_updates.py b/tests/test_persona_updates.py new file mode 100644 index 0000000..ac0cb39 --- /dev/null +++ b/tests/test_persona_updates.py @@ -0,0 +1,125 @@ +"""Persona-owned update lifecycle and restart-persistence tests.""" +from __future__ import annotations + +import hashlib +import json +import os +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +import persona_updates +from spiritus import ( + ReleaseCandidate, + StagedUpdate, + UpdateArtifact, + UpdateCheck, + UpdateStatus, +) + + +class _FakeClient: + def __init__(self, staged: StagedUpdate): + self.staged = staged + self.artifact = staged.artifact + + def check(self) -> UpdateCheck: + return UpdateCheck( + UpdateStatus.AVAILABLE, + "0.1.6", + candidate=ReleaseCandidate(version="0.2.0", artifacts=(self.artifact,)), + artifact=self.artifact, + ) + + def stage_update(self, result: UpdateCheck, destination: Path) -> StagedUpdate: + destination.mkdir(parents=True, exist_ok=True) + target = destination / self.staged.path.name + target.write_bytes(self.staged.path.read_bytes()) + return StagedUpdate( + artifact=self.staged.artifact, + path=target, + bytes=self.staged.bytes, + sha256=self.staged.sha256, + ) + + +class PersonaUpdateTests(unittest.TestCase): + def setUp(self) -> None: + persona_updates._STAGED_UPDATE = None + persona_updates._STAGED_VERSION = None + + def tearDown(self) -> None: + persona_updates._STAGED_UPDATE = None + persona_updates._STAGED_VERSION = None + os.environ.pop("PERSONA_UPDATE_TEST_MODE", None) + os.environ.pop("PERSONA_UPDATE_TEST_VERSION", None) + + def test_staged_update_is_restored_after_startup(self) -> None: + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + (root / "pyproject.toml").write_text('[project]\nversion = "0.1.6"\n', encoding="utf-8") + payload = b"fake verified installer" + source = root / "source.exe" + source.write_bytes(payload) + digest = hashlib.sha256(payload).hexdigest() + artifact = UpdateArtifact( + filename="Persona-Setup-0.2.0.exe", + url="https://example.test/Persona-Setup-0.2.0.exe", + sha256=digest, + size=len(payload), + ) + staged = StagedUpdate(artifact, source, len(payload), digest) + client = _FakeClient(staged) + + with patch.object(persona_updates, "_client", return_value=client): + result = persona_updates.stage_update(root=root) + self.assertEqual(result["status"], "available") + self.assertEqual(result["version"], "0.2.0") + self.assertTrue((root / ".spiritus-update-staging" / "staged.json").is_file()) + + persona_updates._STAGED_UPDATE = None + persona_updates._STAGED_VERSION = None + with patch.object(persona_updates, "_client", side_effect=AssertionError("network called")): + restored = persona_updates.check_for_updates(root=root) + self.assertEqual(restored["status"], "ready") + self.assertEqual(restored["version"], "0.2.0") + self.assertTrue(Path(restored["staged_path"]).is_file()) + + def test_invalid_staged_manifest_is_discarded(self) -> None: + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + (root / "pyproject.toml").write_text('[project]\nversion = "0.1.6"\n', encoding="utf-8") + staging = root / ".spiritus-update-staging" + staging.mkdir() + (staging / "staged.json").write_text(json.dumps({"path": "missing.exe"}), encoding="utf-8") + + with patch.object(persona_updates, "_client") as client: + client.return_value.check.return_value = UpdateCheck(UpdateStatus.CURRENT, "0.1.6") + result = persona_updates.check_for_updates(root=root) + + self.assertEqual(result["status"], "current") + self.assertFalse((staging / "staged.json").exists()) + + def test_development_update_simulation_covers_toast_lifecycle(self) -> None: + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + (root / "pyproject.toml").write_text('[project]\nversion = "0.1.6"\n', encoding="utf-8") + os.environ["PERSONA_UPDATE_TEST_MODE"] = "available" + os.environ["PERSONA_UPDATE_TEST_VERSION"] = "0.2.0" + + available = persona_updates.check_for_updates(root=root) + self.assertEqual(available["status"], "available") + staged = persona_updates.stage_update(root=root) + self.assertEqual(staged["status"], "available") + self.assertTrue(Path(staged["staged_path"]).is_file()) + + os.environ["PERSONA_UPDATE_TEST_MODE"] = "ready" + persona_updates._STAGED_UPDATE = None + persona_updates._STAGED_VERSION = None + ready = persona_updates.check_for_updates(root=root) + self.assertEqual(ready["status"], "ready") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/ui/app.js b/ui/app.js index b0fa22d..9e9d5d4 100644 --- a/ui/app.js +++ b/ui/app.js @@ -5446,14 +5446,67 @@ const updateFlow = { state: 'idle', stagedPath: null, version: null, + currentVersion: null, + surface: 'settings', }; +function hideUpdateToast() { + document.getElementById('update-toast')?.classList.add('hidden'); +} + +function renderUpdateToast(state, details = {}) { + const toast = document.getElementById('update-toast'); + if (!toast) return; + const version = details.version || updateFlow.version || 'latest'; + const current = details.current_version || updateFlow.currentVersion || 'current version'; + const title = { + available: 'Persona update available', + downloading: 'Updating Persona', + ready: 'Persona update ready', + launching: 'Starting Persona update', + download_error: 'Persona update could not download', + launch_error: 'Persona update could not start', + }[state]; + if (!title) { + hideUpdateToast(); + return; + } + let icon = 'download'; + let copy = `Version ${version} is available. You are running ${current}.`; + let actions = 'Later'; + if (state === 'downloading') { + icon = 'refresh-cw'; + copy = `Downloading and verifying Persona ${version}…`; + actions = ''; + } else if (state === 'ready') { + icon = 'circle-check'; + copy = `Persona ${version} is downloaded and verified. Apply it now, or leave it for the next time you open Persona.`; + actions = 'LaterApply update now'; + } else if (state === 'launching') { + icon = 'refresh-cw'; + copy = 'Starting the installer. Persona will close now.'; + actions = ''; + } else if (state === 'download_error' || state === 'launch_error') { + icon = 'triangle-alert'; + copy = details.message || 'Try again from Settings → Updates.'; + actions = 'Dismiss'; + } else { + actions = 'Update laterUpdate now'; + } + const iconMarkup = ['downloading', 'launching'].includes(state) + ? '' + : ``; + toast.innerHTML = `
${iconMarkup}${title}
${escHtml(copy)}
${actions}
`; + toast.classList.remove('hidden'); +} + function renderUpdateFlow(state, details = {}) { const button = document.getElementById('btn-update-action'); const status = document.getElementById('updates-status'); if (!button || !status) return; updateFlow.state = state; if (details.version) updateFlow.version = details.version; + if (details.current_version) updateFlow.currentVersion = details.current_version; const version = details.version || updateFlow.version || 'latest'; const busy = ['checking', 'downloading', 'launching'].includes(state); button.disabled = busy; @@ -5496,13 +5549,18 @@ function renderUpdateFlow(state, details = {}) { ['checking', 'downloading', 'launching'].includes(state) ? 'status-info' : state === 'idle' ? '' : 'status-err' }`; + if (updateFlow.surface === 'startup') renderUpdateToast(state, details); } function applyUpdateCheckResult(result) { - updateFlow.stagedPath = null; + if (result.status !== 'ready') updateFlow.stagedPath = null; updateFlow.version = result.version || result.current_version || null; + updateFlow.currentVersion = result.current_version || updateFlow.currentVersion; if (result.status === 'available') { - renderUpdateFlow('available', { version: result.version }); + renderUpdateFlow('available', { version: result.version, current_version: result.current_version }); + } else if (result.status === 'ready') { + updateFlow.stagedPath = result.staged_path || null; + renderUpdateFlow('ready', { version: result.version, current_version: result.current_version }); } else if (result.status === 'current') { renderUpdateFlow('current', { version: result.version, current_version: result.current_version }); } else if (result.status === 'no_compatible_asset') { @@ -5512,7 +5570,8 @@ function applyUpdateCheckResult(result) { } } -async function checkForUpdates() { +async function checkForUpdates({ startup = false } = {}) { + updateFlow.surface = startup ? 'startup' : 'settings'; renderUpdateFlow('checking'); try { applyUpdateCheckResult(await bridge.updatesCheck()); @@ -5525,6 +5584,11 @@ async function stagePersonaUpdate() { renderUpdateFlow('downloading'); try { const result = await bridge.updatesStage(); + if (result.status === 'ready') { + updateFlow.stagedPath = result.staged_path || null; + renderUpdateFlow('ready', { version: result.version, current_version: result.current_version }); + return; + } if (result.status !== 'available') { if (result.status === 'error') { renderUpdateFlow('download_error', { message: result.error || 'The update could not be downloaded. Try again.' }); @@ -5537,7 +5601,7 @@ async function stagePersonaUpdate() { throw new Error('The installer was not returned by the update service.'); } updateFlow.stagedPath = result.staged_path; - renderUpdateFlow('ready', { version: result.version }); + renderUpdateFlow('ready', { version: result.version, current_version: result.current_version }); } catch (e) { renderUpdateFlow('download_error', { message: `The update could not be downloaded. ${e.message}` }); } @@ -6397,6 +6461,11 @@ function wire() { () => document.getElementById('analysis-history-dialog').hide()); document.getElementById('btn-save-key').addEventListener('click', saveKey); document.getElementById('btn-update-action').addEventListener('click', runUpdateAction); + document.getElementById('update-toast').addEventListener('click', e => { + const action = e.target.closest('[data-update-toast]')?.dataset.updateToast; + if (action === 'later') hideUpdateToast(); + if (action === 'now') runUpdateAction(); + }); document.getElementById('btn-set-model').addEventListener('click', setModel); document.getElementById('connected-list').addEventListener('click', e => { const btn = e.target.closest('.provider-tag-remove'); @@ -6520,6 +6589,7 @@ async function init() { const [hasProfile] = await Promise.all([loadProfile(), loadBrowserProfileStatus()]); if (hasProfile) renderProfileSections(); showProfileSubview('main'); + checkForUpdates({ startup: true }); } init(); diff --git a/ui/index.html b/ui/index.html index 1a1c5ae..d39cb4c 100644 --- a/ui/index.html +++ b/ui/index.html @@ -427,6 +427,7 @@

Paste to Analyze +