Skip to content

Commit 3058ffb

Browse files
committed
Add permission step to onboarding
1 parent e199ca6 commit 3058ffb

File tree

6 files changed

+287
-10
lines changed

6 files changed

+287
-10
lines changed

src-tauri/src/commands/mod.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,3 +130,30 @@ pub fn check_apple_intelligence_available() -> bool {
130130
false
131131
}
132132
}
133+
134+
/// Try to initialize Enigo (keyboard/mouse simulation) after accessibility permissions are granted.
135+
/// This allows the app to gain paste functionality without requiring a restart.
136+
#[specta::specta]
137+
#[tauri::command]
138+
pub fn initialize_enigo(app: AppHandle) -> Result<(), String> {
139+
use crate::input::EnigoState;
140+
141+
// Check if already initialized
142+
if app.try_state::<EnigoState>().is_some() {
143+
log::info!("Enigo already initialized");
144+
return Ok(());
145+
}
146+
147+
// Try to initialize
148+
match EnigoState::new() {
149+
Ok(enigo_state) => {
150+
app.manage(enigo_state);
151+
log::info!("Enigo initialized successfully after permission grant");
152+
Ok(())
153+
}
154+
Err(e) => {
155+
log::warn!("Failed to initialize Enigo: {}", e);
156+
Err(format!("Failed to initialize input system: {}", e))
157+
}
158+
}
159+
}

