Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions apps/desktop/loopx-control-plane/src-tauri/src/maintenance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -265,8 +265,6 @@ fn detect_environment() -> Value {

#[tauri::command]
pub fn desktop_update_status(app: AppHandle, state: State<'_, Maintenance>) -> Value {
let snapshot = state.status_snapshot();
let last_failure = state.last_failure.lock().unwrap().clone();
// Probing spawns bounded sub-processes; the boot page polls every second,
// so serve the cached block and refresh at most every ENVIRONMENT_TTL.
let environment = {
Expand All @@ -278,7 +276,21 @@ pub fn desktop_update_status(app: AppHandle, state: State<'_, Maintenance>) -> V
cache.as_ref().expect("refreshed above").1.clone()
};
let selection = crate::runtime_selection::selected(&app).ok();
json!({"state": snapshot, "startup": state.startup_timing(), "last_failure": last_failure, "app_version": app.package_info().version.to_string(), "runtime": bundled_runtime::identity(&app).ok(), "runtime_selection":{"explicit":selection.as_ref().is_some_and(|selected| selected.environment_override), "remembered":selection.as_ref().is_some_and(|selected| selected.explicit), "bundled_repair_available":selection.is_some() && !state.separately_managed_runtime.load(Ordering::Acquire)}, "rollback_available": crate::update_backup::available(&app), "environment": environment})
let runtime = bundled_runtime::identity(&app).ok();
let runtime_selection = json!({
"explicit": selection.as_ref().is_some_and(|selected| selected.environment_override),
"remembered": selection.as_ref().is_some_and(|selected| selected.explicit),
"bundled_repair_available": selection.is_some()
&& !state.separately_managed_runtime.load(Ordering::Acquire),
});
let rollback_available = crate::update_backup::available(&app);
let app_version = app.package_info().version.to_string();
let startup = state.startup_timing();
// Read mutable transaction state after the potentially blocking
// diagnostics so this response cannot revive a superseded phase.
let snapshot = state.status_snapshot();
let last_failure = state.last_failure.lock().unwrap().clone();
json!({"state": snapshot, "startup": startup, "last_failure": last_failure, "app_version": app_version, "runtime": runtime, "runtime_selection": runtime_selection, "rollback_available": rollback_available, "environment": environment})
}
#[tauri::command]
pub async fn desktop_update(
Expand Down
46 changes: 32 additions & 14 deletions apps/desktop/loopx-control-plane/static/boot.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,12 @@ const updateStatus = document.querySelector("#update-status");
const forgetSelection = document.querySelector("#forget-selection");
let nextAction = "check";
let working = false;
let actionInFlight = false;
let statusGeneration = 0;
let channelInitialized = false;
let runtimeExplicit = false;
let bundledRepairAvailable = true;
const updateWorkingPhases = ["checking","downloading","installing_app","installing_runtime","connecting"];
document.querySelector("#retry").onclick = () => location.reload();
channel.onchange = () => { channelInitialized = true; render({phase:"idle"}); };
const labels = {
Expand Down Expand Up @@ -133,13 +136,14 @@ function codeText(code, phase) {
function render(state) {
if (!state?.phase) return state;
if (state.phase === "available" && state.details?.channel !== channel.value) state = {phase:"idle"};
working = ["checking","downloading","installing_app","installing_runtime","connecting"].includes(state.phase);
update.disabled = working;
repair.disabled = working || state.phase === "restart_required" || runtimeExplicit || !bundledRepairAvailable;
working = updateWorkingPhases.includes(state.phase);
const controlsDisabled = working || actionInFlight;
update.disabled = controlsDisabled;
repair.disabled = controlsDisabled || state.phase === "restart_required" || runtimeExplicit || !bundledRepairAvailable;
if (state.details?.bundled_repair_available === false) repair.disabled = true;
forgetSelection.disabled = working || state.phase === "restart_required";
rollback.disabled = working || state.phase === "restart_required";
channel.disabled = working || state.phase === "restart_required";
forgetSelection.disabled = controlsDisabled || state.phase === "restart_required";
rollback.disabled = controlsDisabled || state.phase === "restart_required";
channel.disabled = controlsDisabled || state.phase === "restart_required";
nextAction = state.phase === "available" ? "apply" : state.phase === "restart_required" ? "restart" : "check";
update.textContent = nextAction === "apply" ? "更新并准备重启 / Install update" : nextAction === "restart" ? "重启完成更新 / Restart" : "检查更新 / Check for updates";
updateStatus.textContent = codeText(state.details?.code, state.phase);
Expand Down Expand Up @@ -186,17 +190,24 @@ document.querySelector("#copy-diagnostics").onclick = async () => {
document.querySelector("#copy-status").textContent = "请按 ⌘C / Ctrl+C 复制已选中的诊断。";
}
};
async function invokeUpdate(action) {
async function run(action) {
if (working || actionInFlight) return;
actionInFlight = true;
statusGeneration++;
// Match the phase the backend publishes for each action (rollback restores
// the previous app; restart keeps the required-restart state) instead of
// previewing a download that is not happening.
render({phase: action === "check" ? "checking" : action === "repair" || action === "align_runtime" ? "installing_runtime" : action === "forget_runtime_selection" ? "connecting" : action === "rollback" ? "installing_app" : action === "restart" ? "restart_required" : "downloading"});
try { return render(await window.__TAURI__.core.invoke("desktop_update", {action,channel:channel.value})); }
catch (error) { return render({phase:"error", details:{code: safeCode(error)}}); }
}
async function run(action) {
if (working) return;
return invokeUpdate(action);
try {
const result = await window.__TAURI__.core.invoke("desktop_update", {action,channel:channel.value});
statusGeneration++;
actionInFlight = false;
return render(result);
} catch (error) {
statusGeneration++;
actionInFlight = false;
return render({phase:"error", details:{code: safeCode(error)}});
}
}
update.onclick = () => run(nextAction);
repair.onclick = () => run("repair");
Expand All @@ -214,8 +225,11 @@ let terminalRounds = 0;
let escalated = false;
async function refresh() {
if (!window.__TAURI__) { renderDiagnostics({state:{phase:"error",details:{code:"desktop_status_unavailable"}}}); return; }
const generation = ++statusGeneration;
try {
const result = await window.__TAURI__.core.invoke("desktop_update_status");
if (generation !== statusGeneration) return;
if (actionInFlight && result.state?.phase && !updateWorkingPhases.includes(result.state.phase)) return;
if (!channelInitialized) {
channel.value = result.state?.details?.channel ?? (result.app_version?.includes("-main.") ? "main" : "stable");
channelInitialized = true;
Expand All @@ -228,7 +242,11 @@ async function refresh() {
render(result.state);
renderStartup(result);
escalateFromSnapshot(result.state);
} catch { renderDiagnostics({state:{phase:"error",details:{code:"desktop_status_unavailable"}}}); }
} catch {
if (generation === statusGeneration && !actionInFlight) {
renderDiagnostics({state:{phase:"error",details:{code:"desktop_status_unavailable"}}});
}
}
}
function escalateFromSnapshot(state) {
const terminal = TERMINAL_PHASES.includes(state?.phase);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,23 @@ export function DesktopUpdate() {
const [rollbackAvailable, setRollbackAvailable] = useState(false);
const [runtimeSelection, setRuntimeSelection] = useState<RuntimeSelection>();
const busy = useRef(false);
const statusGeneration = useRef(0);
const invoke = (window as DesktopWindow).__TAURI__?.core.invoke;
const working = workingPhases.includes(state.phase);
const repairAvailable = runtimeSelection?.explicit !== true && runtimeSelection?.bundled_repair_available !== false;

function readStatus(value: UpdateStatus) {
function readStatus(value: UpdateStatus, includeState = true) {
setVersion(value.app_version);
setRollbackAvailable(value.rollback_available === true);
setRuntimeSelection(value.runtime_selection);
if (value.state?.phase) setState(value.state);
if (includeState && value.state?.phase) setState(value.state);
}

function commitStatus(value: UpdateStatus, generation: number) {
if (generation !== statusGeneration.current) return false;
const stateIsCurrent = !busy.current || !value.state?.phase || workingPhases.includes(value.state.phase);
readStatus(value, stateIsCurrent);
return true;
}

useEffect(() => {
Expand All @@ -56,37 +64,54 @@ export function DesktopUpdate() {
useEffect(() => {
if (!invoke) return;
let alive = true;
const generation = ++statusGeneration.current;
void invoke<UpdateStatus>("desktop_update_status").then((value) => {
if (!alive) return;
readStatus(value);
if (!alive || !commitStatus(value, generation)) return;
const selected = value.state?.details?.channel ?? (value.app_version.includes("-main.") ? "main" : "stable");
setChannel(selected);
if (!value.state?.phase) {
void invoke<UpdateState>("desktop_update", { action: "check", channel: selected }).then((next) => { if (alive) setState(next); }).catch((error: unknown) => { if (alive) setState(failedUpdate(error)); });
const actionGeneration = ++statusGeneration.current;
void invoke<UpdateState>("desktop_update", { action: "check", channel: selected }).then((next) => {
if (alive && actionGeneration === statusGeneration.current) setState(next);
}).catch((error: unknown) => {
if (alive && actionGeneration === statusGeneration.current) setState(failedUpdate(error));
});
}
}).catch(() => { if (alive) setState(failedUpdate(null, "desktop_status_unavailable")); });
return () => { alive = false; };
}).catch(() => {
if (alive && generation === statusGeneration.current) setState(failedUpdate(null, "desktop_status_unavailable"));
});
return () => { alive = false; statusGeneration.current++; };
}, [invoke]);

useEffect(() => {
if (!invoke || !working) return;
const timer = window.setInterval(() => {
void invoke<UpdateStatus>("desktop_update_status").then(readStatus).catch(() => {});
const generation = ++statusGeneration.current;
void invoke<UpdateStatus>("desktop_update_status").then((value) => {
commitStatus(value, generation);
}).catch(() => {});
}, 1000);
return () => window.clearInterval(timer);
}, [invoke, working]);

async function run(action: "check" | "apply" | "repair" | "restart" | "rollback" | "forget_runtime_selection") {
if (!invoke || busy.current) return;
busy.current = true;
statusGeneration.current++;
setState({ phase: action === "check" ? "checking" : action === "repair" ? "installing_runtime" : action === "forget_runtime_selection" ? "connecting" : "downloading" });
try {
if (!version) {
readStatus(await invoke<UpdateStatus>("desktop_update_status"));
const generation = ++statusGeneration.current;
commitStatus(await invoke<UpdateStatus>("desktop_update_status"), generation);
}
setState(await invoke<UpdateState>("desktop_update", { action, channel }));
const next = await invoke<UpdateState>("desktop_update", { action, channel });
statusGeneration.current++;
setState(next);
}
catch (error: unknown) {
statusGeneration.current++;
setState(failedUpdate(error, version ? "update_failed" : "desktop_status_unavailable"));
}
catch (error: unknown) { setState(failedUpdate(error, version ? "update_failed" : "desktop_status_unavailable")); }
finally { busy.current = false; }
}
const message: Record<Phase, string> = {
Expand Down
64 changes: 62 additions & 2 deletions examples/desktop-update-browser-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,23 +23,56 @@ const server = createServer(async (req, res) => {
await new Promise((done) => server.listen(0, "127.0.0.1", done));
const origin = `http://127.0.0.1:${server.address().port}`;
const browser = await launchBrowser(loadPlaywright().chromium);
function deferred() {
let resolvePromise;
const promise = new Promise((resolve) => {
resolvePromise = resolve;
});
return { promise, resolve: resolvePromise };
}
try {
await mkdir(output, { recursive: true });
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
const calls = [];
let nativeState = null;
let nativeActionSettled = Promise.resolve();
let nativeActionDelayMs = 150;
let nativeRuntimeSelection;
let startupTiming = null;
let failUpdate = false;
let checkFailure = null;
let checkUpToDate = false;
let statusFailure = false;
let environmentTelemetry = null;
let delayedStatus = null;
function delayNextStatus(phase) {
delayedStatus = {
phase,
started: deferred(),
release: deferred(),
returned: deferred(),
};
return delayedStatus;
}
await page.exposeFunction("nativeInvoke", async (command, args) => {
if (command === "desktop_update_status") {
if (statusFailure) throw new Error("Command desktop_update_status not allowed by ACL");
return { state: nativeState, runtime_selection: nativeRuntimeSelection, startup: startupTiming, app_version: "0.5.4", rollback_available: true, environment: environmentTelemetry };
const result = {
state: structuredClone(nativeState),
runtime_selection: structuredClone(nativeRuntimeSelection),
startup: structuredClone(startupTiming),
app_version: "0.5.4",
rollback_available: true,
environment: structuredClone(environmentTelemetry),
};
if (delayedStatus && delayedStatus.phase === result.state?.phase) {
const request = delayedStatus;
delayedStatus = null;
request.started.resolve();
await request.release.promise;
request.returned.resolve();
}
return result;
}
calls.push({ command, args });
if (checkFailure && args.action === "check") return { phase: "error", details: { code: checkFailure } };
Expand All @@ -48,7 +81,11 @@ try {
return nativeState;
}
if (failUpdate) throw new Error("private diagnostic must not be displayed");
nativeActionSettled = new Promise((done) => setTimeout(done, 150)).then(() => {
nativeState = {
phase: args.action === "check" ? "checking" : args.action === "repair" ? "installing_runtime" : ["align_runtime", "use_installed_runtime", "forget_runtime_selection"].includes(args.action) ? "connecting" : args.action === "rollback" ? "installing_app" : args.action === "restart" ? "restart_required" : "downloading",
details: { version: "0.5.5", channel: args.channel },
};
nativeActionSettled = new Promise((done) => setTimeout(done, nativeActionDelayMs)).then(() => {
nativeState = {
phase: args.action === "check" ? "available" : ["align_runtime", "use_installed_runtime", "forget_runtime_selection"].includes(args.action) ? "connecting" : "restart_required",
details: { version: "0.5.5", channel: args.channel },
Expand All @@ -75,9 +112,19 @@ try {
await page.getByRole("button", { name: "更新并准备重启", exact: true }).waitFor();
assert.deepEqual(calls.map((call) => call.args?.action), ["check"], "auto-check must not mutate installation");
await page.screenshot({ path: resolve(output, "update-panel.png") });
nativeActionDelayMs = 1500;
const delayedWorkspaceStatus = delayNextStatus("downloading");
await page.getByRole("button", { name: "更新并准备重启", exact: true }).click();
await delayedWorkspaceStatus.started.promise;
await nativeActionSettled;
await page.locator(".personal-update-panel").getByRole("button", { name: "重启完成更新", exact: true }).waitFor();
delayedWorkspaceStatus.release.resolve();
await delayedWorkspaceStatus.returned.promise;
await page.waitForTimeout(50);
assert.equal(await page.getByRole("button", { name: "更新并准备重启", exact: true }).count(), 0, "a delayed workspace status must not replace restart_required");
assert.equal(await page.locator(".personal-update-panel").getByRole("button", { name: "重启完成更新", exact: true }).count(), 1);
assert.deepEqual(calls.map((call) => call.args?.action), ["check", "apply"]);
nativeActionDelayMs = 150;
await page.reload();
await page.getByRole("button", { name: /重启完成更新/ }).waitFor();
assert.deepEqual(calls.map((call) => call.args?.action), ["check", "apply"], "reload must recover native progress, not repeat install");
Expand Down Expand Up @@ -234,6 +281,19 @@ try {
await page.locator("#update").click();
await page.getByRole("button", { name: "更新并准备重启 / Install update" }).waitFor();
assert.deepEqual(calls.at(-1).args, { action: "check", channel: "main" });
nativeActionDelayMs = 1500;
const applyCount = calls.filter((call) => call.args?.action === "apply").length;
const delayedBootStatus = delayNextStatus("downloading");
await page.locator("#update").click();
await delayedBootStatus.started.promise;
await nativeActionSettled;
await page.getByRole("button", { name: "重启完成更新 / Restart" }).waitFor();
delayedBootStatus.release.resolve();
await delayedBootStatus.returned.promise;
await page.waitForTimeout(50);
assert.equal(await page.locator("#update").innerText(), "重启完成更新 / Restart", "a delayed boot status must not replace restart_required");
assert.equal(calls.filter((call) => call.args?.action === "apply").length, applyCount + 1, "the boot page must issue Apply once");
nativeActionDelayMs = 150;
await page.reload();
await page.getByText("恢复与更新 / Recovery & updates").click();
await page.waitForFunction(() => document.querySelector("#channel").value === "main");
Expand Down
Loading