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
365 changes: 317 additions & 48 deletions src-tauri/src/config.rs

Large diffs are not rendered by default.

30 changes: 25 additions & 5 deletions src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,10 @@ async fn init_gladia_session(
.filter(|entries| !entries.is_empty())
.or_else(|| config::get_custom_vocabulary().ok())
.unwrap_or_default();
let endpointing = endpointing.unwrap_or_else(config::endpointing);
let endpointing = match endpointing {
Some(endpointing) => endpointing,
None => config::endpointing()?,
};
gladia
.init_session(
&api_key,
Expand Down Expand Up @@ -424,7 +427,10 @@ async fn subscribe_to_transcriptions(
let mut cleaner = utterance_cleaner::UtteranceCleaner::new();
// When enabled, the final transcription is left on the clipboard at
// session end instead of restoring the user's original clipboard.
let copy_to_clipboard = config::copy_to_clipboard();
let copy_to_clipboard = config::copy_to_clipboard().unwrap_or_else(|error| {
log::error!("[config] failed to load copy-to-clipboard setting; using false: {error}");
false
});
let mut final_transcript: Option<String> = None;
// Snapshot the user's clipboard once so it can be restored at session end.
let original_clipboard = clipboard_get();
Expand Down Expand Up @@ -1162,8 +1168,18 @@ fn main() {
});

let current_version = env!("CARGO_PKG_VERSION");
let installed = config::get_installed_version();
let version_changed = installed.as_deref() != Some(current_version);
let installed = match config::get_installed_version() {
Ok(installed) => Some(installed),
Err(error) => {
log::error!(
"[config] failed to load installed version; skipping config migrations: {error}"
);
None
}
};
let version_changed = installed
.as_ref()
.is_some_and(|installed| installed.as_deref() != Some(current_version));

// One-time TCC cleanup for legacy bundle ids / signing migrations (FDE-147).
// Does NOT run on every version bump — stable signing keeps the grant alive.
Expand All @@ -1175,7 +1191,10 @@ fn main() {
if version_changed {
log::info!(
"Post-update accessibility re-validation (v{} -> v{current_version})",
installed.as_deref().unwrap_or("none")
installed
.as_ref()
.and_then(|installed| installed.as_deref())
.unwrap_or("none")
);
}
let _ = permissions::accessibility::check_and_log_state();
Expand Down Expand Up @@ -1267,6 +1286,7 @@ fn main() {
config::save_api_key,
config::get_api_key,
config::delete_api_key,
config::reset_corrupted_config,
init_gladia_session,
test_gladia_connection,
list_transcription_history,
Expand Down
9 changes: 6 additions & 3 deletions src-tauri/src/permissions/accessibility.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,10 @@ mod macos {

pub fn check_state() -> AccessibilityState {
let trusted = macos::is_trusted();
let prompted = crate::config::is_accessibility_prompted();
let prompted = crate::config::is_accessibility_prompted().unwrap_or_else(|error| {
log::error!("[config] failed to load accessibility prompt state; using false: {error}");
false
});
classify(trusted, prompted)
}

Expand Down Expand Up @@ -178,8 +181,8 @@ pub fn check_and_log_state() -> AccessibilityState {

/// One-time migration: clear stale TCC rows from legacy bundle ids / signing changes.
pub fn maybe_run_migration_reset() -> Result<bool, String> {
let tcc_reset_done = crate::config::is_tcc_reset_done();
let cleanup_generation = crate::config::get_cleanup_generation();
let tcc_reset_done = crate::config::is_tcc_reset_done()?;
let cleanup_generation = crate::config::get_cleanup_generation()?;
if !is_tcc_cleanup_pending(tcc_reset_done, cleanup_generation) {
return Ok(false);
}
Expand Down
106 changes: 96 additions & 10 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
SHORT_EMPTY_DICTATION_LIMIT,
updateHoldModeWarningStreak,
} from "./lib/holdModeWarning";
import { loadSavedApiKey, resetCorruptedConfig } from "./lib/configLoad";
import { SidebarNav, type NavScreen } from "./components/SidebarNav";
import { AppSettingsView } from "./components/AppSettingsView";
import { TranscriptionSettingsView } from "./components/TranscriptionSettingsView";
Expand All @@ -57,6 +58,7 @@ import { CustomVocabularyView } from "./components/CustomVocabularyView";
import { HistoryView } from "./components/HistoryView";
import { HISTORY_PAGE_SIZE } from "./components/HistoryView";
import { HoldModeWarningToast } from "./components/HoldModeWarningToast";
import { ConfigResetDialog } from "./components/ConfigResetDialog";

type Screen = NavScreen | "permissions" | "api-onboarding";

Expand Down Expand Up @@ -132,6 +134,12 @@ export default function App() {
const [isApiKeyLocked, setIsApiKeyLocked] = useState(false);
const [hasSavedApiKey, setHasSavedApiKey] = useState(false);
const [isTestingApiKey, setIsTestingApiKey] = useState(false);
const [configLoadPending, setConfigLoadPending] = useState(true);
const [configLoadError, setConfigLoadError] = useState<string | null>(null);
const [configResetConfirmationOpen, setConfigResetConfirmationOpen] =
useState(false);
const [isResettingConfig, setIsResettingConfig] = useState(false);
const [configResetError, setConfigResetError] = useState<string | null>(null);
const [transcript, setTranscript] = useState<string>("");
const [isRecording, setIsRecording] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
Expand Down Expand Up @@ -427,6 +435,52 @@ export default function App() {
const pressedKeysRef = useRef(new Set<string>());
const recordedHotkeyRef = useRef<string>("");

const loadApiKeyFromConfig = useCallback(async () => {
setConfigLoadPending(true);
const result = await loadSavedApiKey();
if (!result.ok) {
await logError(
"[config] API key load failed; showing configuration recovery screen",
).catch(() => {});
setConfigLoadError(result.message);
setConfigLoadPending(false);
return;
}

setConfigLoadError(null);
if (result.apiKey) {
setApiKey(result.apiKey);
setIsApiKeyLocked(true);
setHasSavedApiKey(true);
} else {
setApiKey("");
setIsApiKeyLocked(false);
setHasSavedApiKey(false);
}
setConfigLoadPending(false);
}, []);

const handleResetCorruptedConfig = useCallback(async () => {
setIsResettingConfig(true);
setConfigResetError(null);
const result = await resetCorruptedConfig();
if (!result.ok) {
await logError("[config] user-confirmed settings reset failed").catch(
() => {},
);
setConfigResetError(result.message);
setIsResettingConfig(false);
return;
}

await logInfo(
"[config] user-confirmed settings reset completed; reloading defaults",
).catch(() => {});
setConfigResetConfirmationOpen(false);
setIsResettingConfig(false);
await loadApiKeyFromConfig();
}, [loadApiKeyFromConfig]);

useEffect(() => {
const init = async () => {
const p = await invoke<string>("get_platform").catch(() => "macos");
Expand All @@ -438,14 +492,7 @@ export default function App() {
const v = await getVersion().catch(() => "");
setAppVersion(v);

const savedKey = await invoke<string | null>("get_api_key").catch(
() => null,
);
if (savedKey) {
setApiKey(savedKey);
setIsApiKeyLocked(true);
setHasSavedApiKey(true);
}
await loadApiKeyFromConfig();

const savedHotkey = await invoke<string | null>("get_hotkey").catch(
() => null,
Expand Down Expand Up @@ -503,7 +550,7 @@ export default function App() {
setSettingsReady(true);
};
init();
}, []);
}, [loadApiKeyFromConfig]);

useEffect(() => {
if (!bootstrap) {
Expand Down Expand Up @@ -1650,7 +1697,7 @@ export default function App() {
const isGateScreen =
activeScreen === "permissions" || activeScreen === "api-onboarding";

if (!bootstrap) {
if (configLoadPending || !bootstrap) {
return (
<main className="loading-shell">
<div className="loading-spinner" />
Expand All @@ -1659,6 +1706,45 @@ export default function App() {
);
}

if (configLoadError) {
return (
<main className="loading-shell config-error-shell">
<h2 className="setup-title">Unable to load settings</h2>
<p className="setup-desc">{configLoadError}</p>
<div className="setup-nav setup-nav-center">
<button className="btn btn-primary" onClick={loadApiKeyFromConfig}>
Retry
</button>
<button
className="btn btn-ghost"
onClick={() => invoke("open_log_folder").catch(console.error)}
>
Open logs folder
</button>
<button
className="btn btn-ghost config-reset-trigger"
onClick={() => {
setConfigResetError(null);
setConfigResetConfirmationOpen(true);
}}
>
Reset settings…
</button>
</div>
<ConfigResetDialog
open={configResetConfirmationOpen}
isResetting={isResettingConfig}
error={configResetError}
onCancel={() => {
setConfigResetConfirmationOpen(false);
setConfigResetError(null);
}}
onConfirm={handleResetCorruptedConfig}
/>
</main>
);
}

return (
<div className={`layout${isGateScreen ? " layout-gate" : ""}`}>
{!isGateScreen && (
Expand Down
24 changes: 24 additions & 0 deletions src/components/ConfigResetDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { ConfigResetDialog } from "./ConfigResetDialog";

describe("ConfigResetDialog", () => {
it("clearly describes the destructive action and retained backup", () => {
const html = renderToStaticMarkup(
<ConfigResetDialog
open
isResetting={false}
error={null}
onCancel={vi.fn()}
onConfirm={vi.fn()}
/>,
);

expect(html).toContain("Reset all settings?");
expect(html).toContain("API key");
expect(html).toContain("shortcut");
expect(html).toContain("timestamped backup");
expect(html).toContain("Reset all settings");
expect(html).toContain("Cancel");
});
});
84 changes: 84 additions & 0 deletions src/components/ConfigResetDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { useEffect, useRef } from "react";

export function ConfigResetDialog({
open,
isResetting,
error,
onCancel,
onConfirm,
}: {
open: boolean;
isResetting: boolean;
error: string | null;
onCancel: () => void;
onConfirm: () => void;
}) {
const dialogRef = useRef<HTMLDialogElement>(null);

useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open && !dialog.open) dialog.showModal();
return () => {
if (dialog.open) dialog.close();
};
}, [open]);

if (!open) return null;

return (
<dialog
ref={dialogRef}
className="config-reset-dialog"
aria-labelledby="config-reset-title"
aria-describedby="config-reset-description"
onCancel={(event) => {
event.preventDefault();
if (!isResetting) onCancel();
}}
onMouseDown={(event) => {
if (isResetting) return;
const bounds = event.currentTarget.getBoundingClientRect();
const clickedBackdrop =
event.clientX < bounds.left ||
event.clientX > bounds.right ||
event.clientY < bounds.top ||
event.clientY > bounds.bottom;
if (clickedBackdrop) onCancel();
}}
>
<h3 id="config-reset-title">Reset all settings?</h3>
<p id="config-reset-description">
This resets your API key, shortcut, languages, vocabulary, and app
preferences. You will need to set up GladiaFlow again.
</p>
<p>
The unreadable settings file will be kept as a timestamped backup. This
action only happens if you confirm below.
</p>
{error && (
<p className="text-danger" role="alert">
{error}
</p>
)}
<div className="config-reset-dialog-actions">
<button
type="button"
className="btn btn-ghost"
onClick={onCancel}
disabled={isResetting}
>
Cancel
</button>
<button
type="button"
className="btn btn-danger"
onClick={onConfirm}
disabled={isResetting}
>
{isResetting ? "Resetting..." : "Reset all settings"}
</button>
</div>
</dialog>
);
}
Loading