src-tauri/src/lib.rs

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,8 +111,24 @@ fn show_main_window(app: &AppHandle) {
111111

112112
fn initialize_core_logic(app_handle: &AppHandle) {
113113
// Initialize the input state (Enigo singleton for keyboard/mouse simulation)
114-
let enigo_state = input::EnigoState::new().expect("Failed to initialize input state (Enigo)");
115-
app_handle.manage(enigo_state);
114+
// On macOS, we skip this at startup to avoid triggering the permission dialog.
115+
// The frontend will call initialize_enigo after the user grants permission.
116+
// On other platforms, we initialize immediately since no permission is needed.
117+
#[cfg(not(target_os = "macos"))]
118+
{
119+
match input::EnigoState::new() {
120+
Ok(enigo_state) => {
121+
app_handle.manage(enigo_state);
122+
}
123+
Err(e) => {
124+
log::warn!(
125+
"Failed to initialize input state (Enigo): {}. \
126+
Paste functionality may be unavailable.",
127+
e
128+
);
129+
}
130+
}
131+
}
116132

117133
// Initialize the managers
118134
let recording_manager = Arc::new(
@@ -275,6 +291,7 @@ pub fn run() {
275291
commands::open_log_dir,
276292
commands::open_app_data_dir,
277293
commands::check_apple_intelligence_available,
294+
commands::initialize_enigo,
278295
commands::models::get_available_models,
279296
commands::models::get_model_info,
280297
commands::models::download_model,

src/App.tsx

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,23 @@ import { Toaster } from "sonner";
33
import "./App.css";
44
import AccessibilityPermissions from "./components/AccessibilityPermissions";
55
import Footer from "./components/footer";
6-
import Onboarding from "./components/onboarding";
6+
import Onboarding, { AccessibilityOnboarding } from "./components/onboarding";
77
import { Sidebar, SidebarSection, SECTIONS_CONFIG } from "./components/Sidebar";
88
import { useSettings } from "./hooks/useSettings";
99
import { commands } from "@/bindings";
1010

11+
type OnboardingStep = "accessibility" | "model" | "done";
12+
1113
const renderSettingsContent = (section: SidebarSection) => {
1214
const ActiveComponent =
1315
SECTIONS_CONFIG[section]?.component || SECTIONS_CONFIG.general.component;
1416
return <ActiveComponent />;
1517
};
1618

1719
function App() {
18-
const [showOnboarding, setShowOnboarding] = useState<boolean | null>(null);
20+
const [onboardingStep, setOnboardingStep] = useState<OnboardingStep | null>(
21+
null
22+
);
1923
const [currentSection, setCurrentSection] =
2024
useState<SidebarSection>("general");
2125
const { settings, updateSetting } = useSettings();
@@ -51,25 +55,49 @@ function App() {
5155

5256
const checkOnboardingStatus = async () => {
5357
try {
54-
// Always check if they have any models available
58+
// Check if using cloud API (no model needed)
59+
const configResult = await commands.getTranscriptionConfig();
60+
if (configResult.status === "ok") {
61+
const isUsingCloudApi = configResult.data.type === "CloudProvider";
62+
if (isUsingCloudApi) {
63+
setOnboardingStep("done");
64+
return;
65+
}
66+
}
67+
68+
// Check if they have any models available
5569
const result = await commands.hasAnyModelsAvailable();
5670
if (result.status === "ok") {
57-
setShowOnboarding(!result.data);
71+
// If they have models, they're done. Otherwise start accessibility step.
72+
setOnboardingStep(result.data ? "done" : "accessibility");
5873
} else {
59-
setShowOnboarding(true);
74+
setOnboardingStep("accessibility");
6075
}
6176
} catch (error) {
6277
console.error("Failed to check onboarding status:", error);
63-
setShowOnboarding(true);
78+
setOnboardingStep("accessibility");
6479
}
6580
};
6681

82+
const handleAccessibilityComplete = () => {
83+
setOnboardingStep("model");
84+
};
85+
6786
const handleModelSelected = () => {
6887
// Transition to main app - user has started a download
69-
setShowOnboarding(false);
88+
setOnboardingStep("done");
7089
};
7190

72-
if (showOnboarding) {
91+
// Still checking onboarding status
92+
if (onboardingStep === null) {
93+
return null;
94+
}
95+
96+
if (onboardingStep === "accessibility") {
97+
return <AccessibilityOnboarding onComplete={handleAccessibilityComplete} />;
98+
}
99+
100+
if (onboardingStep === "model") {
73101
return <Onboarding onModelSelected={handleModelSelected} />;
74102
}
75103

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import { useEffect, useState, useCallback, useRef } from "react";
2+
import { useTranslation } from "react-i18next";
3+
import { platform } from "@tauri-apps/plugin-os";
4+
import {
5+
checkAccessibilityPermission,
6+
requestAccessibilityPermission,
7+
} from "tauri-plugin-macos-permissions-api";
8+
import { commands } from "@/bindings";
9+
import HandyTextLogo from "../icons/HandyTextLogo";
10+
import { Keyboard, Check, Loader2 } from "lucide-react";
11+
12+
interface AccessibilityOnboardingProps {
13+
onComplete: () => void;
14+
}
15+
16+
type PermissionState = "checking" | "prompt" | "waiting" | "granted";
17+
18+
const AccessibilityOnboarding: React.FC<AccessibilityOnboardingProps> = ({
19+
onComplete,
20+
}) => {
21+
const { t } = useTranslation();
22+
const [isMacOS, setIsMacOS] = useState<boolean | null>(null);
23+
const [permissionState, setPermissionState] =
24+
useState<PermissionState>("checking");
25+
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
26+
27+
// Check platform and permission status on mount
28+
useEffect(() => {
29+
const currentPlatform = platform();
30+
const isMac = currentPlatform === "macos";
31+
setIsMacOS(isMac);
32+
33+
// Skip immediately on non-macOS - no permission needed
34+
if (!isMac) {
35+
onComplete();
36+
return;
37+
}
38+
39+
// On macOS, check if permission is already granted
40+
const checkInitial = async () => {
41+
try {
42+
const granted = await checkAccessibilityPermission();
43+
if (granted) {
44+
// Already have permission, initialize Enigo and skip ahead
45+
try {
46+
await commands.initializeEnigo();
47+
} catch (e) {
48+
console.warn("Failed to initialize Enigo:", e);
49+
}
50+
setPermissionState("granted");
51+
setTimeout(() => onComplete(), 300);
52+
} else {
53+
// Need to ask for permission - show the prompt
54+
setPermissionState("prompt");
55+
}
56+
} catch (error) {
57+
console.error("Failed to check accessibility permission:", error);
58+
// On error, show the prompt anyway
59+
setPermissionState("prompt");
60+
}
61+
};
62+
63+
checkInitial();
64+
}, [onComplete]);
65+
66+
// Polling for permission after user clicks the button
67+
const startPolling = useCallback(() => {
68+
if (pollingRef.current) return; // Already polling
69+
70+
pollingRef.current = setInterval(async () => {
71+
try {
72+
const granted = await checkAccessibilityPermission();
73+
if (granted) {
74+
// Permission granted!
75+
if (pollingRef.current) {
76+
clearInterval(pollingRef.current);
77+
pollingRef.current = null;
78+
}
79+
80+
// Try to initialize Enigo now that we have permission
81+
try {
82+
await commands.initializeEnigo();
83+
} catch (e) {
84+
console.warn("Failed to initialize Enigo:", e);
85+
}
86+
87+
setPermissionState("granted");
88+
setTimeout(() => onComplete(), 500);
89+
}
90+
} catch (error) {
91+
console.error("Error checking permission:", error);
92+
}
93+
}, 1000);
94+
}, [onComplete]);
95+
96+
// Cleanup polling on unmount
97+
useEffect(() => {
98+
return () => {
99+
if (pollingRef.current) {
100+
clearInterval(pollingRef.current);
101+
}
102+
};
103+
}, []);
104+
105+
const handleGivePermission = async () => {
106+
try {
107+
// This opens System Settings to the Accessibility pane
108+
await requestAccessibilityPermission();
109+
} catch (error) {
110+
console.error("Failed to request accessibility permission:", error);
111+
}
112+
// Start polling for permission regardless of whether request succeeded
113+
setPermissionState("waiting");
114+
startPolling();
115+
};
116+
117+
// Still checking platform/initial permission
118+
if (isMacOS === null || permissionState === "checking") {
119+
return (
120+
<div className="h-screen w-screen flex items-center justify-center">
121+
<Loader2 className="w-8 h-8 animate-spin text-text/50" />
122+
</div>
123+
);
124+
}
125+
126+
// Permission granted - show success briefly
127+
if (permissionState === "granted") {
128+
return (
129+
<div className="h-screen w-screen flex flex-col items-center justify-center gap-4">
130+
<div className="p-4 rounded-full bg-emerald-500/20">
131+
<Check className="w-12 h-12 text-emerald-400" />
132+
</div>
133+
<p className="text-lg font-medium text-text">
134+
{t("onboarding.accessibility.granted")}
135+
</p>
136+
</div>
137+
);
138+
}
139+
140+
// Show permission request screen (prompt or waiting state)
141+
return (
142+
<div className="h-screen w-screen flex flex-col p-6 gap-6 items-center justify-center">
143+
<div className="flex flex-col items-center gap-2">
144+
<HandyTextLogo width={200} />
145+
</div>
146+
147+
<div className="max-w-md w-full flex flex-col items-center gap-6">
148+
<div className="p-4 rounded-full bg-logo-primary/20">
149+
<Keyboard className="w-12 h-12 text-logo-primary" />
150+
</div>
151+
152+
<div className="text-center">
153+
<h2 className="text-xl font-semibold text-text mb-2">
154+
{t("onboarding.accessibility.title")}
155+
</h2>
156+
<p className="text-text/70">
157+
{t("onboarding.accessibility.description")}
158+
</p>
159+
</div>
160+
161+
<div className="w-full p-4 rounded-lg bg-white/5 border border-mid-gray/20">
162+
<p className="text-sm text-text/60 text-center">
163+
{t("onboarding.accessibility.explanation")}
164+
</p>
165+
</div>
166+
167+
{permissionState === "prompt" && (
168+
<button
169+
onClick={handleGivePermission}
170+
className="w-full py-3 px-6 rounded-lg bg-logo-primary hover:bg-logo-primary/90 text-white font-medium transition-colors"
171+
>
172+
{t("onboarding.accessibility.givePermission")}
173+
</button>
174+
)}
175+
176+
{permissionState === "waiting" && (
177+
<>
178+
<button
179+
disabled
180+
className="w-full py-3 px-6 rounded-lg bg-mid-gray/30 text-text/50 font-medium cursor-not-allowed flex items-center justify-center gap-2"
181+
>
182+
<Loader2 className="w-4 h-4 animate-spin" />
183+
{t("onboarding.accessibility.waiting")}
184+
</button>
185+
<p className="text-xs text-text/40 text-center">
186+
{t("onboarding.accessibility.instructions")}
187+
</p>
188+
</>
189+
)}
190+
</div>
191+
</div>
192+
);
193+
};
194+
195+
export default AccessibilityOnboarding;

src/components/onboarding/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export { default } from "./Onboarding";
2+
export { default as AccessibilityOnboarding } from "./AccessibilityOnboarding";

src/i18n/locales/en/translation.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,15 @@
5151
"errors": {
5252
"loadModels": "Failed to load available models",
5353
"downloadModel": "Failed to download model: {{error}}"
54+
},
55+
"accessibility": {
56+
"title": "Accessibility Permission Required",
57+
"description": "Handy needs accessibility permission to type transcribed text into your applications.",
58+
"explanation": "When you speak, Handy will automatically type the transcribed text wherever your cursor is. This requires accessibility access to simulate keyboard input.",
59+
"givePermission": "Grant Permission",
60+
"waiting": "Waiting for permission...",
61+
"instructions": "Toggle the switch next to Handy in System Settings, then return here.",
62+
"granted": "Permission granted!"
5463
}
5564
},
5665
"modelSelector": {

0 commit comments

Comments
 (0)