* fix windows-only mic permission guidance * don't hardcode error strings in rust * format --------- Co-authored-by: CJ Pais <cj@cjpais.com>
270 lines
8.8 KiB
TypeScript
270 lines
8.8 KiB
TypeScript
import { useEffect, useState, useRef } from "react";
|
|
import { toast, Toaster } from "sonner";
|
|
import { useTranslation } from "react-i18next";
|
|
import { listen } from "@tauri-apps/api/event";
|
|
import { platform } from "@tauri-apps/plugin-os";
|
|
import {
|
|
checkAccessibilityPermission,
|
|
checkMicrophonePermission,
|
|
} from "tauri-plugin-macos-permissions-api";
|
|
import { ModelStateEvent, RecordingErrorEvent } from "./lib/types/events";
|
|
import "./App.css";
|
|
import AccessibilityPermissions from "./components/AccessibilityPermissions";
|
|
import Footer from "./components/footer";
|
|
import Onboarding, { AccessibilityOnboarding } from "./components/onboarding";
|
|
import { Sidebar, SidebarSection, SECTIONS_CONFIG } from "./components/Sidebar";
|
|
import { useSettings } from "./hooks/useSettings";
|
|
import { useSettingsStore } from "./stores/settingsStore";
|
|
import { commands } from "@/bindings";
|
|
import { getLanguageDirection, initializeRTL } from "@/lib/utils/rtl";
|
|
|
|
type OnboardingStep = "accessibility" | "model" | "done";
|
|
|
|
const renderSettingsContent = (section: SidebarSection) => {
|
|
const ActiveComponent =
|
|
SECTIONS_CONFIG[section]?.component || SECTIONS_CONFIG.general.component;
|
|
return <ActiveComponent />;
|
|
};
|
|
|
|
function App() {
|
|
const { t, i18n } = useTranslation();
|
|
const [onboardingStep, setOnboardingStep] = useState<OnboardingStep | null>(
|
|
null,
|
|
);
|
|
// Track if this is a returning user who just needs to grant permissions
|
|
// (vs a new user who needs full onboarding including model selection)
|
|
const [isReturningUser, setIsReturningUser] = useState(false);
|
|
const [currentSection, setCurrentSection] =
|
|
useState<SidebarSection>("general");
|
|
const { settings, updateSetting } = useSettings();
|
|
const direction = getLanguageDirection(i18n.language);
|
|
const refreshAudioDevices = useSettingsStore(
|
|
(state) => state.refreshAudioDevices,
|
|
);
|
|
const refreshOutputDevices = useSettingsStore(
|
|
(state) => state.refreshOutputDevices,
|
|
);
|
|
const hasCompletedPostOnboardingInit = useRef(false);
|
|
|
|
useEffect(() => {
|
|
checkOnboardingStatus();
|
|
}, []);
|
|
|
|
// Initialize RTL direction when language changes
|
|
useEffect(() => {
|
|
initializeRTL(i18n.language);
|
|
}, [i18n.language]);
|
|
|
|
// Initialize Enigo, shortcuts, and refresh audio devices when main app loads
|
|
useEffect(() => {
|
|
if (onboardingStep === "done" && !hasCompletedPostOnboardingInit.current) {
|
|
hasCompletedPostOnboardingInit.current = true;
|
|
Promise.all([
|
|
commands.initializeEnigo(),
|
|
commands.initializeShortcuts(),
|
|
]).catch((e) => {
|
|
console.warn("Failed to initialize:", e);
|
|
});
|
|
refreshAudioDevices();
|
|
refreshOutputDevices();
|
|
}
|
|
}, [onboardingStep, refreshAudioDevices, refreshOutputDevices]);
|
|
|
|
// Handle keyboard shortcuts for debug mode toggle
|
|
useEffect(() => {
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
// Check for Ctrl+Shift+D (Windows/Linux) or Cmd+Shift+D (macOS)
|
|
const isDebugShortcut =
|
|
event.shiftKey &&
|
|
event.key.toLowerCase() === "d" &&
|
|
(event.ctrlKey || event.metaKey);
|
|
|
|
if (isDebugShortcut) {
|
|
event.preventDefault();
|
|
const currentDebugMode = settings?.debug_mode ?? false;
|
|
updateSetting("debug_mode", !currentDebugMode);
|
|
}
|
|
};
|
|
|
|
// Add event listener when component mounts
|
|
document.addEventListener("keydown", handleKeyDown);
|
|
|
|
// Cleanup event listener when component unmounts
|
|
return () => {
|
|
document.removeEventListener("keydown", handleKeyDown);
|
|
};
|
|
}, [settings?.debug_mode, updateSetting]);
|
|
|
|
// Listen for recording errors from the backend and show a toast
|
|
useEffect(() => {
|
|
const unlisten = listen<RecordingErrorEvent>("recording-error", (event) => {
|
|
const { error_type, detail } = event.payload;
|
|
|
|
if (error_type === "microphone_permission_denied") {
|
|
const currentPlatform = platform();
|
|
const platformKey = `errors.micPermissionDenied.${currentPlatform}`;
|
|
const description = t(platformKey, {
|
|
defaultValue: t("errors.micPermissionDenied.generic"),
|
|
});
|
|
toast.error(t("errors.micPermissionDeniedTitle"), { description });
|
|
} else {
|
|
toast.error(
|
|
t("errors.recordingFailed", { error: detail ?? "Unknown error" }),
|
|
);
|
|
}
|
|
});
|
|
return () => {
|
|
unlisten.then((fn) => fn());
|
|
};
|
|
}, [t]);
|
|
|
|
// Listen for model loading failures and show a toast
|
|
useEffect(() => {
|
|
const unlisten = listen<ModelStateEvent>("model-state-changed", (event) => {
|
|
if (event.payload.event_type === "loading_failed") {
|
|
toast.error(
|
|
t("errors.modelLoadFailed", {
|
|
model:
|
|
event.payload.model_name || t("errors.modelLoadFailedUnknown"),
|
|
}),
|
|
{
|
|
description: event.payload.error,
|
|
},
|
|
);
|
|
}
|
|
});
|
|
return () => {
|
|
unlisten.then((fn) => fn());
|
|
};
|
|
}, [t]);
|
|
|
|
const revealMainWindowForPermissions = async () => {
|
|
try {
|
|
await commands.showMainWindowCommand();
|
|
} catch (e) {
|
|
console.warn("Failed to show main window for permission onboarding:", e);
|
|
}
|
|
};
|
|
|
|
const checkOnboardingStatus = async () => {
|
|
try {
|
|
// Check if they have any models available
|
|
const result = await commands.hasAnyModelsAvailable();
|
|
const hasModels = result.status === "ok" && result.data;
|
|
const currentPlatform = platform();
|
|
|
|
if (hasModels) {
|
|
// Returning user - check if they need to grant permissions first
|
|
setIsReturningUser(true);
|
|
|
|
if (currentPlatform === "macos") {
|
|
try {
|
|
const [hasAccessibility, hasMicrophone] = await Promise.all([
|
|
checkAccessibilityPermission(),
|
|
checkMicrophonePermission(),
|
|
]);
|
|
if (!hasAccessibility || !hasMicrophone) {
|
|
await revealMainWindowForPermissions();
|
|
setOnboardingStep("accessibility");
|
|
return;
|
|
}
|
|
} catch (e) {
|
|
console.warn("Failed to check macOS permissions:", e);
|
|
// If we can't check, proceed to main app and let them fix it there
|
|
}
|
|
}
|
|
|
|
if (currentPlatform === "windows") {
|
|
try {
|
|
const microphoneStatus =
|
|
await commands.getWindowsMicrophonePermissionStatus();
|
|
if (
|
|
microphoneStatus.supported &&
|
|
microphoneStatus.overall_access === "denied"
|
|
) {
|
|
await revealMainWindowForPermissions();
|
|
setOnboardingStep("accessibility");
|
|
return;
|
|
}
|
|
} catch (e) {
|
|
console.warn("Failed to check Windows microphone permissions:", e);
|
|
// If we can't check, proceed to main app and let them fix it there
|
|
}
|
|
}
|
|
|
|
setOnboardingStep("done");
|
|
} else {
|
|
// New user - start full onboarding
|
|
setIsReturningUser(false);
|
|
setOnboardingStep("accessibility");
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to check onboarding status:", error);
|
|
setOnboardingStep("accessibility");
|
|
}
|
|
};
|
|
|
|
const handleAccessibilityComplete = () => {
|
|
// Returning users already have models, skip to main app
|
|
// New users need to select a model
|
|
setOnboardingStep(isReturningUser ? "done" : "model");
|
|
};
|
|
|
|
const handleModelSelected = () => {
|
|
// Transition to main app - user has started a download
|
|
setOnboardingStep("done");
|
|
};
|
|
|
|
// Still checking onboarding status
|
|
if (onboardingStep === null) {
|
|
return null;
|
|
}
|
|
|
|
if (onboardingStep === "accessibility") {
|
|
return <AccessibilityOnboarding onComplete={handleAccessibilityComplete} />;
|
|
}
|
|
|
|
if (onboardingStep === "model") {
|
|
return <Onboarding onModelSelected={handleModelSelected} />;
|
|
}
|
|
|
|
return (
|
|
<div
|
|
dir={direction}
|
|
className="h-screen flex flex-col select-none cursor-default"
|
|
>
|
|
<Toaster
|
|
theme="system"
|
|
toastOptions={{
|
|
unstyled: true,
|
|
classNames: {
|
|
toast:
|
|
"bg-background border border-mid-gray/20 rounded-lg shadow-lg px-4 py-3 flex items-center gap-3 text-sm",
|
|
title: "font-medium",
|
|
description: "text-mid-gray",
|
|
},
|
|
}}
|
|
/>
|
|
{/* Main content area that takes remaining space */}
|
|
<div className="flex-1 flex overflow-hidden">
|
|
<Sidebar
|
|
activeSection={currentSection}
|
|
onSectionChange={setCurrentSection}
|
|
/>
|
|
{/* Scrollable content area */}
|
|
<div className="flex-1 flex flex-col overflow-hidden">
|
|
<div className="flex-1 overflow-y-auto">
|
|
<div className="flex flex-col items-center p-4 gap-4">
|
|
<AccessibilityPermissions />
|
|
{renderSettingsContent(currentSection)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/* Fixed footer at bottom */}
|
|
<Footer />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default App;
|