diff --git a/electron/src/components/UpdateProgressBar.tsx b/electron/src/components/UpdateProgressBar.tsx
new file mode 100644
index 00000000..55fc0c0e
--- /dev/null
+++ b/electron/src/components/UpdateProgressBar.tsx
@@ -0,0 +1,137 @@
+import React from "react";
+import { Icon } from "./Icon";
+import { cn } from "@/lib/utils";
+import type { UpdateStep } from "@/stores/updateStore";
+
+interface UpdateProgressBarProps {
+ steps: UpdateStep[];
+ overallProgress: number;
+ className?: string;
+}
+
+export function UpdateProgressBar({
+ steps,
+ overallProgress,
+ className,
+}: UpdateProgressBarProps) {
+ return (
+
+ {/* Overall Progress Bar */}
+
+
+ Overall Progress
+
+ {overallProgress}%
+
+
+
+
+
+ {/* Steps List */}
+
+ {steps.map((step) => (
+
+ {/* Status Icon */}
+
+ {step.status === "pending" && (
+
+ )}
+ {step.status === "in-progress" && (
+
+ )}
+ {step.status === "completed" && (
+
+
+
+ )}
+ {step.status === "error" && (
+
+
+
+ )}
+
+
+ {/* Step Content */}
+
+
+
+
+ {step.label}
+
+ {/* Subsector Badge - minimal inline display */}
+
+ (
+ {step.id === "rust-build"
+ ? "rust & electron"
+ : step.subsector}
+ )
+
+
+ {/* Progress percentage for in-progress steps */}
+ {step.status === "in-progress" &&
+ step.progress !== undefined &&
+ step.progress > 0 && (
+
+ {Math.round(step.progress)}%
+
+ )}
+
+
+ {/* Progress bar for steps with detailed tracking */}
+ {step.status === "in-progress" &&
+ step.progress !== undefined &&
+ step.progress > 0 && (
+
+ )}
+
+
+ ))}
+
+
+ );
+}
diff --git a/electron/src/helpers/ipc/update/update-channels.ts b/electron/src/helpers/ipc/update/update-channels.ts
index 8bae21ab..7043c022 100644
--- a/electron/src/helpers/ipc/update/update-channels.ts
+++ b/electron/src/helpers/ipc/update/update-channels.ts
@@ -2,3 +2,4 @@ export const UPDATE_EXECUTE = "update:execute";
export const UPDATE_LOG = "update:log";
export const UPDATE_END = "update:end";
export const UPDATE_CANCEL = "update:cancel";
+export const UPDATE_STEP = "update:step";
diff --git a/electron/src/helpers/ipc/update/update-context.ts b/electron/src/helpers/ipc/update/update-context.ts
index 54b8891a..b5657876 100644
--- a/electron/src/helpers/ipc/update/update-context.ts
+++ b/electron/src/helpers/ipc/update/update-context.ts
@@ -3,6 +3,7 @@ import {
UPDATE_END,
UPDATE_EXECUTE,
UPDATE_LOG,
+ UPDATE_STEP,
} from "./update-channels";
type UpdateExecuteInvokeParams = {
@@ -16,11 +17,20 @@ type UpdateExecuteInvokeParams = {
};
};
+type UpdateStepParams = {
+ stepId: string;
+ status: "pending" | "in-progress" | "completed" | "error";
+ progress?: number;
+};
+
export function exposeUpdateContext() {
const { contextBridge, ipcRenderer } = window.require("electron");
let currentLogListener: ((event: any, log: string) => void) | null = null;
let currentEndListener: ((event: any, params: any) => void) | null = null;
+ let currentStepListener:
+ | ((event: any, params: UpdateStepParams) => void)
+ | null = null;
contextBridge.exposeInMainWorld("update", {
execute: (params: UpdateExecuteInvokeParams) =>
@@ -52,5 +62,17 @@ export function exposeUpdateContext() {
ipcRenderer.on(UPDATE_END, currentEndListener);
},
+
+ onStep: (callback: (params: UpdateStepParams) => void) => {
+ if (currentStepListener) {
+ ipcRenderer.removeListener(UPDATE_STEP, currentStepListener);
+ }
+
+ currentStepListener = (_event, params) => {
+ callback(params);
+ };
+
+ ipcRenderer.on(UPDATE_STEP, currentStepListener);
+ },
});
}
diff --git a/electron/src/helpers/ipc/update/update-listeners.ts b/electron/src/helpers/ipc/update/update-listeners.ts
index da6cf374..32ab0745 100644
--- a/electron/src/helpers/ipc/update/update-listeners.ts
+++ b/electron/src/helpers/ipc/update/update-listeners.ts
@@ -4,6 +4,7 @@ import {
UPDATE_END,
UPDATE_EXECUTE,
UPDATE_LOG,
+ UPDATE_STEP,
} from "./update-channels";
import { spawn, ChildProcess } from "child_process";
import tkill from "@jub3i/tree-kill";
@@ -116,6 +117,13 @@ async function update(
commit,
});
+ // Reset Rust build progress tracking
+ rustBuildProgress = {
+ totalDerivations: 0,
+ builtDerivations: 0,
+ maxPercent: 0,
+ };
+
const qitechControlEnv = process.env.QITECH_CONTROL_ENV;
const homeDir =
qitechControlEnv === "control-os" ? "/home/qitech" : process.env.HOME;
@@ -129,6 +137,8 @@ async function update(
// 1. first make sure the clone path is empty by deleting it if it containsa .git folder
const repoDir = `${homeDir}/${githubRepoName}`;
+
+ // Clear repo (not tracked in progress UI)
const clearResult = await clearRepoDirectory(
`${homeDir}/${githubRepoName}`,
event,
@@ -139,6 +149,10 @@ async function update(
}
// 2. clone the repository
+ event.sender.send(UPDATE_STEP, {
+ stepId: "clone-repo",
+ status: "in-progress",
+ });
const cloneResult = await cloneRepository(
{
githubRepoOwner,
@@ -151,11 +165,19 @@ async function update(
event,
);
if (!cloneResult.success) {
+ event.sender.send(UPDATE_STEP, {
+ stepId: "clone-repo",
+ status: "error",
+ });
event.sender.send(UPDATE_LOG, cloneResult.error);
return;
}
+ event.sender.send(UPDATE_STEP, {
+ stepId: "clone-repo",
+ status: "completed",
+ });
- // 3. make the nixos-install.sh script executable
+ // 3. make the nixos-install.sh script executable (not tracked in progress UI)
const chmodResult = await runCommand(
"chmod",
["+x", "nixos-install.sh"],
@@ -168,17 +190,35 @@ async function update(
}
// 4. run the nixos-install.sh script
- const installResult = await runCommand(
+ // This script will handle rust-build, electron-build, and system-install
+ // Start with rust-build (cargo builds)
+ event.sender.send(UPDATE_STEP, {
+ stepId: "rust-build",
+ status: "in-progress",
+ });
+
+ const installResult = await runCommandWithStepTracking(
"./nixos-install.sh",
[],
repoDir,
event,
);
+
if (!installResult.success) {
+ // Mark current and remaining steps as error
+ event.sender.send(UPDATE_STEP, {
+ stepId: "rust-build",
+ status: "error",
+ });
+ event.sender.send(UPDATE_STEP, {
+ stepId: "system-install",
+ status: "error",
+ });
event.sender.send(UPDATE_LOG, installResult.error);
return;
}
+ // Success - steps are already marked as completed by runCommandWithStepTracking
resolve();
} catch (error: any) {
reject(error);
@@ -244,7 +284,7 @@ async function cloneRepository(
: `https://github.com/${githubRepoOwner}/${githubRepoName}.git`;
// Determine clone arguments based on whether tag, branch, or commit is specified
- const cloneArgs = ["clone", repoUrl];
+ const cloneArgs = ["clone", "--progress", repoUrl];
if (tag) {
// Clone a specific tag
@@ -333,6 +373,11 @@ async function runCommand(
const log = data.toString();
console.error(log);
event.sender.send(UPDATE_LOG, log);
+
+ // Git outputs progress to stderr
+ if (cmd === "git" && args.includes("--progress")) {
+ parseGitProgress(log, event);
+ }
});
// Handle process completion
@@ -386,6 +431,249 @@ async function runCommand(
}
}
+// Parse Git clone progress
+function parseGitProgress(
+ log: string,
+ event: Electron.IpcMainInvokeEvent,
+): void {
+ // Git progress format: "Receiving objects: 45% (234/520)"
+ // or "Resolving deltas: 100% (150/150)"
+ const receivingMatch = log.match(/Receiving objects:\s*(\d+)%/);
+ const resolvingMatch = log.match(/Resolving deltas:\s*(\d+)%/);
+
+ if (receivingMatch) {
+ const percent = parseInt(receivingMatch[1], 10);
+ event.sender.send(UPDATE_STEP, {
+ stepId: "clone-repo",
+ status: "in-progress",
+ progress: Math.floor(percent * 0.8), // Receiving is 80% of clone
+ });
+ } else if (resolvingMatch) {
+ const percent = parseInt(resolvingMatch[1], 10);
+ event.sender.send(UPDATE_STEP, {
+ stepId: "clone-repo",
+ status: "in-progress",
+ progress: Math.floor(80 + percent * 0.2), // Resolving is last 20%
+ });
+ }
+}
+
+// Track Rust build progress
+let rustBuildProgress = {
+ totalDerivations: 0,
+ builtDerivations: 0,
+ maxPercent: 0, // Track max to prevent backward movement
+};
+
+// Parse Rust build output for progress
+function parseRustBuildOutput(
+ log: string,
+ event: Electron.IpcMainInvokeEvent,
+): void {
+ // Track derivations to build
+ const derivationsMatch = log.match(/these (\d+) derivations? will be built/i);
+ if (derivationsMatch) {
+ rustBuildProgress.totalDerivations = parseInt(derivationsMatch[1], 10);
+ rustBuildProgress.builtDerivations = 0;
+ rustBuildProgress.maxPercent = 0;
+ event.sender.send(UPDATE_STEP, {
+ stepId: "rust-build",
+ status: "in-progress",
+ progress: 0,
+ });
+ return;
+ }
+
+ // Track building packages
+ if (
+ log.includes("building '/nix/store/") ||
+ log.includes("building /nix/store/")
+ ) {
+ rustBuildProgress.builtDerivations++;
+
+ // Check if this is the server-deps package (one of the last builds)
+ const isServerDeps = log.includes("-server-deps");
+
+ let percent = 15;
+ if (isServerDeps) {
+ // server-deps indicates we're at 85%
+ percent = 85;
+ } else if (rustBuildProgress.totalDerivations > 0) {
+ const derivationProgress =
+ rustBuildProgress.builtDerivations / rustBuildProgress.totalDerivations;
+ percent = 15 + Math.floor(derivationProgress * 70); // Map to 15-85%
+ }
+
+ // Only move forward
+ percent = Math.max(percent, rustBuildProgress.maxPercent);
+ rustBuildProgress.maxPercent = percent;
+
+ event.sender.send(UPDATE_STEP, {
+ stepId: "rust-build",
+ status: "in-progress",
+ progress: percent,
+ });
+ }
+
+ // Track installing phase - go up to 90%
+ if (log.includes("installing") || log.includes("Installing")) {
+ const percent = Math.max(90, rustBuildProgress.maxPercent);
+ rustBuildProgress.maxPercent = percent;
+ event.sender.send(UPDATE_STEP, {
+ stepId: "rust-build",
+ status: "in-progress",
+ progress: percent,
+ });
+ }
+}
+
+// Enhanced version of runCommand that tracks build steps based on log output
+async function runCommandWithStepTracking(
+ cmd: string,
+ args: string[],
+ workingDir: string,
+ event: Electron.IpcMainInvokeEvent,
+): Promise<{ success: boolean; error?: string }> {
+ try {
+ const completeCommand = `${cmd} ${args.join(" ")}`;
+ const workingDirText = terminalGray(workingDir);
+ event.sender.send(
+ UPDATE_LOG,
+ `🚀 ${workingDirText} ${terminalColor("blue", completeCommand)}`,
+ );
+
+ const childProcess = spawn(cmd, args, {
+ cwd: workingDir,
+ });
+
+ // Store reference to current process for cancellation
+ currentUpdateProcess = childProcess;
+
+ // Track which steps have been marked as in-progress
+ // Note: rust-build is already marked as in-progress before calling this function
+ let systemInstallStarted = false;
+
+ // Function to process log output and update steps
+ const processLogForSteps = (log: string) => {
+ const logLower = log.toLowerCase();
+
+ // Parse Rust build progress
+ parseRustBuildOutput(log, event);
+
+ // Check for system install indicators
+ // System install happens after Rust build completes (at ~90%)
+ // Look for bootloader/activation messages that indicate final system installation
+ if (
+ !systemInstallStarted &&
+ rustBuildProgress.maxPercent >= 90 &&
+ (logLower.includes("updating grub") ||
+ logLower.includes("installing bootloader") ||
+ logLower.includes("updating bootloader") ||
+ logLower.includes("activating the configuration") ||
+ logLower.includes("building the system configuration") ||
+ logLower.includes("these 0 derivations"))
+ ) {
+ console.log("🟢 Detected system install start:", log.substring(0, 100));
+ // Mark rust as complete, start system install
+ event.sender.send(UPDATE_STEP, {
+ stepId: "rust-build",
+ status: "completed",
+ });
+ event.sender.send(UPDATE_STEP, {
+ stepId: "system-install",
+ status: "in-progress",
+ });
+ systemInstallStarted = true;
+ }
+ };
+
+ // Stream stdout logs back to renderer
+ childProcess.stdout.on("data", (data) => {
+ const log = data.toString();
+ console.log(log);
+ event.sender.send(UPDATE_LOG, log);
+ processLogForSteps(log);
+ });
+
+ // Stream stderr logs back to renderer
+ childProcess.stderr.on("data", (data) => {
+ const log = data.toString();
+ console.error(log);
+ event.sender.send(UPDATE_LOG, log);
+ processLogForSteps(log);
+ });
+
+ // Handle process completion
+ return new Promise((resolve, reject) => {
+ childProcess.on("close", (code, signal) => {
+ // Clear process reference when completed
+ if (currentUpdateProcess === childProcess) {
+ currentUpdateProcess = null;
+ }
+
+ if (signal === "SIGTERM" || signal === "SIGKILL") {
+ event.sender.send(UPDATE_LOG, terminalInfo("Command was cancelled"));
+ reject({
+ success: false,
+ error: "Command was cancelled",
+ });
+ } else if (code === 0) {
+ // Mark all remaining steps as completed on success
+ if (!systemInstallStarted) {
+ event.sender.send(UPDATE_STEP, {
+ stepId: "rust-build",
+ status: "completed",
+ });
+ event.sender.send(UPDATE_STEP, {
+ stepId: "system-install",
+ status: "completed",
+ });
+ } else {
+ event.sender.send(UPDATE_STEP, {
+ stepId: "system-install",
+ status: "completed",
+ });
+ }
+
+ event.sender.send(
+ UPDATE_LOG,
+ terminalSuccess("Command completed successfully"),
+ );
+ resolve({ success: true, error: undefined });
+ } else {
+ event.sender.send(
+ UPDATE_LOG,
+ terminalError(`Command failed with code ${code}`),
+ );
+ reject({
+ success: false,
+ error: terminalError(code?.toString() ?? "NO_CODE"),
+ });
+ }
+ });
+
+ childProcess.on("error", (err) => {
+ // Clear process reference on error
+ if (currentUpdateProcess === childProcess) {
+ currentUpdateProcess = null;
+ }
+
+ event.sender.send(
+ UPDATE_LOG,
+ terminalError(`Command error: ${err.message}`),
+ );
+ reject({
+ success: false,
+ error: terminalError(err.message),
+ });
+ });
+ });
+ } catch (error: any) {
+ event.sender.send(UPDATE_LOG, terminalError(`Error: ${error.toString()}`));
+ return { success: false, error: error.toString() };
+ }
+}
+
function terminalColor(
color: "blue" | "green" | "red" | "cyan" | "gray",
text: string,
diff --git a/electron/src/helpers/update_helpers.ts b/electron/src/helpers/update_helpers.ts
index 9716781e..cfe5df6c 100644
--- a/electron/src/helpers/update_helpers.ts
+++ b/electron/src/helpers/update_helpers.ts
@@ -2,6 +2,13 @@ import type { UpdateInfo } from "@/stores/updateStore";
import { useUpdateStore } from "@/stores/updateStore";
let currentLogListener: ((line: string) => void) | null = null;
+let currentStepListener:
+ | ((params: {
+ stepId: string;
+ status: "pending" | "in-progress" | "completed" | "error";
+ progress?: number;
+ }) => void)
+ | null = null;
export async function updateExecute(
source: UpdateInfo,
@@ -14,15 +21,32 @@ export async function updateExecute(
currentLogListener = null;
}
+ if (currentStepListener) {
+ window.update.onStep(() => {}); // clear previous
+ currentStepListener = null;
+ }
+
currentLogListener = onLog;
window.update.onLog(currentLogListener);
+ // Set up step listener
+ currentStepListener = (params) => {
+ const { setStepStatus, setStepProgress } = useUpdateStore.getState();
+ setStepStatus(params.stepId, params.status);
+ if (params.progress !== undefined) {
+ setStepProgress(params.stepId, params.progress);
+ }
+ };
+ window.update.onStep(currentStepListener);
+
window.update.execute(source);
window.update.onEnd((params) => {
window.update.onLog(() => {}); // remove listener
window.update.onEnd(() => {});
+ window.update.onStep(() => {}); // remove step listener
currentLogListener = null;
+ currentStepListener = null;
resolve(params);
});
});
@@ -55,6 +79,7 @@ export async function updateCancel(): Promise<{
const result = await window.update.cancel();
window.update.onLog(() => {}); // remove listener
window.update.onEnd(() => {}); // remove listener
+ window.update.onStep(() => {}); // remove step listener
return result;
}
diff --git a/electron/src/setup/UpdateExecutePage.tsx b/electron/src/setup/UpdateExecutePage.tsx
index af6cb264..2d167c1b 100644
--- a/electron/src/setup/UpdateExecutePage.tsx
+++ b/electron/src/setup/UpdateExecutePage.tsx
@@ -3,6 +3,7 @@ import { Page } from "@/components/Page";
import { SectionTitle } from "@/components/SectionTitle";
import { Terminal } from "@/components/Terminal";
import { TouchButton } from "@/components/touch/TouchButton";
+import { UpdateProgressBar } from "@/components/UpdateProgressBar";
import { updateExecute, updateCancelWithStore } from "@/helpers/update_helpers";
import { useUpdateStore } from "@/stores/updateStore";
import { useSearch } from "@tanstack/react-router";
@@ -18,12 +19,15 @@ export function UpdateExecutePage() {
isUpdating,
terminalLines,
currentUpdateInfo,
+ steps,
+ overallProgress,
setUpdateInfo,
startUpdate,
stopUpdate,
addTerminalLine,
clearTerminalLines,
resetUpdateState,
+ initializeSteps,
} = useUpdateStore();
// Set update info from search params when component mounts or search changes
@@ -50,6 +54,7 @@ export function UpdateExecutePage() {
commit: search.commit,
};
+ initializeSteps();
startUpdate();
// Perhaps we just need to clear the logs ?
const res = await updateExecute(updateInfo, addTerminalLine);
@@ -138,6 +143,15 @@ export function UpdateExecutePage() {
)}
+ {/* Progress Bar */}
+ {isUpdating && (
+
+ )}
+
void;
clearTerminalLines: () => void;
resetUpdateState: () => void;
+ setStepStatus: (stepId: string, status: UpdateStep["status"]) => void;
+ setStepProgress: (stepId: string, progress: number) => void;
+ initializeSteps: () => void;
+ updateProgress: (progress: number) => void;
};
export type UpdateStore = UpdateState & UpdateActions;
+const defaultSteps: UpdateStep[] = [
+ {
+ id: "clone-repo",
+ label: "Clone repository",
+ status: "pending",
+ subsector: "general",
+ progress: 0,
+ },
+ {
+ id: "rust-build",
+ label: "Build system packages",
+ status: "pending",
+ subsector: "rust",
+ progress: 0,
+ },
+ {
+ id: "system-install",
+ label: "Install system update",
+ status: "pending",
+ subsector: "nixos",
+ progress: 0,
+ },
+];
+
+// Helper function to calculate overall progress based on step weights and individual progress
+const calculateOverallProgress = (steps: UpdateStep[]): number => {
+ // Define weights for each step (should sum to 100)
+ const stepWeights: Record = {
+ "clone-repo": 10, // 10% for cloning
+ "rust-build": 80, // 80% for building (goes up to 90% internally)
+ "system-install": 10, // 10% for system installation
+ };
+
+ let totalProgress = 0;
+
+ steps.forEach((step) => {
+ const weight = stepWeights[step.id] || 0;
+
+ if (step.status === "completed") {
+ // Completed steps contribute their full weight
+ totalProgress += weight;
+ } else if (step.status === "in-progress") {
+ // In-progress steps: use progress value or 0 if not yet set
+ const stepProgress = step.progress ?? 0;
+
+ if (step.id === "rust-build") {
+ // Rust build progress is 0-90%, map it to 0-100% of its weight
+ totalProgress += (stepProgress / 90) * weight;
+ } else {
+ // Other steps: progress is 0-100%
+ totalProgress += (stepProgress / 100) * weight;
+ }
+ }
+ // Pending and error steps contribute 0
+ });
+
+ return Math.round(Math.min(100, Math.max(0, totalProgress)));
+};
+
const initialState: UpdateState = {
isUpdating: false,
terminalLines: [],
currentUpdateInfo: null,
+ steps: defaultSteps,
+ currentStepIndex: 0,
+ overallProgress: 0,
};
export const useUpdateStore = create((set) => ({
@@ -105,6 +182,63 @@ export const useUpdateStore = create((set) => ({
state.isUpdating = false;
state.terminalLines = [];
state.currentUpdateInfo = null;
+ state.steps = defaultSteps.map((step) => ({ ...step }));
+ state.currentStepIndex = 0;
+ state.overallProgress = 0;
+ }),
+ ),
+
+ initializeSteps: () =>
+ set(
+ produce((state: UpdateState) => {
+ state.steps = defaultSteps.map((step) => ({ ...step }));
+ state.currentStepIndex = 0;
+ state.overallProgress = 0;
+ }),
+ ),
+
+ setStepStatus: (stepId: string, status: UpdateStep["status"]) =>
+ set(
+ produce((state: UpdateState) => {
+ const stepIndex = state.steps.findIndex((s) => s.id === stepId);
+ if (stepIndex !== -1) {
+ state.steps[stepIndex].status = status;
+
+ // Update current step index and ensure progress is initialized
+ if (status === "in-progress") {
+ state.currentStepIndex = stepIndex;
+ // Initialize progress to 0 if not already set
+ if (state.steps[stepIndex].progress === undefined) {
+ state.steps[stepIndex].progress = 0;
+ }
+ }
+
+ // Calculate overall progress based on step weights and progress
+ state.overallProgress = calculateOverallProgress(state.steps);
+ }
+ }),
+ ),
+
+ setStepProgress: (stepId: string, progress: number) =>
+ set(
+ produce((state: UpdateState) => {
+ const stepIndex = state.steps.findIndex((s) => s.id === stepId);
+ if (stepIndex !== -1) {
+ state.steps[stepIndex].progress = Math.min(
+ 100,
+ Math.max(0, progress),
+ );
+
+ // Recalculate overall progress when step progress changes
+ state.overallProgress = calculateOverallProgress(state.steps);
+ }
+ }),
+ ),
+
+ updateProgress: (progress: number) =>
+ set(
+ produce((state: UpdateState) => {
+ state.overallProgress = Math.min(100, Math.max(0, progress));
}),
),
}));
diff --git a/electron/src/types.d.ts b/electron/src/types.d.ts
index 48bf7733..f683fdc0 100644
--- a/electron/src/types.d.ts
+++ b/electron/src/types.d.ts
@@ -46,6 +46,13 @@ interface UpdateContext {
onEnd: (
callback: (params: { success: boolean; error?: string }) => void,
) => void;
+ onStep: (
+ callback: (params: {
+ stepId: string;
+ status: "pending" | "in-progress" | "completed" | "error";
+ progress?: number;
+ }) => void,
+ ) => void;
}
interface TroubleshootContext {