Skip to content

Commit 40282fb

Browse files
wilcorreaCopilot
andauthored
feat(cli): add path selection UI for CLI installer (#31)
- add get_suggested_paths() and install_to_dir() to cli_installer.rs - add get_cli_suggested_paths and install_cli_to_path Tauri commands - add CliInstallSettings component with suggested paths, custom input and feedback - add dedicated CLI tab in Settings window with programmatic navigation - listen to menu-install-cli in App.tsx to open Settings on CLI tab - rename menu label to 'Install CLI…' / 'Instalar CLI…' - add settings.cli.* i18n keys in en.json and pt-BR.json Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent bf22d84 commit 40282fb

6 files changed

Lines changed: 237 additions & 8 deletions

File tree

apps/tauri/src-tauri/src/cli_installer.rs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,62 @@ pub fn is_cli_installed() -> bool {
5555
let paths = [
5656
PathBuf::from("/usr/local/bin/arandu"),
5757
home.join(".local/bin/arandu"),
58+
home.join("bin/arandu"),
5859
];
5960
paths.iter().any(|p| p.is_file())
6061
}
6162

63+
pub fn get_suggested_paths() -> Vec<String> {
64+
let home = home_dir().unwrap_or_default();
65+
vec![
66+
home.join(".local/bin").to_string_lossy().into_owned(),
67+
home.join("bin").to_string_lossy().into_owned(),
68+
"/usr/local/bin".to_string(),
69+
]
70+
}
71+
72+
pub fn install_to_dir(dest_dir: &std::path::Path) -> InstallResult {
73+
let tmp = std::env::temp_dir().join("arandu-cli-install");
74+
if let Err(e) = fs::write(&tmp, CLI_SCRIPT) {
75+
return InstallResult {
76+
success: false,
77+
path: String::new(),
78+
error: format!("Could not write temporary file: {e}"),
79+
};
80+
}
81+
82+
let dest = dest_dir.join("arandu");
83+
84+
// Attempt 1: direct copy
85+
if let Ok(()) = try_direct_install(&tmp, &dest) {
86+
let _ = fs::remove_file(&tmp);
87+
return InstallResult {
88+
success: true,
89+
path: dest.to_string_lossy().into(),
90+
error: String::new(),
91+
};
92+
}
93+
94+
// Attempt 2: privilege escalation via osascript (only for system paths)
95+
let dest_str = dest_dir.to_string_lossy();
96+
let is_system_path = dest_str.starts_with("/usr/") || dest_str.starts_with("/opt/");
97+
if is_system_path && try_privileged_install(&tmp, &dest) {
98+
let _ = fs::remove_file(&tmp);
99+
return InstallResult {
100+
success: true,
101+
path: dest.to_string_lossy().into(),
102+
error: String::new(),
103+
};
104+
}
105+
106+
let _ = fs::remove_file(&tmp);
107+
InstallResult {
108+
success: false,
109+
path: String::new(),
110+
error: format!("Could not install to {}: permission denied", dest.display()),
111+
}
112+
}
113+
62114
pub fn has_been_dismissed(app_data_dir: &PathBuf) -> bool {
63115
app_data_dir.join(DISMISSED_FILE).exists()
64116
}

apps/tauri/src/App.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { Toaster } from "@/components/ui/sonner";
1414

1515
const { getCurrentWindow } = window.__TAURI__.window;
1616
const { invoke } = window.__TAURI__.core;
17-
const { listen } = window.__TAURI__.event;
17+
const { listen, emit } = window.__TAURI__.event;
1818

1919
const EXPAND_EASING = "cubic-bezier(0.3, 0.0, 0.0, 1)";
2020
const MINIMIZE_EASING = "cubic-bezier(0.3, 0.0, 0.8, 0.15)";
@@ -143,9 +143,15 @@ function AppContent() {
143143
openFile();
144144
});
145145

