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
390 changes: 272 additions & 118 deletions src-tauri/src/commands/app_updates.rs

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src-tauri/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ pub mod types;
pub mod workspace_config;

pub use types::{
AgentConfig, AiProvider, AppConfig, AutoUpdateConfig, ClaiConfig, ExecutionCapabilityConfig,
AgentConfig, AiProvider, AppConfig, ClaiConfig, ExecutionCapabilityConfig,
FilesystemPathAccess, FilesystemPathGrant, GrantOrigin, McpEnvVar, McpServerAuth,
McpServerConfig, McpServerTransport, SandboxNetworkConfig, SandboxSessionBusConfig,
ShellAccessMode, SkillSourceConfig, SkillSourceKind,
Expand Down
55 changes: 20 additions & 35 deletions src-tauri/src/config/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -650,24 +650,6 @@ fn default_workspace_dirs() -> Vec<PathBuf> {
vec![PathBuf::from("~/.clai/workspaces")]
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, ts_rs::TS)]
#[serde(rename_all = "camelCase", default)]
#[ts(export, export_to = "bindings.ts")]
pub struct AutoUpdateConfig {
/// Download new versions in the background on self-update-capable
/// builds; the user still chooses when to restart and apply. Checking
/// for updates is always on and not configurable.
pub auto_download: bool,
}

impl Default for AutoUpdateConfig {
fn default() -> Self {
Self {
auto_download: true,
}
}
}

/// Root app configuration persisted at `~/.clai/config.json`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
Expand Down Expand Up @@ -699,11 +681,6 @@ pub struct AppConfig {
#[serde(default)]
pub system_apps: crate::system_apps::SystemAppsConfig,

/// App updater preferences. Enabled by default for native installer builds;
/// runtime support detection gates package-manager-managed installs.
#[serde(default)]
pub auto_update: AutoUpdateConfig,

/// Global "pause all" overlay for the agent scheduler. When true, NO
/// workspace's scheduled tick runs, regardless of its individual
/// `schedule.paused` state — which is preserved underneath and restored
Expand All @@ -723,7 +700,6 @@ impl Default for AppConfig {
skill_sources: Vec::new(),
provider_connections: Vec::new(),
system_apps: crate::system_apps::SystemAppsConfig::default(),
auto_update: AutoUpdateConfig::default(),
scheduler_paused: false,
}
}
Expand Down Expand Up @@ -784,22 +760,31 @@ mod tests {
}

#[test]
fn app_config_defaults_auto_download_enabled() {
let config = ClaiConfig::default();
assert!(config.auto_update.auto_download);
}

#[test]
fn legacy_config_deserializes_with_auto_download_enabled() {
fn config_written_before_updates_became_mandatory_keeps_its_settings() {
// `autoUpdate.autoDownload` was a user setting until background
// downloads became mandatory on self-updating builds. Configs on disk
// still carry the key, so the removed field must be ignored, NOT
// rejected: a parse error here would silently reset every real
// setting in the file to its default. Both asserted values are
// deliberately non-default so the test can fail.
let legacy = r#"{
"version": 1,
"workspaceDirs": ["~/.clai/workspaces"],
"workspaceDirs": ["/tmp/clai-test-workspaces"],
"mcpServers": [],
"skillSources": [],
"providerConnections": []
"providerConnections": [],
"schedulerPaused": true,
"autoUpdate": { "autoDownload": false }
}"#;
let parsed: ClaiConfig = serde_json::from_str(legacy).unwrap();
assert!(parsed.auto_update.auto_download);
let parsed: ClaiConfig = serde_json::from_str(legacy)
.expect("a config carrying the removed autoUpdate key must still load");

assert_eq!(
parsed.workspace_dirs,
vec![PathBuf::from("/tmp/clai-test-workspaces")],
);
assert_ne!(parsed.workspace_dirs, default_workspace_dirs());
assert!(parsed.scheduler_paused);
}

