Skip to content
Closed
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
2 changes: 2 additions & 0 deletions apps/desktop-tauri/src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ mod credentials;
mod diagnostics;
mod grok_accounts;
mod locale_cmd;
mod preferences_transfer;
mod provider_detail;
mod provider_refresh;
mod provider_settings;
Expand All @@ -63,6 +64,7 @@ pub use credentials::*;
pub use diagnostics::*;
pub use grok_accounts::*;
pub use locale_cmd::*;
pub use preferences_transfer::*;
pub use provider_detail::*;
#[cfg(test)]
pub(crate) use provider_refresh::is_provider_cache_fresh;
Expand Down
103 changes: 103 additions & 0 deletions apps/desktop-tauri/src-tauri/src/commands/preferences_transfer.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
//! Export and import of the portable preferences document.
//!
//! The document format, allowlist and validation live in
//! `codexbar::settings::PreferencesDocument`; this module only adds the
//! desktop side effects that a settings save normally triggers.

use std::collections::HashSet;
use std::path::Path;
use std::sync::Mutex;

use codexbar::core::ProviderId;
use codexbar::settings::{PreferencesDocument, Settings};
use tauri::{Emitter, Manager};

use super::{SettingsSnapshot, language_label};
use crate::events;
use crate::state::AppState;

fn checked_path(path: &str) -> Result<&Path, String> {
let path = path.trim();
if path.is_empty() {
return Err("Preferences path must not be empty".to_string());
}
Ok(Path::new(path))
}

fn enabled_provider_set_changed(before: &[ProviderId], after: &[ProviderId]) -> bool {
before.iter().copied().collect::<HashSet<_>>() != after.iter().copied().collect()
}

/// Write the current portable preferences to `path`; returns how many were written.
#[tauri::command]
pub fn export_preferences(path: String) -> Result<usize, String> {
let path = checked_path(&path)?;
let document =
PreferencesDocument::from_settings(&Settings::load()).map_err(|error| error.to_string())?;
document
.write_file(path)
.map_err(|error| error.to_string())?;
tracing::info!(count = document.len(), "exported portable preferences");
Ok(document.len())
}

/// Apply the preferences file at `path` to this machine's settings.
///
/// Nothing is saved when the file is rejected. On success the same live
/// updates as `update_settings` follow: locale, float bar, tray and dependent
/// windows, plus a provider cache prune and refresh when the enabled set changed.
#[tauri::command]
pub async fn import_preferences(
app: tauri::AppHandle,
path: String,
) -> Result<SettingsSnapshot, String> {
let path = checked_path(&path)?;
let document = PreferencesDocument::read_file(path).map_err(|error| error.to_string())?;
let mut settings = Settings::load();
let previous_language = settings.ui_language;
let previous_enabled = settings.get_enabled_provider_ids();
let applied = document
.apply_to(&mut settings)
.map_err(|error| error.to_string())?;
settings.save().map_err(|error| error.to_string())?;
tracing::info!(applied, "imported portable preferences");

if settings.ui_language != previous_language {
let _ = app.emit(events::LOCALE_CHANGED, language_label(settings.ui_language));
}
let enabled_ids = settings.get_enabled_provider_ids();
let providers_changed = enabled_provider_set_changed(&previous_enabled, &enabled_ids);
if providers_changed {
let state = app.state::<Mutex<AppState>>();
let _ = super::invalidate_provider_refresh_and_prune_disabled(&state, &enabled_ids);
}
crate::floatbar::notify_settings_changed(&app);
crate::floatbar::apply_state(&app, &settings);
crate::tray_bridge::rebuild_tray_menu(&app);
crate::tray_bridge::refresh_tray_presentation(&app);
events::emit_settings_changed(&app);

if providers_changed {
let refresh_app = app.clone();
tauri::async_runtime::spawn(async move {
let _ = super::do_refresh_providers(&refresh_app).await;
});
}
Ok(SettingsSnapshot::from(settings))
}