146+
const unlistenInstallCli = listen("menu-install-cli", () => {
147+
invoke("show_settings_window").catch(console.error);
148+
emit("open-settings-tab", "cli").catch(console.error);
149+
});
150+
146151
return () => {
147152
unlistenOpen.then((fn) => fn());
148153
unlistenMenu.then((fn) => fn());
154+
unlistenInstallCli.then((fn) => fn());
149155
};
150156
}, [openFile]);
151157

apps/tauri/src/SettingsApp.tsx

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
1-
import { useEffect } from "react";
1+
import { useEffect, useState } from "react";
22
import { ThemeProvider } from "next-themes";
33
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
44
import { TooltipProvider } from "@/components/ui/tooltip";
55
import { WhisperSettings } from "@/components/settings/WhisperSettings";
66
import { GeneralSettings } from "@/components/settings/GeneralSettings";
7+
import { CliInstallSettings } from "@/components/settings/CliInstallSettings";
78
import { Toaster } from "@/components/ui/sonner";
89
import { useTranslation } from "react-i18next";
9-
import { Mic, Settings } from "lucide-react";
10+
import { Mic, Settings, Terminal } from "lucide-react";
1011

1112
const { getCurrentWindow } = window.__TAURI__.window;
13+
const { listen } = window.__TAURI__.event;
1214

