diff --git a/packages/app/src/app/app.tsx b/packages/app/src/app/app.tsx index e26fee49..548019c8 100644 --- a/packages/app/src/app/app.tsx +++ b/packages/app/src/app/app.tsx @@ -2043,6 +2043,8 @@ export default function App() { setPendingUpdate, updateEnv, setUpdateEnv, + updateChannel, + setUpdateChannel, checkForUpdates, downloadUpdate, installUpdateAndRestart, @@ -3340,6 +3342,11 @@ export default function App() { } } + const storedUpdateChannel = window.localStorage.getItem("openwork.updateChannel"); + if (storedUpdateChannel === "stable" || storedUpdateChannel === "prerelease") { + setUpdateChannel(storedUpdateChannel); + } + const storedNotionStatus = window.localStorage.getItem("openwork.notionStatus"); if ( storedNotionStatus === "disconnected" || @@ -3655,6 +3662,15 @@ export default function App() { } }); + createEffect(() => { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem("openwork.updateChannel", updateChannel()); + } catch { + // ignore + } + }); + createEffect(() => { if (typeof window === "undefined") return; try { @@ -4059,6 +4075,8 @@ export default function App() { checkForUpdates: () => checkForUpdates(), downloadUpdate: () => downloadUpdate(), installUpdateAndRestart, + updateChannel: updateChannel(), + setUpdateChannel, anyActiveRuns: anyActiveRuns(), engineSource: engineSource(), setEngineSource, diff --git a/packages/app/src/app/context/updater.ts b/packages/app/src/app/context/updater.ts index 98182791..4c72f693 100644 --- a/packages/app/src/app/context/updater.ts +++ b/packages/app/src/app/context/updater.ts @@ -1,7 +1,7 @@ import { createSignal } from "solid-js"; import type { UpdateHandle } from "../types"; -import type { UpdaterEnvironment } from "../lib/tauri"; +import type { UpdateChannel, UpdaterEnvironment } from "../lib/tauri"; export type UpdateStatus = | { state: "idle"; lastCheckedAt: number | null } @@ -16,15 +16,18 @@ export type UpdateStatus = notes?: string; } | { state: "ready"; lastCheckedAt: number; version: string; notes?: string } + | { state: "applying"; lastCheckedAt: number; version: string; notes?: string } + | { state: "restart-required"; lastCheckedAt: number; version: string; notes?: string } | { state: "error"; lastCheckedAt: number | null; message: string }; -export type PendingUpdate = { update: UpdateHandle; version: string; notes?: string } | null; +export type PendingUpdate = { version: string; notes?: string; date?: string } | null; export function createUpdaterState() { const [updateAutoCheck, setUpdateAutoCheck] = createSignal(true); const [updateStatus, setUpdateStatus] = createSignal({ state: "idle", lastCheckedAt: null }); const [pendingUpdate, setPendingUpdate] = createSignal(null); const [updateEnv, setUpdateEnv] = createSignal(null); + const [updateChannel, setUpdateChannel] = createSignal("stable"); return { updateAutoCheck, @@ -35,5 +38,7 @@ export function createUpdaterState() { setPendingUpdate, updateEnv, setUpdateEnv, + updateChannel, + setUpdateChannel, } as const; } diff --git a/packages/app/src/app/lib/tauri.ts b/packages/app/src/app/lib/tauri.ts index 0f3c3b0b..829d0707 100644 --- a/packages/app/src/app/lib/tauri.ts +++ b/packages/app/src/app/lib/tauri.ts @@ -1,4 +1,4 @@ -import { invoke } from "@tauri-apps/api/core"; +import { Channel, invoke } from "@tauri-apps/api/core"; import { fetch as tauriFetch } from "@tauri-apps/plugin-http"; import { isTauriRuntime } from "../utils"; import { validateMcpServerName } from "../mcp"; @@ -496,10 +496,40 @@ export type UpdaterEnvironment = { appBundlePath: string | null; }; +export type UpdateChannel = "stable" | "prerelease"; + +export type UpdaterCheckResult = { + version: string; + currentVersion: string; + date?: string; + notes?: string; +} | null; + +export type UpdaterDownloadEvent = + | { event: "Started"; data: { contentLength?: number | null } } + | { event: "Progress"; data: { chunkLength: number } } + | { event: "Finished"; data: Record }; + export async function updaterEnvironment(): Promise { return invoke("updater_environment"); } +export async function updaterCheck(channel: UpdateChannel): Promise { + return invoke("updater_check", { channel }); +} + +export async function updaterDownload( + onEvent: (event: UpdaterDownloadEvent) => void, +): Promise { + const channel = new Channel(); + channel.onmessage = onEvent; + await invoke("updater_download", { onEvent: channel }); +} + +export async function updaterInstall(): Promise { + await invoke("updater_install"); +} + export async function readOpencodeConfig( scope: "project" | "global", projectDir: string, diff --git a/packages/app/src/app/pages/dashboard.tsx b/packages/app/src/app/pages/dashboard.tsx index e47d278b..f542b7ca 100644 --- a/packages/app/src/app/pages/dashboard.tsx +++ b/packages/app/src/app/pages/dashboard.tsx @@ -228,6 +228,8 @@ export type DashboardViewProps = { downloadedBytes?: number; message?: string; } | null; + updateChannel: "stable" | "prerelease"; + setUpdateChannel: (value: "stable" | "prerelease") => void; updateEnv: { supported?: boolean; reason?: string | null } | null; appVersion: string | null; checkForUpdates: () => void; @@ -1025,6 +1027,8 @@ export default function DashboardView(props: DashboardViewProps) { downloadUpdate={props.downloadUpdate} installUpdateAndRestart={props.installUpdateAndRestart} anyActiveRuns={props.anyActiveRuns} + updateChannel={props.updateChannel} + setUpdateChannel={props.setUpdateChannel} onResetStartupPreference={props.onResetStartupPreference} openResetModal={props.openResetModal} resetModalBusy={props.resetModalBusy} diff --git a/packages/app/src/app/pages/settings.tsx b/packages/app/src/app/pages/settings.tsx index cd891af9..279a571b 100644 --- a/packages/app/src/app/pages/settings.tsx +++ b/packages/app/src/app/pages/settings.tsx @@ -96,16 +96,18 @@ export type SettingsViewProps = { setThemeMode: (value: "light" | "dark" | "system") => void; updateAutoCheck: boolean; toggleUpdateAutoCheck: () => void; - updateStatus: { - state: string; - lastCheckedAt?: number | null; - version?: string; - date?: string; - notes?: string; - totalBytes?: number | null; - downloadedBytes?: number; - message?: string; - } | null; + updateStatus: { + state: string; + lastCheckedAt?: number | null; + version?: string; + date?: string; + notes?: string; + totalBytes?: number | null; + downloadedBytes?: number; + message?: string; + } | null; + updateChannel: "stable" | "prerelease"; + setUpdateChannel: (value: "stable" | "prerelease") => void; updateEnv: { supported?: boolean; reason?: string | null } | null; appVersion: string | null; checkForUpdates: () => void; @@ -744,7 +746,7 @@ function OwpenbotSettings(props: { } export default function SettingsView(props: SettingsViewProps) { - const updateState = () => props.updateStatus?.state ?? "idle"; + const updateState = () => props.updateStatus?.state ?? "idle"; const updateNotes = () => props.updateStatus?.notes ?? null; const updateVersion = () => props.updateStatus?.version ?? null; const updateDate = () => props.updateStatus?.date ?? null; @@ -782,13 +784,16 @@ export default function SettingsView(props: SettingsViewProps) { return "bg-red-7/10 text-red-11 border-red-7/20"; case "checking": case "downloading": + case "applying": return "bg-gray-4/60 text-gray-11 border-gray-7/50"; default: return "bg-gray-4/60 text-gray-11 border-gray-7/50"; } }); - const updateToolbarSpinning = createMemo(() => updateState() === "checking" || updateState() === "downloading"); + const updateToolbarSpinning = createMemo( + () => updateState() === "checking" || updateState() === "downloading" || updateState() === "applying", + ); const updateToolbarLabel = createMemo(() => { const state = updateState(); @@ -799,12 +804,18 @@ export default function SettingsView(props: SettingsViewProps) { if (state === "ready") { return `Ready to install${version ? ` · v${version}` : ""}`; } + if (state === "restart-required") { + return `Restart to finish${version ? ` · v${version}` : ""}`; + } if (state === "downloading") { const downloaded = updateDownloadedBytes() ?? 0; const total = updateTotalBytes(); const progress = total != null ? `${formatBytes(downloaded)} / ${formatBytes(total)}` : formatBytes(downloaded); return `Downloading ${progress}`; } + if (state === "applying") { + return "Applying update"; + } if (state === "checking") { return "Checking for updates"; } @@ -817,7 +828,7 @@ export default function SettingsView(props: SettingsViewProps) { const updateToolbarActionLabel = createMemo(() => { const state = updateState(); if (state === "available") return "Download"; - if (state === "ready") return "Install"; + if (state === "ready" || state === "restart-required") return "Install"; if (state === "error") return "Retry"; if (state === "idle") return "Check"; return null; @@ -825,7 +836,7 @@ export default function SettingsView(props: SettingsViewProps) { const updateToolbarDisabled = createMemo(() => { const state = updateState(); - if (state === "checking" || state === "downloading") return true; + if (state === "checking" || state === "downloading" || state === "applying") return true; if (state === "ready" && props.anyActiveRuns) return true; return props.busy; }); @@ -837,7 +848,7 @@ export default function SettingsView(props: SettingsViewProps) { props.downloadUpdate(); return; } - if (state === "ready") { + if (state === "ready" || state === "restart-required") { props.installUpdateAndRestart(); return; } @@ -1480,22 +1491,59 @@ export default function SettingsView(props: SettingsViewProps) { when={props.updateEnv && props.updateEnv.supported === false} fallback={ <> -
-
-
Automatic checks
-
Once per day (quiet)
-
- -
+
+
+
Automatic checks
+
Once per day (quiet)
+
+ +
+ +
+
+
Update channel
+
+ {props.updateChannel === "stable" ? "Stable releases only" : "Include prereleases"} +
+
+
+ + +
+
@@ -1503,8 +1551,10 @@ export default function SettingsView(props: SettingsViewProps) { Checking... Update available: v{updateVersion()} - Downloading... - Ready to install: v{updateVersion()} + Downloading... + Ready to install: v{updateVersion()} + Applying update... + Restart required: v{updateVersion()} Update check failed Up to date @@ -1531,36 +1581,43 @@ export default function SettingsView(props: SettingsViewProps) {
- + - + - +
diff --git a/packages/app/src/app/system-state.ts b/packages/app/src/app/system-state.ts index 6dd723a9..bfd75733 100644 --- a/packages/app/src/app/system-state.ts +++ b/packages/app/src/app/system-state.ts @@ -3,7 +3,6 @@ import { createEffect, createMemo, createSignal, type Accessor } from "solid-js" import type { Session } from "@opencode-ai/sdk/v2/client"; import type { ProviderListItem } from "./types"; -import { check } from "@tauri-apps/plugin-updater"; import { relaunch } from "@tauri-apps/plugin-process"; import type { @@ -12,12 +11,17 @@ import type { ReloadReason, ReloadTrigger, ResetOpenworkMode, - UpdateHandle, } from "./types"; import { addOpencodeCacheHint, isTauriRuntime, safeStringify } from "./utils"; import { mapConfigProvidersToList } from "./utils/providers"; import { createUpdaterState } from "./context/updater"; -import { resetOpenworkState, resetOpencodeCache } from "./lib/tauri"; +import { + resetOpenworkState, + resetOpencodeCache, + updaterCheck, + updaterDownload, + updaterInstall, +} from "./lib/tauri"; import { unwrap, waitForHealthy } from "./lib/opencode"; export type NotionState = { @@ -65,6 +69,8 @@ export function createSystemState(options: { setPendingUpdate, updateEnv, setUpdateEnv, + updateChannel, + setUpdateChannel, } = updater; const [resetModalOpen, setResetModalOpen] = createSignal(false); @@ -375,7 +381,8 @@ export function createSystemState(options: { setUpdateStatus({ state: "checking", startedAt: Date.now() }); try { - const update = (await check({ timeout: 8_000 })) as unknown as UpdateHandle | null; + const channel = updateChannel(); + const update = await updaterCheck(channel); const checkedAt = Date.now(); if (!update) { @@ -384,14 +391,13 @@ export function createSystemState(options: { return; } - const notes = typeof update.body === "string" ? update.body : undefined; - setPendingUpdate({ update, version: update.version, notes }); + setPendingUpdate({ version: update.version, notes: update.notes, date: update.date }); setUpdateStatus({ state: "available", lastCheckedAt: checkedAt, version: update.version, date: update.date, - notes, + notes: update.notes, }); } catch (e) { const message = e instanceof Error ? e.message : safeStringify(e); @@ -425,21 +431,18 @@ export function createSystemState(options: { }); try { - await pending.update.download((event: any) => { - if (!event || typeof event !== "object") return; - const record = event as Record; - + await updaterDownload((event) => { setUpdateStatus((current) => { if (current.state !== "downloading") return current; - if (record.event === "Started") { + if (event.event === "Started") { const total = - record.data && typeof record.data.contentLength === "number" ? record.data.contentLength : null; + event.data && typeof event.data.contentLength === "number" ? event.data.contentLength : null; return { ...current, totalBytes: total }; } - if (record.event === "Progress") { - const chunk = record.data && typeof record.data.chunkLength === "number" ? record.data.chunkLength : 0; + if (event.event === "Progress") { + const chunk = event.data && typeof event.data.chunkLength === "number" ? event.data.chunkLength : 0; return { ...current, downloadedBytes: current.downloadedBytes + chunk }; } @@ -470,8 +473,15 @@ export function createSystemState(options: { options.setError(null); try { - await pending.update.install(); - await pending.update.close(); + setUpdateStatus((current) => { + if (current.state !== "ready") return current; + return { ...current, state: "applying" } as UpdateStatus; + }); + await updaterInstall(); + setUpdateStatus((current) => { + if (current.state !== "applying") return current; + return { ...current, state: "restart-required" } as UpdateStatus; + }); await relaunch(); } catch (e) { const message = e instanceof Error ? e.message : safeStringify(e); @@ -505,6 +515,8 @@ export function createSystemState(options: { setPendingUpdate, updateEnv, setUpdateEnv, + updateChannel, + setUpdateChannel, checkForUpdates, downloadUpdate, installUpdateAndRestart, diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index 2ad64edb..7f951392 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -2746,6 +2746,7 @@ dependencies = [ "tauri-plugin-shell", "tauri-plugin-updater", "ureq", + "url", "uuid", "walkdir", "zip 0.6.6", diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index d2036e8d..d2039661 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -23,6 +23,7 @@ tauri-plugin-opener = "2.5.3" tauri-plugin-shell = "2" uuid = { version = "1", features = ["v4"] } ureq = { version = "2.10", features = ["json"] } +url = "2.5" gethostname = "0.4" local-ip-address = "0.5" walkdir = "2.5" diff --git a/packages/desktop/src-tauri/src/commands/updater.rs b/packages/desktop/src-tauri/src/commands/updater.rs index e0f0e64c..8f39ef24 100644 --- a/packages/desktop/src-tauri/src/commands/updater.rs +++ b/packages/desktop/src-tauri/src/commands/updater.rs @@ -1,7 +1,192 @@ +use std::sync::Mutex; + +use serde::{Deserialize, Serialize}; +use tauri::ipc::Channel; +use tauri::{AppHandle, State}; +use tauri_plugin_updater::{Update, UpdaterExt}; +use url::Url; + use crate::types::UpdaterEnvironment; use crate::updater::updater_environment as updater_environment_inner; +const UPDATE_ENDPOINT_STABLE: &str = + "https://github.com/different-ai/openwork/releases/latest/download/latest.json"; +const UPDATE_RELEASES_API: &str = + "https://api.github.com/repos/different-ai/openwork/releases?per_page=20"; + +#[derive(Default)] +pub struct PendingUpdateState { + update: Mutex>, + bytes: Mutex>>, +} + +#[derive(Debug, Deserialize)] +struct GitHubRelease { + tag_name: String, + prerelease: bool, + draft: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateMetadata { + pub version: String, + pub current_version: String, + pub date: Option, + pub notes: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "event", content = "data")] +pub enum DownloadEvent { + Started { + #[serde(rename = "contentLength")] + content_length: Option, + }, + Progress { + #[serde(rename = "chunkLength")] + chunk_length: usize, + }, + Finished, +} + +fn normalize_channel(channel: Option) -> String { + match channel.as_deref() { + Some("prerelease") => "prerelease".to_string(), + _ => "stable".to_string(), + } +} + +fn fetch_prerelease_tag() -> Result { + let response = ureq::get(UPDATE_RELEASES_API) + .set("User-Agent", "openwork-updater") + .call() + .map_err(|e| format!("Failed to load release list: {e}"))?; + let releases: Vec = response + .into_json() + .map_err(|e| format!("Failed to parse release list: {e}"))?; + + releases + .into_iter() + .find(|release| release.prerelease && !release.draft) + .map(|release| release.tag_name) + .ok_or_else(|| "No prerelease releases found.".to_string()) +} + +fn resolve_update_endpoint(channel: &str) -> Result { + if channel == "stable" { + return Ok(UPDATE_ENDPOINT_STABLE.to_string()); + } + + let tag = fetch_prerelease_tag()?; + Ok(format!( + "https://github.com/different-ai/openwork/releases/download/{tag}/latest.json" + )) +} + #[tauri::command] pub fn updater_environment(_app: tauri::AppHandle) -> UpdaterEnvironment { updater_environment_inner() } + +#[tauri::command] +pub async fn updater_check( + app: AppHandle, + channel: Option, + pending: State<'_, PendingUpdateState>, +) -> Result, String> { + let channel = normalize_channel(channel); + let endpoint = Url::parse(&resolve_update_endpoint(&channel)?) + .map_err(|e: url::ParseError| e.to_string())?; + + let update = app + .updater_builder() + .endpoints(vec![endpoint]) + .map_err(|e| e.to_string())? + .build() + .map_err(|e| e.to_string())? + .check() + .await + .map_err(|e| e.to_string())?; + + let meta = update.as_ref().map(|update| UpdateMetadata { + version: update.version.clone(), + current_version: update.current_version.clone(), + date: update.date.map(|date| date.to_string()), + notes: update.body.clone(), + }); + + *pending + .update + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = update; + *pending + .bytes + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = None; + + Ok(meta) +} + +#[tauri::command] +pub async fn updater_download( + pending: State<'_, PendingUpdateState>, + on_event: Channel, +) -> Result<(), String> { + let update = pending + .update + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone(); + + let Some(update) = update else { + return Err("No pending update.".to_string()); + }; + + let mut started = false; + let bytes = update + .download( + |chunk_length, content_length| { + if !started { + started = true; + let _ = on_event.send(DownloadEvent::Started { content_length }); + } + let _ = on_event.send(DownloadEvent::Progress { chunk_length }); + }, + || { + let _ = on_event.send(DownloadEvent::Finished); + }, + ) + .await + .map_err(|e| e.to_string())?; + + *pending + .bytes + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(bytes); + Ok(()) +} + +#[tauri::command] +pub async fn updater_install(pending: State<'_, PendingUpdateState>) -> Result<(), String> { + let update = pending + .update + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take(); + let bytes = pending + .bytes + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take(); + + let Some(update) = update else { + return Err("No pending update.".to_string()); + }; + let Some(bytes) = bytes else { + return Err("No downloaded update available.".to_string()); + }; + + update.install(bytes).map_err(|e| e.to_string())?; + Ok(()) +} diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index 6690144c..b12cd5ba 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -30,7 +30,7 @@ use commands::owpenbot::{ owpenbot_pairing_list, owpenbot_qr, owpenbot_start, owpenbot_status, owpenbot_stop, }; use commands::skills::{install_skill_template, list_local_skills, uninstall_skill}; -use commands::updater::updater_environment; +use commands::updater::{updater_check, updater_download, updater_environment, updater_install, PendingUpdateState}; use commands::workspace::{ workspace_add_authorized_root, workspace_bootstrap, workspace_create, workspace_create_remote, workspace_export_config, workspace_forget, workspace_import_config, workspace_openwork_read, @@ -60,6 +60,7 @@ pub fn run() { .manage(OpenworkServerManager::default()) .manage(OwpenbotManager::default()) .manage(WorkspaceWatchState::default()) + .manage(PendingUpdateState::default()) .invoke_handler(tauri::generate_handler![ engine_start, engine_stop, @@ -101,6 +102,9 @@ pub fn run() { read_opencode_config, write_opencode_config, updater_environment, + updater_check, + updater_download, + updater_install, reset_openwork_state, reset_opencode_cache, opencode_mcp_auth,