fn interval_kind(minutes: u32) -> crate::config::workspace_config::ScheduleKind {
Expand Down
2 changes: 0 additions & 2 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -329,8 +329,6 @@ pub fn run() {
// App metadata
commands::app_info::app_version_detail,
// App update commands
commands::app_updates::get_auto_update_settings,
commands::app_updates::set_auto_update_settings,
commands::app_updates::get_app_update_status,
commands::app_updates::check_for_app_update,
commands::app_updates::install_app_update,
Expand Down
49 changes: 48 additions & 1 deletion src/components/AppUpdateBadge.module.css
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
/* Shrinkable (`min-width: 0`) so the pill, not the action button, is what
gives way when the top bar runs out of room. `text-overflow` clips a block
container's own inline content, and this is a flex container whose text
would become an anonymous flex item instead, hence the `.label` span. */
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
min-width: 0;
padding: 4px 10px;
border: 1px solid var(--color-primary);
border-radius: 999px;
Expand All @@ -20,9 +24,52 @@
color: var(--color-bg-primary);
}

/* Truncating a version string would show a plausible-looking wrong version
("v26.8." for 26.8.12), so the ellipsis has to be real. */
.label {
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}

.dot {
flex-shrink: 0;
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
}

.group {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
}

/* Filled, unlike the outlined pill: the pill is information, this is the one
action that applies an update. */
.action {
flex-shrink: 0;
padding: 4px 10px;
border: 1px solid var(--color-primary);
border-radius: 999px;
background: var(--color-primary);
color: var(--color-bg-primary);
font-size: 12px;
font-weight: 600;
line-height: 1;
cursor: pointer;
transition: opacity var(--transition-fast);
}

.action:hover:not(:disabled) {
opacity: 0.85;
}

.action:disabled {
cursor: default;
opacity: 0.6;
}

136 changes: 103 additions & 33 deletions src/components/AppUpdateBadge.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@ import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

const mockInvoke = vi.hoisted(() => vi.fn());
vi.mock('@tauri-apps/api/core', () => ({ invoke: mockInvoke }));
vi.mock('@tauri-apps/api/core', () => ({
invoke: mockInvoke,
// installAppUpdate hands the backend a progress Channel; the badge ignores
// progress (the package is already downloaded) but the class must exist.
Channel: class {
onmessage: unknown = null;
},
}));

// Capture the app-updates://available handler so tests can fire it.
let listenHandlers: Record<string, (event: { payload: unknown }) => void> = {};
Expand All @@ -27,17 +34,16 @@ const UPDATE = {
downloaded: false,
};

const LINUX_DEB_STATUS = {
settings: { autoDownload: true },
support: {
supported: false,
canCheck: true,
platform: 'linux',
arch: 'x86_64',
channel: 'linux_pkg',
bundleType: 'deb',
reason: 'Notify only: updated by your package manager.',
},
const DOWNLOADED = { ...UPDATE, downloaded: true };

const LINUX_DEB_SUPPORT = {
supported: false,
canCheck: true,
platform: 'linux',
arch: 'x86_64',
channel: 'linux_pkg',
bundleType: 'deb',
reason: 'Notify only: updated by your package manager.',
};

const statusWith = (
Expand All @@ -52,75 +58,139 @@ const statusWith = (
reason: null,
}
) => ({
settings: { autoDownload: true },
support,
lastCheck: update ? { checkedAt: '2026-07-24T12:00:00Z', update, error: null } : null,
});

/** Resolves `get_app_update_status`, and lets each test drive the install. */
const mockBackend = (
status: ReturnType<typeof statusWith>,
install?: () => Promise<unknown>
) => {
mockInvoke.mockImplementation((command: string) => {
if (command === 'get_app_update_status') return Promise.resolve(status);
if (command === 'install_app_update') {
return install ? install() : new Promise(() => {});
}
return Promise.reject(new Error(`unexpected command ${command}`));
});
};

beforeEach(() => {
mockInvoke.mockReset();
listenHandlers = {};
});

describe('AppUpdateBadge', () => {
it('renders nothing when no update is available', async () => {
mockInvoke.mockResolvedValue(statusWith(null));
mockBackend(statusWith(null));
const { container } = render(<AppUpdateBadge />);
await waitFor(() => expect(mockInvoke).toHaveBeenCalledWith('get_app_update_status'));
expect(container).toBeEmptyDOMElement();
});

it('shows the version from the seeded backend status', async () => {
mockInvoke.mockResolvedValue(statusWith(UPDATE));
mockBackend(statusWith(UPDATE));
render(<AppUpdateBadge />);
expect(await screen.findByText(/Update available · v26\.8\.1/)).toBeInTheDocument();
});

it('still renders when the build is notify-only (Linux deb/rpm)', async () => {
// The badge shows the same "Update available" pill for notify-only
// builds as for self-updating ones — the only difference is that
// About shows the package-manager copy and there is no toast.
const notifyOnlyUpdate = { ...UPDATE, installable: false };
mockInvoke.mockResolvedValue(statusWith(notifyOnlyUpdate, LINUX_DEB_STATUS.support));
// builds as for self-updating ones — the difference is that no package
// is ever downloaded, so the restart action never appears.
mockBackend(statusWith({ ...UPDATE, installable: false }, LINUX_DEB_SUPPORT));
render(<AppUpdateBadge />);
expect(await screen.findByText(/Update available · v26\.8\.1/)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Restart to install/ })).not.toBeInTheDocument();
});

it('appears when an update event fires after mount', async () => {
mockInvoke.mockResolvedValue(statusWith(null));
mockBackend(statusWith(null));
render(<AppUpdateBadge />);
await waitFor(() => expect(listenHandlers[APP_UPDATE_AVAILABLE_EVENT]).toBeDefined());
listenHandlers[APP_UPDATE_AVAILABLE_EVENT]?.({ payload: { update: UPDATE } });
expect(await screen.findByText(/Update available · v26\.8\.1/)).toBeInTheDocument();
});

it('flips to "Update ready" once the package is downloaded', async () => {
mockInvoke.mockResolvedValue(statusWith(UPDATE));
it('offers the restart action only once the package is downloaded', async () => {
mockBackend(statusWith(UPDATE));
render(<AppUpdateBadge />);
expect(await screen.findByText(/Update available · v26\.8\.1/)).toBeInTheDocument();
// While the background download runs there is nothing to restart into,
// so the pill is informational only.
expect(screen.queryByRole('button', { name: /Restart to install/ })).not.toBeInTheDocument();

await waitFor(() => expect(listenHandlers[APP_UPDATE_AVAILABLE_EVENT]).toBeDefined());
listenHandlers[APP_UPDATE_AVAILABLE_EVENT]?.({
payload: { update: { ...UPDATE, downloaded: true } },
});
expect(await screen.findByText(/Update ready · v26\.8\.1/)).toBeInTheDocument();
listenHandlers[APP_UPDATE_AVAILABLE_EVENT]?.({ payload: { update: DOWNLOADED } });
expect(
await screen.findByRole('button', { name: /Restart to install/ })
).toBeInTheDocument();
// The pill copy stays stable across the flip: the new button is the
// signal, so the version label never rewrites itself under the cursor.
expect(screen.getByText(/Update available · v26\.8\.1/)).toBeInTheDocument();
});

it('does not downgrade "Update ready" when a stale event arrives late', async () => {
mockInvoke.mockResolvedValue(statusWith(null));
it('installs and restarts only when the restart action is clicked', async () => {
mockBackend(statusWith(DOWNLOADED));
render(<AppUpdateBadge />);
const action = await screen.findByRole('button', { name: /Restart to install/ });
expect(mockInvoke).not.toHaveBeenCalledWith('install_app_update', expect.anything());

await userEvent.click(action);

expect(mockInvoke).toHaveBeenCalledWith('install_app_update', expect.anything());
// The backend restarts the app, so the pending state must stay pending
// rather than inviting a second click into the same install.
expect(await screen.findByRole('button', { name: /Restarting/ })).toBeDisabled();
});

it('surfaces a failed install instead of silently doing nothing', async () => {
mockBackend(statusWith(DOWNLOADED), () => Promise.reject('Permission denied'));
render(<AppUpdateBadge />);
await userEvent.click(await screen.findByRole('button', { name: /Restart to install/ }));

// The full reason goes to a live region (and the tooltip) rather than
// inline text, which would stretch the top bar by an arbitrary amount.
expect(await screen.findByRole('alert')).toHaveTextContent('Permission denied');
// Re-enabled: a failed install must be retryable.
const retry = screen.getByRole('button', { name: /Install failed/ });
expect(retry).toBeEnabled();
expect(retry).toHaveAttribute('title', 'Permission denied');
});

it('clears a failed install when a newer version arrives', async () => {
mockBackend(statusWith(DOWNLOADED), () => Promise.reject('Permission denied'));
render(<AppUpdateBadge />);
await userEvent.click(await screen.findByRole('button', { name: /Restart to install/ }));
expect(await screen.findByRole('alert')).toBeInTheDocument();

await waitFor(() => expect(listenHandlers[APP_UPDATE_AVAILABLE_EVENT]).toBeDefined());
listenHandlers[APP_UPDATE_AVAILABLE_EVENT]?.({
payload: { update: { ...UPDATE, downloaded: true } },
payload: { update: { ...DOWNLOADED, version: '26.8.2' } },
});
expect(await screen.findByText(/Update ready · v26\.8\.1/)).toBeInTheDocument();

expect(await screen.findByText(/Update available · v26\.8\.2/)).toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /Restart to install/ })).toBeInTheDocument();
});

it('does not downgrade a downloaded update when a stale event arrives late', async () => {
mockBackend(statusWith(null));
render(<AppUpdateBadge />);
await waitFor(() => expect(listenHandlers[APP_UPDATE_AVAILABLE_EVENT]).toBeDefined());
listenHandlers[APP_UPDATE_AVAILABLE_EVENT]?.({ payload: { update: DOWNLOADED } });
expect(
await screen.findByRole('button', { name: /Restart to install/ })
).toBeInTheDocument();
// A concurrent check that started before the download finished can emit
// downloaded: false after the fact — the badge must not regress.
// downloaded: false after the fact — the action must not vanish.
listenHandlers[APP_UPDATE_AVAILABLE_EVENT]?.({ payload: { update: UPDATE } });
expect(await screen.findByText(/Update ready · v26\.8\.1/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Restart to install/ })).toBeInTheDocument();
});

it('opens global settings at the About tab on click', async () => {
mockInvoke.mockResolvedValue(statusWith(UPDATE));
mockBackend(statusWith(UPDATE));
const opened = vi.fn();
const onOpen = (event: Event) => opened((event as CustomEvent).detail);
window.addEventListener(OPEN_GLOBAL_SETTINGS_EVENT, onOpen);
Expand Down
Loading
Loading