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
31 changes: 30 additions & 1 deletion apps/desktop/loopx-control-plane/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ use tauri_plugin_notification::NotificationExt;

const APP_IDENTIFIER: &str = "io.loopx.control-plane";

fn maintenance_origin(url: &Url) -> String {
// Custom-protocol IPC carries the HTTP Origin header (no /chat/ path).
// postMessage carries the page URL. Both must match the same exact origin.
url.origin().ascii_serialization()
}

fn show_main_window(app: &AppHandle) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
Expand Down Expand Up @@ -54,7 +60,7 @@ pub fn run() {
let origin: Url = web_origin.parse()?;
app.add_capability(
CapabilityBuilder::new("desktop-loopx-chat")
.remote(origin.to_string())
.remote(maintenance_origin(&origin))
.permission("allow-desktop-update")
.permission("allow-desktop-update-status")
.window("main"),
Expand Down Expand Up @@ -148,6 +154,29 @@ pub fn run() {

#[cfg(test)]
mod tests {
#[test]
fn maintenance_acl_accepts_both_transports_only_on_the_app_origin() {
use tauri::utils::acl::RemoteUrlPattern;
let page: tauri::Url = "http://127.0.0.1:8767/chat/".parse().unwrap();
let old: RemoteUrlPattern = page.to_string().parse().unwrap();
assert!(!old.test(&"http://127.0.0.1:8767".parse().unwrap()));
let pattern: RemoteUrlPattern = super::maintenance_origin(&page).parse().unwrap();
for allowed in [
"http://127.0.0.1:8767",
"http://127.0.0.1:8767/chat/?goal=x",
] {
assert!(pattern.test(&allowed.parse().unwrap()), "{allowed}");
}
for denied in [
"http://127.0.0.1:8766/chat/",
"http://localhost:8767/chat/",
"https://127.0.0.1:8767/chat/",
"https://example.com/chat/",
] {
assert!(!pattern.test(&denied.parse().unwrap()), "{denied}");
}
}

#[test]
fn startup_surface_is_visible_and_names_automatic_recovery() {
let html = include_str!("../../static/index.html");
Expand Down
61 changes: 56 additions & 5 deletions apps/desktop/loopx-control-plane/src-tauri/src/maintenance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,21 @@ fn endpoint(channel: &str) -> Result<tauri::Url, String> {
_ => return Err("invalid_update_channel".into()),
}.parse().unwrap())
}

fn check_error(error: tauri_plugin_updater::Error) -> &'static str {
use tauri_plugin_updater::Error;
match error {
// The plugin discards non-success HTTP status codes, so this cannot
// distinguish an unpublished feed (404) from an unavailable server.
Error::ReleaseNotFound => "update_feed_unavailable",
Error::Serialization(_) => "update_feed_invalid",
Error::TargetNotFound(_) | Error::TargetsNotFound(_) => "update_platform_unavailable",
Error::Reqwest(error) if error.is_timeout() => "update_check_timeout",
Error::Reqwest(error) if error.is_decode() => "update_feed_invalid",
Error::Reqwest(_) => "update_network_failed",
_ => "update_check_failed",
}
}
#[tauri::command]
pub fn desktop_update_status(app: AppHandle, state: State<'_, Maintenance>) -> Value {
json!({"state": state.snapshot.lock().unwrap().clone(), "app_version": app.package_info().version.to_string(), "runtime": bundled_runtime::identity(&app).ok(), "rollback_available": crate::update_backup::available(&app)})
Expand Down Expand Up @@ -160,7 +175,7 @@ async fn perform(
.map_err(|_| "update_unavailable")?
.check()
.await
.map_err(|_| "update_check_failed")?;
.map_err(check_error)?;
let details = json!({"channel":channel,"version":update.as_ref().map(|v| &v.version),"current_version":app.package_info().version.to_string()});
let phase = if update.is_some() {
"available"
Expand Down Expand Up @@ -276,6 +291,27 @@ pub fn start_services(app: &AppHandle) -> Result<Option<crate::services::Service
mod tests {
use super::*;
#[test]
fn check_failures_preserve_actionable_categories_without_diagnostics() {
use tauri_plugin_updater::Error;
assert_eq!(
check_error(Error::ReleaseNotFound),
"update_feed_unavailable"
);
assert_eq!(
check_error(Error::TargetNotFound("private-target".into())),
"update_platform_unavailable"
);
assert_eq!(
check_error(Error::Network("private-diagnostic".into())),
"update_check_failed"
);
let invalid = serde_json::from_str::<Value>("invalid").unwrap_err();
assert_eq!(
check_error(Error::Serialization(invalid)),
"update_feed_invalid"
);
}
#[test]
fn transaction_is_singleflight_and_released_on_unwind() {
let state = Maintenance::default();
let guard = state.acquire().unwrap();
Expand Down Expand Up @@ -312,9 +348,14 @@ mod tests {
fn service_failure_releases_recovery_and_later_success_is_ready() {
let state = Maintenance::default();
state.publish("connecting", json!({}));
assert!(state.reconcile_services::<()>(|| Err("occupied port".into())).is_err());
assert!(state
.reconcile_services::<()>(|| Err("occupied port".into()))
.is_err());
assert_eq!(state.snapshot.lock().unwrap()["phase"], "service_error");
assert!(state.acquire().is_ok(), "recovery transaction must be available");
assert!(
state.acquire().is_ok(),
"recovery transaction must be available"
);
assert_eq!(state.reconcile_services(|| Ok(())).unwrap(), Some(()));
assert_eq!(state.snapshot.lock().unwrap()["phase"], "ready");
}
Expand All @@ -323,9 +364,19 @@ mod tests {
fn service_retry_cannot_race_maintenance_or_restart() {
let state = Maintenance::default();
let guard = state.acquire().unwrap();
assert_eq!(state.reconcile_services::<()>(|| panic!("must not start")).unwrap(), None);
assert_eq!(
state
.reconcile_services::<()>(|| panic!("must not start"))
.unwrap(),
None
);
drop(guard);
state.publish("restart_required", json!({}));
assert_eq!(state.reconcile_services::<()>(|| panic!("must not start")).unwrap(), None);
assert_eq!(
state
.reconcile_services::<()>(|| panic!("must not start"))
.unwrap(),
None
);
}
}
12 changes: 10 additions & 2 deletions apps/desktop/loopx-control-plane/static/boot.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ const labels = {
ready: "更新完成,正在打开工作区。",
error: "更新未完成。请重试检查,或修复当前版本。Goal 数据不会被删除。",
};
const errors = {
update_feed_unavailable: "此通道的更新源尚未就绪或暂时不可用。可稍后重新检查。",
update_feed_invalid: "更新源格式异常。请稍后重新检查。",
update_platform_unavailable: "此通道尚无适用于本机的更新包。",
update_check_timeout: "检查更新超时。请稍后重试。",
update_network_failed: "无法连接更新服务器。请检查网络后重试。",
update_download_or_signature_failed: "更新包下载或签名校验失败,尚未安装。请重新检查更新。",
};
function render(state) {
if (!state?.phase) return;
if (state.phase === "available" && state.details?.channel !== channel.value) state = {phase:"idle"};
Expand All @@ -45,13 +53,13 @@ function render(state) {
channel.disabled = working || 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 = labels[state.phase] || "";
updateStatus.textContent = state.phase === "error" && Object.hasOwn(errors, state.details?.code) ? errors[state.details.code] : labels[state.phase] || "";
}
async function run(action) {
if (working) return;
render({phase: action === "check" ? "checking" : action === "repair" ? "installing_runtime" : "downloading"});
try { render(await window.__TAURI__.core.invoke("desktop_update", {action,channel:channel.value})); }
catch { render({phase:"error"}); }
catch (error) { render({phase:"error", details:{code: typeof error === "string" && Object.hasOwn(errors, error) ? error : "update_failed"}}); }
}
update.onclick = () => run(nextAction);
repair.onclick = () => run("repair");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ type UpdateState = { phase: Phase; details?: { version?: string; channel?: strin
type DesktopWindow = Window & { __TAURI__?: { core: { invoke: <T>(command: string, args?: Record<string, string>) => Promise<T> } } };
const workingPhases: Phase[] = ["checking", "connecting", "downloading", "installing_app", "installing_runtime"];

const updateErrors: Record<string, [string, string]> = {
desktop_status_unavailable: ["无法读取 App 更新状态。请重启 App 后再试;若仍失败,请重新安装最新 App。", "Cannot read the App update status. Restart the App; if this persists, reinstall the latest App."],
update_feed_unavailable: ["此通道的更新源尚未就绪或暂时不可用。可稍后重新检查,当前版本仍可继续使用。", "This channel's update feed is not ready or temporarily unavailable. Check again later; you can keep using this version."],
update_feed_invalid: ["更新源格式异常。请稍后重新检查。", "The update feed is invalid. Check again later."],
update_platform_unavailable: ["此通道尚无适用于本机的更新包。", "This channel has no update package for this platform."],
update_check_timeout: ["检查更新超时。请稍后重试。", "The update check timed out. Try again later."],
update_network_failed: ["无法连接更新服务器。请检查网络后重试。", "Cannot reach the update server. Check your connection and retry."],
update_download_or_signature_failed: ["更新包下载或签名校验失败,尚未安装。请重新检查更新。", "Download or signature verification failed; the update was not installed. Check for updates again."],
};
function failedUpdate(error: unknown, fallback = "update_failed"): UpdateState {
return { phase: "error", details: { code: typeof error === "string" && Object.hasOwn(updateErrors, error) ? error : fallback } };
}

export function DesktopUpdate() {
const { locale } = useWorkspaceI18n();
const zh = locale === "zh-CN";
Expand Down Expand Up @@ -39,9 +52,9 @@ export function DesktopUpdate() {
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(() => { if (alive) setState({ phase: "error" }); });
void invoke<UpdateState>("desktop_update", { action: "check", channel: selected }).then((next) => { if (alive) setState(next); }).catch((error: unknown) => { if (alive) setState(failedUpdate(error)); });
}
}).catch(() => { if (alive) setState({ phase: "error" }); });
}).catch(() => { if (alive) setState(failedUpdate(null, "desktop_status_unavailable")); });
return () => { alive = false; };
}, [invoke]);

Expand All @@ -57,8 +70,15 @@ export function DesktopUpdate() {
if (!invoke || busy.current) return;
busy.current = true;
setState({ phase: action === "check" ? "checking" : action === "repair" ? "installing_runtime" : "downloading" });
try { setState(await invoke<UpdateState>("desktop_update", { action, channel })); }
catch { setState({ phase: "error" }); }
try {
if (!version) {
const current = await invoke<{ app_version: string; rollback_available?: boolean }>("desktop_update_status");
setVersion(current.app_version);
setRollbackAvailable(current.rollback_available === true);
}
setState(await invoke<UpdateState>("desktop_update", { action, channel }));
}
catch (error: unknown) { setState(failedUpdate(error, version ? "update_failed" : "desktop_status_unavailable")); }
finally { busy.current = false; }
}
const message: Record<Phase, string> = {
Expand All @@ -74,7 +94,7 @@ export function DesktopUpdate() {
installing_runtime: zh ? "正在安装匹配的运行时,请稍候…" : "Installing the matching runtime…",
restart_required: zh ? "重启后将自动完成运行时安装与服务连接。" : "Restart to finish runtime installation and reconnect services.",
ready: zh ? "更新完成,服务已就绪。" : "Update completed; services are ready.",
error: zh ? "更新未完成。请检查网络后重试;启动失败可尝试修复当前版本。" : "Update incomplete. Check the connection and retry; repair this version if startup fails.",
error: updateErrors[state.details?.code ?? ""]?.[zh ? 0 : 1] ?? (zh ? "更新未完成。请重试;启动失败可尝试修复当前版本。" : "Update incomplete. Retry; repair this version if startup fails."),
};
const label = working ? (zh ? "正在更新…" : "Updating…") : state.phase === "available" ? (zh ? "有可用更新" : "Update available") : state.phase === "restart_required" ? (zh ? "重启完成更新" : "Restart to finish") : state.phase === "error" ? (zh ? "更新需重试" : "Retry update") : (zh ? "更新 LoopX" : "Update LoopX");
return <div className="personal-desktop-update">
Expand Down
2 changes: 1 addition & 1 deletion docs/book/chapters/00-reading-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ lifecycle 选择,不是所有贡献的默认终点。

## 版本基线

当前内容以 LoopX GitHub release `v0.5.4` 为发布锚点;本地命令示例已按 `loopx 0.5.4` 的
当前内容以 LoopX GitHub release `v1.0.0` 为发布锚点;本地命令示例已按 `loopx 1.0.0` 的
公开 CLI 与协议表面复核。该版本要求 Python 3.11+ 与 Node.js 22.6+;后者运行由 LoopX 自动管理、
空闲后退出的 TypeScript Effect runtime,用户不需要手工维护 daemon。

Expand Down
4 changes: 2 additions & 2 deletions docs/book/en/chapters/00-reading-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,8 @@ Do not bypass a newer permission or lifecycle check just to make an older exampl

## Version baseline

The current release anchor is LoopX GitHub release `v0.5.4`. Local command examples were checked against
the public `loopx 0.5.4` CLI and protocol surface. This release requires Python 3.11+ and Node.js 22.6+.
The current release anchor is LoopX GitHub release `v1.0.0`. Local command examples were checked against
the public `loopx 1.0.0` CLI and protocol surface. This release requires Python 3.11+ and Node.js 22.6+.
LoopX starts and reuses its managed, idle-exiting TypeScript Effect runtime automatically; users do not
operate that runtime as a manual daemon.

Expand Down
2 changes: 1 addition & 1 deletion docs/book/en/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ versioned or optional functionality, not the default shape of every contribution
- Source format: Markdown
- Site generator: MkDocs Material
- Hosting: GitHub Pages
- LoopX release anchor: `v0.5.4`
- LoopX release anchor: `v1.0.0`
- Runtime prerequisites: Python 3.11+ and Node.js 22.6+

The official public protocols remain authoritative for protocol facts. Commands that change across
Expand Down
2 changes: 1 addition & 1 deletion docs/book/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ Extension 只是可独立版本化和交付的一种路径。
- 正文格式:Markdown;
- 站点生成器:MkDocs Material;
- 在线发布:GitHub Pages;
- LoopX 发布锚点:`v0.5.4`;
- LoopX 发布锚点:`v1.0.0`;
- 运行时前提:Python 3.11+ 与 Node.js 22.6+。

协议解释以 LoopX 官方公开合同为事实源。易变化的命令仍以对应发布物、官方文档和当前
Expand Down
27 changes: 25 additions & 2 deletions examples/desktop-update-browser-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,15 @@ try {
const calls = [];
let nativeState = null;
let failUpdate = false;
let checkFailure = null;
let statusFailure = false;
await page.exposeFunction("nativeInvoke", async (command, args) => {
if (command === "desktop_update_status") return { state: nativeState, app_version: "0.5.4", rollback_available: true };
if (command === "desktop_update_status") {
if (statusFailure) throw new Error("Command desktop_update_status not allowed by ACL");
return { state: nativeState, app_version: "0.5.4", rollback_available: true };
}
calls.push({ command, args });
if (checkFailure && args.action === "check") return { phase: "error", details: { code: checkFailure } };
if (failUpdate) throw new Error("private diagnostic must not be displayed");
await new Promise((done) => setTimeout(done, 150));
nativeState = { phase: args.action === "check" ? "available" : "restart_required", details: { version: "0.5.5", channel: args.channel } };
Expand Down Expand Up @@ -66,7 +72,7 @@ try {
await page.reload();
await page.getByRole("button", { name: "有可用更新" }).click();
await page.getByRole("button", { name: "更新并准备重启", exact: true }).click();
await page.getByText("更新未完成。请检查网络后重试;启动失败可尝试修复当前版本。").waitFor();
await page.getByText("更新未完成。请重试;启动失败可尝试修复当前版本。").waitFor();
assert.ok(!(await page.locator("body").innerText()).includes("private diagnostic"));
assert.equal(await page.getByRole("button", { name: "更新并准备重启", exact: true }).count(), 0);
await page.setViewportSize({ width: 390, height: 844 });
Expand All @@ -75,6 +81,23 @@ try {
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
await page.screenshot({ path: resolve(output, "update-mobile.png") });

await page.setViewportSize({ width: 1280, height: 900 });
statusFailure = true;
await page.reload();
await page.getByRole("button", { name: "更新需重试" }).click();
await page.getByText("无法读取 App 更新状态。请重启 App 后再试;若仍失败,请重新安装最新 App。").waitFor();
assert.ok(!(await page.locator("body").innerText()).includes("not allowed by ACL"));
statusFailure = false;
failUpdate = false;
checkFailure = "update_feed_unavailable";
await page.getByRole("button", { name: "检查更新", exact: true }).click();
await page.getByText("此通道的更新源尚未就绪或暂时不可用。可稍后重新检查,当前版本仍可继续使用。").waitFor();
await page.getByText("0.5.4 · 稳定版", { exact: true }).waitFor();
assert.equal(await page.getByRole("button", { name: "更新并准备重启", exact: true }).count(), 0);
checkFailure = null;
await page.getByRole("button", { name: "检查更新", exact: true }).click();
await page.getByRole("button", { name: "更新并准备重启", exact: true }).waitFor();

const missing = await browser.newPage();
await missing.route("**/assets/*.js", (route) => route.abort());
await missing.goto(`${origin}/chat/`);
Expand Down
2 changes: 1 addition & 1 deletion loopx/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@

__all__ = ["__version__"]

__version__ = "0.5.4"
__version__ = "1.0.0"
129 changes: 129 additions & 0 deletions loopx/web/chat/assets/index-CyzsRtSq.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion loopx/web/chat/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
content="LoopX 个人 Agent 工作区:在同一个频道里查看、纠偏并推进 Goal。"
/>
<title>LoopX 个人 Agent 工作区</title>
<script type="module" crossorigin src="/chat/assets/index-O0nPVPnM.js"></script>
<script type="module" crossorigin src="/chat/assets/index-CyzsRtSq.js"></script>
<link rel="stylesheet" crossorigin href="/chat/assets/index-0bhIx5_M.css">
</head>
<body>
Expand Down
2 changes: 1 addition & 1 deletion man/loopx.1
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.TH LOOPX 1 "" "LoopX 0.5.4" "User Commands"
.TH LOOPX 1 "" "LoopX 1.0.0" "User Commands"
.SH NAME
loopx \- control-plane helper for long-running agent work
.SH SYNOPSIS
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "loopx"
version = "0.5.4"
version = "1.0.0"
description = "A lightweight Loop Engineering control plane for long-running agent goals."
readme = "README.md"
requires-python = ">=3.11"
Expand Down
Loading
Loading