1315
export function SettingsApp() {
1416
const { t } = useTranslation();
17+
const [activeTab, setActiveTab] = useState("whisper");
1518

1619
useEffect(() => {
1720
const currentWindow = getCurrentWindow();
@@ -29,13 +32,22 @@ export function SettingsApp() {
2932
};
3033
}, [t]);
3134

35+
useEffect(() => {
36+
const unlisten = listen<string>("open-settings-tab", (event) => {
37+
setActiveTab(event.payload);
38+
});
39+
return () => {
40+
unlisten.then((fn) => fn());
41+
};
42+
}, []);
43+
3244
return (
3345
<ThemeProvider attribute="class" defaultTheme="system" enableSystem storageKey="arandu-theme">
3446
<TooltipProvider>
3547
<div className="h-screen overflow-hidden bg-background text-foreground flex flex-col">
3648
<div className="p-6 flex-1 overflow-y-auto">
3749
<h1 className="text-lg font-semibold mb-4">{t("settings.title")}</h1>
38-
<Tabs defaultValue="whisper">
50+
<Tabs value={activeTab} onValueChange={setActiveTab}>
3951
<TabsList className="mb-4">
4052
<TabsTrigger value="whisper" className="gap-1.5">
4153
<Mic className="h-3.5 w-3.5" />
@@ -45,13 +57,20 @@ export function SettingsApp() {
4557
<Settings className="h-3.5 w-3.5" />
4658
{t("settings.general")}
4759
</TabsTrigger>
60+
<TabsTrigger value="cli" className="gap-1.5">
61+
<Terminal className="h-3.5 w-3.5" />
62+
{t("settings.cli")}
63+
</TabsTrigger>
4864
</TabsList>
4965
<TabsContent value="whisper">
5066
<WhisperSettings />
5167
</TabsContent>
5268
<TabsContent value="general">
5369
<GeneralSettings />
5470
</TabsContent>
71+
<TabsContent value="cli">
72+
<CliInstallSettings />
73+
</TabsContent>
5574
</Tabs>
5675
</div>
5776
<Toaster position="bottom-center" />
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { useEffect, useState } from "react";
2+
import { useTranslation } from "react-i18next";
3+
import { Label } from "@/components/ui/label";
4+
import { Input } from "@/components/ui/input";
5+
import { Button } from "@/components/ui/button";
6+
import { Badge } from "@/components/ui/badge";
7+
import { CheckCircle, Circle } from "lucide-react";
8+
9+
const { invoke } = window.__TAURI__.core;
10+
11+
interface CliStatus {
12+
installed: boolean;
13+
dismissed: boolean;
14+
}
15+
16+
interface InstallResult {
17+
success: boolean;
18+
path: string;
19+
error: string;
20+
}
21+
22+
export function CliInstallSettings() {
23+
const { t } = useTranslation();
24+
const [status, setStatus] = useState<CliStatus | null>(null);
25+
const [suggestedPaths, setSuggestedPaths] = useState<string[]>([]);
26+
const [selectedPath, setSelectedPath] = useState("");
27+
const [installing, setInstalling] = useState(false);
28+
const [result, setResult] = useState<InstallResult | null>(null);
29+
30+
useEffect(() => {
31+
invoke<CliStatus>("check_cli_status")
32+
.then(setStatus)
33+
.catch(console.error);
34+
35+
invoke<string[]>("get_cli_suggested_paths")
36+
.then((paths) => {
37+
setSuggestedPaths(paths);
38+
if (paths.length > 0) setSelectedPath(paths[0]);
39+
})
40+
.catch(console.error);
41+
}, []);
42+
43+
async function handleInstall() {
44+
if (!selectedPath.trim()) return;
45+
setInstalling(true);
46+
setResult(null);
47+
try {
48+
const r = await invoke<InstallResult>("install_cli_to_path", { path: selectedPath.trim() });
49+
setResult(r);
50+
if (r.success) {
51+
setStatus((prev) => prev ? { ...prev, installed: true } : { installed: true, dismissed: false });
52+
}
53+
} catch (e) {
54+
setResult({ success: false, path: "", error: String(e) });
55+
} finally {
56+
setInstalling(false);
57+
}
58+
}
59+
60+
return (
61+
<div className="space-y-4">
62+
{/* Status */}
63+
<div className="flex items-center gap-2">
64+
{status?.installed ? (
65+
<CheckCircle className="h-4 w-4 text-green-500 shrink-0" />
66+
) : (
67+
<Circle className="h-4 w-4 text-muted-foreground shrink-0" />
68+
)}
69+
<span className="text-sm text-muted-foreground">
70+
{status?.installed
71+
? t("settings.cliStatusInstalled")
72+
: t("settings.cliStatusNotInstalled")}
73+
</span>
74+
</div>
75+
76+
{/* Suggested paths */}
77+
{suggestedPaths.length > 0 && (
78+
<div className="space-y-2">
79+
<Label className="text-sm font-medium">{t("settings.cliSuggestedPaths")}</Label>
80+
<div className="flex flex-wrap gap-2">
81+
{suggestedPaths.map((p) => (
82+
<Badge
83+
key={p}
84+
variant={selectedPath === p ? "default" : "outline"}
85+
className="cursor-pointer font-mono text-xs"
86+
onClick={() => { setSelectedPath(p); setResult(null); }}
87+
>
88+
{p}
89+
</Badge>
90+
))}
91+
</div>
92+
</div>
93+
)}
94+
95+
{/* Custom path input */}
96+
<div className="space-y-2">
97+
<Label className="text-sm font-medium">{t("settings.cliCustomPath")}</Label>
98+
<Input
99+
className="font-mono text-sm"
100+
placeholder={t("settings.cliCustomPathPlaceholder")}
101+
value={selectedPath}
102+
onChange={(e) => { setSelectedPath(e.target.value); setResult(null); }}
103+
/>
104+
</div>
105+
106+
{/* Install button */}
107+
<Button
108+
onClick={handleInstall}
109+
disabled={installing || !selectedPath.trim()}
110+
size="sm"
111+
>
112+
{installing ? t("settings.cliInstalling") : t("settings.cliInstall")}
113+
</Button>
114+
115+
{/* Feedback */}
116+
{result && (
117+
<p className={`text-xs ${result.success ? "text-green-600 dark:text-green-400" : "text-destructive"}`}>
118+
{result.success
119+
? t("settings.cliSuccess", { path: result.path })
120+
: t("settings.cliError", { error: result.error })}
121+
</p>
122+
)}
123+
124+
{/* Hint */}
125+
<p className="text-xs text-muted-foreground">{t("settings.cliHint")}</p>
126+
</div>
127+
);
128+
}

apps/tauri/src/locales/en.json

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@
127127
"title": "Settings",
128128
"general": "General",
129129
"whisper": "Voice-to-Text",
130+
"cli": "CLI",
130131
"theme": "Theme",
131132
"language": "Language",
132133
"openSettings": "Settings",
@@ -135,7 +136,18 @@
135136
"copilotPathHint": "Leave blank to use default ($COPILOT_PATH or 'copilot')",
136137
"ghToken": "GitHub Token",
137138
"ghTokenPlaceholder": "ghp_... or gho_...",
138-
"ghTokenHint": "Required in production builds. Get it with: gh auth token"
139+
"ghTokenHint": "Required in production builds. Get it with: gh auth token",
140+
"cliTitle": "CLI Installer",
141+
"cliStatusInstalled": "Installed",
142+
"cliStatusNotInstalled": "Not installed",
143+
"cliSuggestedPaths": "Suggested paths",
144+
"cliCustomPath": "Custom path",
145+
"cliCustomPathPlaceholder": "e.g. /usr/local/bin",
146+
"cliInstall": "Install",
147+
"cliInstalling": "Installing...",
148+
"cliSuccess": "Installed successfully at {{path}}",
149+
"cliError": "Installation failed: {{error}}",
150+
"cliHint": "User paths (~/…) install without password. System paths may require administrator access."
139151
},
140152
"tray": {
141153
"show": "Show Window",
@@ -146,7 +158,7 @@
146158
"menu": {
147159
"arandu": {
148160
"settings": "Settings\u2026",
149-
"installCli": "Install Command Line Tool\u2026"
161+
"installCli": "Install CLI\u2026"
150162
},
151163
"file": {
152164
"title": "File",

apps/tauri/src/locales/pt-BR.json

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@
127127
"title": "Configurações",
128128
"general": "Geral",
129129
"whisper": "Voz para Texto",
130+
"cli": "CLI",
130131
"theme": "Tema",
131132
"language": "Idioma",
132133
"openSettings": "Configurações",
@@ -135,7 +136,18 @@
135136
"copilotPathHint": "Deixe em branco para usar o padrão ($COPILOT_PATH ou 'copilot')",
136137
"ghToken": "GitHub Token",
137138
"ghTokenPlaceholder": "ghp_... ou gho_...",
138-
"ghTokenHint": "Necessário em builds de produção. Obtenha com: gh auth token"
139+
"ghTokenHint": "Necessário em builds de produção. Obtenha com: gh auth token",
140+
"cliTitle": "Instalador CLI",
141+
"cliStatusInstalled": "Instalado",
142+
"cliStatusNotInstalled": "Não instalado",
143+
"cliSuggestedPaths": "Caminhos sugeridos",
144+
"cliCustomPath": "Caminho personalizado",
145+
"cliCustomPathPlaceholder": "ex. /usr/local/bin",
146+
"cliInstall": "Instalar",
147+
"cliInstalling": "Instalando...",
148+
"cliSuccess": "Instalado com sucesso em {{path}}",
149+
"cliError": "Falha na instalação: {{error}}",
150+
"cliHint": "Caminhos de usuário (~/…) instalam sem senha. Caminhos de sistema podem exigir acesso de administrador."
139151
},
140152
"tray": {
141153
"show": "Mostrar Janela",
@@ -146,7 +158,7 @@
146158
"menu": {
147159
"arandu": {
148160
"settings": "Configurações\u2026",
149-
"installCli": "Instalar Ferramenta de Linha de Comando\u2026"
161+
"installCli": "Instalar CLI\u2026"
150162
},
151163
"file": {
152164
"title": "Arquivo",

0 commit comments

Comments
 (0)