#[cfg(test)]
mod tests {
use super::enabled_provider_set_changed;
use codexbar::core::ProviderId;

#[test]
fn refresh_decision_tracks_provider_membership() {
let before = [ProviderId::Claude, ProviderId::Codex];
assert!(!enabled_provider_set_changed(
&before,
&[ProviderId::Codex, ProviderId::Claude]
));
assert!(enabled_provider_set_changed(&before, &[ProviderId::Claude]));
}
}
2 changes: 2 additions & 0 deletions apps/desktop-tauri/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,8 @@ fn main() {
commands::get_provider_local_usage_summary,
commands::get_usage_spend_summary,
commands::write_usage_spend_export,
commands::export_preferences,
commands::import_preferences,
commands::get_spend_contract,
commands::get_codex_workspaces_snapshot,
commands::reorder_providers,
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop-tauri/src/i18n/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,12 @@ export const ALL_LOCALE_KEYS = [
"CriticalUsageWarningHelper",
"GlobalShortcutFieldLabel",
"GlobalShortcutToggleHelper",
"SectionPreferencesTransfer",
"PreferencesTransferCaption",
"PreferencesExportButton",
"PreferencesImportButton",
"PreferencesExportSuccess",
"PreferencesImportSuccess",
"ShortcutRecordButton",
"ShortcutRecordingLabel",
"ShortcutRecordingHint",
Expand Down
10 changes: 10 additions & 0 deletions apps/desktop-tauri/src/lib/tauri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,16 @@ export function writeUsageSpendExport(path: string, payload: string): Promise<vo
return invoke<void>("write_usage_spend_export", { path, payload });
}

/** Write portable preferences to `path`; resolves with how many were exported. */
export function exportPreferences(path: string): Promise<number> {
return invoke<number>("export_preferences", { path });
}

/** Apply the preferences file at `path`; the shell validates before saving anything. */
export function importPreferences(path: string): Promise<SettingsSnapshot> {
return invoke<SettingsSnapshot>("import_preferences", { path });
}

export function getSpendContract(
providerId: string,
options?: { historyDays?: number; includeOpenCodex?: boolean },
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import { ShortcutCapture } from "../../../components/ShortcutCapture";
import { Field, Toggle } from "../../../components/FormControls";
import type { TabProps } from "../settingsTabs";
import PreferencesTransferSection from "./PreferencesTransferSection";

function formatCodexSessionsDirs(paths: string[]): string {
return paths.join("; ");
Expand Down Expand Up @@ -372,6 +373,8 @@ export default function AdvancedTab({ settings, set, saving }: TabProps) {
</div>
</section>

<PreferencesTransferSection />

{/* ── Diagnostics ──────────────────────────────────────────── */}
<section className="settings-section">
<h3 className="settings-section__title settings-section__title--bold">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";

const mocks = vi.hoisted(() => ({
open: vi.fn(),
save: vi.fn(),
exportPreferences: vi.fn(),
importPreferences: vi.fn(),
}));

vi.mock("@tauri-apps/plugin-dialog", () => ({
open: mocks.open,
save: mocks.save,
}));
vi.mock("../../../lib/tauri", () => ({
exportPreferences: mocks.exportPreferences,
importPreferences: mocks.importPreferences,
}));
vi.mock("../../../hooks/useLocale", () => ({
useLocale: () => ({ t: (key: string) => key }),
}));

import PreferencesTransferSection from "./PreferencesTransferSection";

describe("PreferencesTransferSection", () => {
beforeEach(() => {
vi.resetAllMocks();
});

it("exports to the chosen path and reports success", async () => {
mocks.save.mockResolvedValue("C:\\temp\\prefs.json");
mocks.exportPreferences.mockResolvedValue(12);
render(<PreferencesTransferSection />);

fireEvent.click(screen.getByText("PreferencesExportButton"));

await waitFor(() =>
expect(mocks.exportPreferences).toHaveBeenCalledWith("C:\\temp\\prefs.json"),
);
expect(await screen.findByRole("status")).toHaveTextContent(
"PreferencesExportSuccess",
);
expect(mocks.save).toHaveBeenCalledWith(
expect.objectContaining({ defaultPath: "codexbar-preferences.json" }),
);
});

it("does nothing when the save dialog is cancelled", async () => {
mocks.save.mockResolvedValue(null);
render(<PreferencesTransferSection />);

fireEvent.click(screen.getByText("PreferencesExportButton"));

await waitFor(() => expect(mocks.save).toHaveBeenCalled());
expect(mocks.exportPreferences).not.toHaveBeenCalled();
expect(screen.queryByRole("status")).toBeNull();
expect(screen.queryByRole("alert")).toBeNull();
});

it("imports the chosen file and reports success", async () => {
mocks.open.mockResolvedValue("C:\\temp\\prefs.json");
mocks.importPreferences.mockResolvedValue({});
render(<PreferencesTransferSection />);

fireEvent.click(screen.getByText("PreferencesImportButton"));

await waitFor(() =>
expect(mocks.importPreferences).toHaveBeenCalledWith("C:\\temp\\prefs.json"),
);
expect(await screen.findByRole("status")).toHaveTextContent(
"PreferencesImportSuccess",
);
expect(mocks.open).toHaveBeenCalledWith(
expect.objectContaining({ multiple: false }),
);
});

it("does nothing when the open dialog is cancelled", async () => {
mocks.open.mockResolvedValue(null);
render(<PreferencesTransferSection />);

fireEvent.click(screen.getByText("PreferencesImportButton"));

await waitFor(() => expect(mocks.open).toHaveBeenCalled());
expect(mocks.importPreferences).not.toHaveBeenCalled();
});

it("shows the shell's rejection and no success message", async () => {
mocks.open.mockResolvedValue("C:\\temp\\bad.json");
mocks.importPreferences.mockRejectedValue(
"Invalid or non-portable preference: refresh_interval_secs",
);
render(<PreferencesTransferSection />);

fireEvent.click(screen.getByText("PreferencesImportButton"));

expect(await screen.findByRole("alert")).toHaveTextContent(
"Invalid or non-portable preference: refresh_interval_secs",
);
expect(screen.queryByRole("status")).toBeNull();
});

it("disables both buttons while a transfer is running", async () => {
let finish: (value: string | null) => void = () => {};
mocks.save.mockReturnValue(
new Promise<string | null>((resolve) => {
finish = resolve;
}),
);
render(<PreferencesTransferSection />);

fireEvent.click(screen.getByText("PreferencesExportButton"));

await waitFor(() =>
expect(screen.getByText("PreferencesImportButton")).toBeDisabled(),
);
expect(screen.getByText("PreferencesExportButton")).toBeDisabled();
finish(null);
await waitFor(() =>
expect(screen.getByText("PreferencesImportButton")).toBeEnabled(),
);
});
});
Loading