Files
Peter Steinberger f6aa7c24f1 fix(linux): truthful install failures, post-install repair, reachable reinstall (#128614)
The Linux desktop companion could complete a CLI install and still claim
'Installation did not finish' with circular update advice, discarding the
real failure. Verified end-to-end in a clean Ubuntu VM across all three
release channels:

- cli.rs: failed CLI commands now surface their stderr tail (deduped, last
  12 lines) instead of being mislabeled as JSON parse failures.
- gateway.rs: missing dashboard --json support maps to an honest curated
  message pointing at Beta/Development channels, not a circular npm-update
  hint.
- main.rs: run 'doctor --fix --non-interactive' right after install so the
  CLI repairs config/state before Gateway readiness checks; wrap
  post-install failures as 'installed, but connecting failed: <reason>'.
- installer.rs: keep structured step events out of the prose failure tail.
- ui/main.js: humanize streamed install steps, render real errors on the
  failure screen, and always offer Reinstall from connection failures.
- scripts/install-cli.sh: service refresh uses 'gateway status --json' with
  the bundled node runtime, corepack failure falls back to npm, dev channel
  clones with --filter=blob:none.
2026-08-24 02:01:15 -07:00

419 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const tauri = window["__TAURI__"];
const { invoke } = tauri.core;
const { listen } = tauri.event;
const elements = {
activity: document.querySelector("#activity"),
activityLabel: document.querySelector("#activity-label"),
actionControls: document.querySelector("#action-controls"),
channel: document.querySelector("#channel"),
description: document.querySelector("#description"),
eyebrow: document.querySelector("#eyebrow"),
gatewayList: document.querySelector("#gateway-list"),
discoveryStatus: document.querySelector("#discovery-status"),
installButton: document.querySelector("#install-button"),
installControls: document.querySelector("#install-controls"),
installLog: document.querySelector("#install-log"),
logStatus: document.querySelector("#log-status"),
logWrap: document.querySelector("#log-wrap"),
primaryAction: document.querySelector("#primary-action"),
statusDot: document.querySelector("#status-dot"),
title: document.querySelector("#title"),
updateAction: document.querySelector("#update-action"),
updateBanner: document.querySelector("#update-banner"),
updateDismiss: document.querySelector("#update-dismiss"),
updateMessage: document.querySelector("#update-message"),
updateProgress: document.querySelector("#update-progress"),
updateTitle: document.querySelector("#update-title"),
};
let primaryAction = null;
let updateAction = null;
let discoveryPending = false;
let discoverySignature = null;
function show(element, visible) {
element.classList.toggle("hidden", !visible);
}
function render({
activity = null,
description,
dot = "working",
eyebrow = "DESKTOP COMPANION",
showInstall = false,
title,
}) {
elements.eyebrow.textContent = eyebrow;
elements.title.textContent = title;
elements.description.textContent = description;
elements.statusDot.className = `status-dot ${dot}`;
show(elements.activity, Boolean(activity));
if (activity) {
elements.activityLabel.textContent = activity;
}
show(elements.installControls, showInstall);
show(elements.actionControls, false);
}
function renderAction(options, action) {
render(options);
primaryAction = action;
elements.primaryAction.textContent = options.actionLabel;
show(elements.actionControls, true);
}
function formatInstallLine(line) {
let event;
try {
event = JSON.parse(line);
} catch {
return line;
}
if (!event || typeof event !== "object" || !event.event) {
return line;
}
if (event.event === "done" && event.ok === true) {
return `✓ Installed${event.version ? ` ${event.version}` : ""}`;
}
if (event.event !== "step" || !event.name) {
return line;
}
const name =
{
node: "Node runtime",
git: "Git checkout",
openclaw: "OpenClaw CLI",
"gateway-service": "Gateway service",
"control-ui": "Control UI build",
"cli-build": "CLI build",
}[event.name] || event.name;
switch (event.status) {
case "start":
return `→ ${name}${event.version ? ` ${event.version}` : ""}…`;
case "ok":
return `✓ ${name}`;
case "skip":
return ` ${name} skipped${event.reason ? ` (${event.reason})` : ""}`;
case "warn":
return `! ${name}${event.reason ? `: ${event.reason}` : ""}`;
default:
return line;
}
}
function appendLog(line) {
elements.installLog.textContent += `${formatInstallLine(line)}\n`;
elements.installLog.scrollTop = elements.installLog.scrollHeight;
}
function renderUpdate({ action = null, actionLabel = "", message, progress = false, title }) {
elements.updateTitle.textContent = title;
elements.updateMessage.textContent = message;
updateAction = action;
elements.updateAction.textContent = actionLabel;
show(elements.updateAction, Boolean(action));
show(elements.updateProgress, progress);
show(elements.updateBanner, true);
}
function formatBytes(bytes) {
if (!Number.isFinite(bytes)) {
return "";
}
const units = ["B", "KB", "MB", "GB"];
let value = bytes;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit += 1;
}
return `${value.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
}
function friendlyError(error) {
if (typeof error === "string") {
return error;
}
return error?.message || "OpenClaw could not complete the operation.";
}
function gatewayHost(gateway) {
return (gateway.host || "").trim().replace(/\.$/, "");
}
function canConnectDirect(gateway) {
return (
gateway.tls ||
gateway.directReachable ||
gatewayHost(gateway).toLowerCase().endsWith(".ts.net")
);
}
function renderGateways(gateways) {
elements.gatewayList.replaceChildren();
elements.discoveryStatus.textContent = gateways.length ? `${gateways.length} FOUND` : "SEARCHING";
if (!gateways.length) {
const empty = document.createElement("p");
empty.className = "discovery-empty";
empty.textContent = "Looking for nearby OpenClaw gateways…";
elements.gatewayList.append(empty);
return;
}
for (const gateway of gateways) {
const button = document.createElement("button");
button.className = "gateway-card";
button.type = "button";
button.disabled = !canConnectDirect(gateway);
if (button.disabled) {
button.title = "This gateway does not advertise a direct connection.";
}
const copy = document.createElement("span");
copy.className = "gateway-copy";
const name = document.createElement("span");
name.className = "gateway-name";
name.textContent = gateway.name;
const endpoint = document.createElement("span");
endpoint.className = "gateway-endpoint";
endpoint.textContent = `${gatewayHost(gateway)}:${gateway.port}`;
copy.append(name, endpoint);
const badge = document.createElement("span");
badge.className = `gateway-badge${gateway.tls ? " secure" : ""}`;
badge.textContent = gateway.tls ? "TLS" : "HTTP";
button.append(copy, badge);
button.addEventListener("click", () => {
button.disabled = true;
void invoke("connect_discovered_gateway", {
host: gateway.host,
port: gateway.port,
tls: gateway.tls,
})
.then(() => {
button.disabled = false;
elements.discoveryStatus.textContent = "WINDOW OPENED";
})
.catch(() => {
button.disabled = false;
elements.discoveryStatus.textContent = "CONNECT FAILED";
});
});
elements.gatewayList.append(button);
}
}
async function refreshGateways() {
if (discoveryPending) {
return;
}
discoveryPending = true;
try {
const gateways = await invoke("discover_gateways");
const signature = JSON.stringify(gateways);
if (signature !== discoverySignature) {
discoverySignature = signature;
renderGateways(gateways);
}
} catch {
discoverySignature = null;
elements.discoveryStatus.textContent = "UNAVAILABLE";
} finally {
discoveryPending = false;
}
}
async function connect() {
render({
activity: "Checking local services…",
description: "Finding your gateway and preparing the Control UI.",
title: "Connecting to OpenClaw",
});
try {
const snapshot = await invoke("bootstrap");
if (snapshot.phase === "missingCli") {
const buildInfo = await invoke("build_info").catch(() => null);
if (buildInfo?.releaseBuild === false) {
elements.channel.value = "dev";
render({
description:
"This companion is a development build, so its Gateway install should usually use the matching release channel.",
eyebrow: "FIRST-RUN SETUP",
showInstall: true,
title: "Choose a release channel",
});
return;
}
render({
activity: "Starting the bundled installer…",
description: "OpenClaw is installing its managed CLI and Node runtime.",
eyebrow: "FIRST-RUN SETUP",
title: "Preparing OpenClaw",
});
await install();
}
} catch (error) {
renderRetry(friendlyError(error));
}
}
async function install() {
elements.installButton.disabled = true;
elements.channel.disabled = true;
elements.installLog.textContent = "";
elements.logStatus.textContent = "RUNNING";
show(elements.logWrap, true);
render({
activity: "Installing OpenClaw…",
description: "A managed CLI and Node runtime are being installed in your home directory.",
eyebrow: "INSTALLING",
title: "Preparing your companion",
});
try {
await invoke("install_cli", { channel: elements.channel.value });
elements.logStatus.textContent = "COMPLETE";
} catch (error) {
const message = friendlyError(error);
elements.logStatus.textContent = "FAILED";
appendLog(message);
render({
description: message,
dot: "error",
eyebrow: "SETUP ISSUE",
showInstall: true,
title: "OpenClaw needs attention",
});
} finally {
elements.installButton.disabled = false;
elements.channel.disabled = false;
}
}
async function runGatewayAction(action) {
render({
activity: `${action === "restart" ? "Restarting" : "Starting"} gateway…`,
description: "OpenClaw is waiting for the local gateway to become healthy.",
eyebrow: "GATEWAY",
title: "One moment",
});
try {
await invoke("gateway_action", { action });
} catch (error) {
renderRetry(friendlyError(error));
}
}
function renderRetry(message) {
show(elements.logWrap, false);
renderAction(
{
actionLabel: "Try again",
description: message,
dot: "error",
eyebrow: "CONNECTION ISSUE",
// A broken managed CLI can only be replaced by reinstalling; retry alone
// must never be the sole exit from a connection failure.
showInstall: true,
title: "OpenClaw needs attention",
},
connect,
);
}
elements.installButton.addEventListener("click", () => {
void install();
});
elements.primaryAction.addEventListener("click", () => {
void primaryAction?.();
});
elements.updateAction.addEventListener("click", () => {
void updateAction?.();
});
elements.updateDismiss.addEventListener("click", () => {
show(elements.updateBanner, false);
});
await listen("install-progress", ({ payload }) => appendLog(payload.line));
await listen("updater://not-available", () => {
renderUpdate({
message: "No update is available.",
title: "OpenClaw is up to date",
});
});
await listen("updater://available", ({ payload }) => {
elements.updateProgress.removeAttribute("value");
renderUpdate({
message: payload.notes || "Downloading in the background…",
progress: true,
title: `Update available v${payload.version} — downloading…`,
});
});
await listen("updater://progress", ({ payload }) => {
if (payload.total) {
elements.updateProgress.max = payload.total;
elements.updateProgress.value = payload.downloaded;
elements.updateMessage.textContent = `${formatBytes(payload.downloaded)} of ${formatBytes(payload.total)}`;
} else {
elements.updateProgress.removeAttribute("value");
elements.updateMessage.textContent = `${formatBytes(payload.downloaded)} downloaded`;
}
});
await listen("updater://ready", ({ payload }) => {
renderUpdate({
action: () => invoke("relaunch"),
actionLabel: "Restart to update",
message: `Version v${payload.version} is installed and ready.`,
title: "Update ready",
});
});
await listen("updater://available-manual", ({ payload }) => {
renderUpdate({
action: () =>
invoke("open_release_page").catch((error) => {
renderUpdate({
message: friendlyError(error),
title: "Could not open release page",
});
}),
actionLabel: "Open download page",
message: payload.notes || "Install the latest system package from the release page.",
title: `Update available v${payload.version}`,
});
});
await listen("updater://error", ({ payload }) => {
renderUpdate({
message: payload.message,
title: "Update check failed",
});
});
void invoke("updater_ready");
void refreshGateways();
window.setInterval(() => void refreshGateways(), 2000);
const mode = new URLSearchParams(window.location.search).get("mode");
if (mode === "reconnecting") {
render({
activity: "Retrying every few seconds…",
description: "The gateway connection dropped. OpenClaw will restore the dashboard automatically.",
eyebrow: "GATEWAY OFFLINE",
title: "Reconnecting",
});
} else if (mode === "stopped") {
renderAction(
{
actionLabel: "Start Gateway",
description: "The gateway is stopped. The desktop companion will remain available in the tray.",
dot: "idle",
eyebrow: "GATEWAY STOPPED",
title: "OpenClaw is standing by",
},
() => runGatewayAction("start"),
);
} else if (mode === "error") {
renderRetry("The last gateway action failed. Check the service, then retry.");
} else {
await connect();
}