From ea085634110adc6401f57cfaae20c9e8b9f1cf41 Mon Sep 17 00:00:00 2001 From: marcusH Date: Wed, 22 Jul 2026 10:47:36 +0200 Subject: [PATCH] Version 0.3.0, Selbstinstallation nach ~/Applications, Gatekeeper im README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Erster Start aus DMG oder Downloads bietet einmalig an, sich nach ~/Applications zu kopieren und von dort neu zu starten — der Weg dorthin braucht kein Administrator-Passwort, anders als /Applications. Ein DMG kann das nicht selbst leisten: der Applications-Alias darin ist ein absoluter Symlink auf /Applications. Die Kopie erbt per ditto die Quarantäne des Downloads, deshalb wird sie am Ziel gelöst — sonst käme beim Neustart erneut die Gatekeeper-Warnung für dieselbe, gerade laufende App. Ablehnen merkt sich die App (moveOfferDismissed), im Debug-Build fragt sie nie. README: ~/Applications als gleichwertiger Installationsort, dazu der Weg über System-Einstellungen -> Datenschutz & Sicherheit fuer alle, die kein Terminal aufmachen wollen. 0.8 ist fuer den Windows-Stand reserviert. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 7 +- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 5 +- src-tauri/src/domain/settings.rs | 14 ++++ src-tauri/src/lib.rs | 7 ++ src-tauri/src/platform/linux/mod.rs | 3 + src-tauri/src/platform/macos.rs | 123 +++++++++++++++++++++++++++- src-tauri/src/platform/windows.rs | 3 + src-tauri/tauri.conf.json | 2 +- 10 files changed, 162 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 3a71f68..9fbef20 100644 --- a/README.md +++ b/README.md @@ -62,12 +62,17 @@ The interface speaks English and German across all windows; the language follows ### macOS -Prebuilt DMGs are attached to the releases. The bundles are **not signed or notarized** — an Apple Developer ID costs €99/year, which this project doesn't have. If you'd like to change that: [Buy me a coffee](https://buymeacoffee.com/marcusH). macOS quarantines the unsigned download; after copying `ai-control.app` to `~/Applications`, clear it with: +Prebuilt DMGs are attached to the releases. Open the DMG and drag `ai-control.app` onto the `Applications` shortcut — or drop it into your personal `~/Applications` instead, which works just as well and asks for no administrator password. On first launch from anywhere else (the DMG, your Downloads folder), the app offers to move itself to `~/Applications` and restarts from there. + +The bundles are **not signed or notarized** — an Apple Developer ID costs €99/year, which this project doesn't have. If you'd like to change that: [Buy me a coffee](https://buymeacoffee.com/marcusH). macOS quarantines the unsigned download; clear it with whichever path you installed to: ```sh +xattr -d com.apple.quarantine /Applications/ai-control.app # or xattr -d com.apple.quarantine ~/Applications/ai-control.app ``` +Without the terminal: double-click the app, dismiss the warning, then open **System Settings → Privacy & Security**. Near the bottom of that page macOS now offers *Open Anyway* for the app it just blocked — click it and confirm. The warning is gone for good afterwards. + Or avoid the topic entirely and build from source (below) — self-built apps aren't quarantined. ### Linux diff --git a/package.json b/package.json index 665ef8c..f9a8036 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ai-control", "private": true, - "version": "0.2.0", + "version": "0.3.0", "license": "MIT", "type": "module", "scripts": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 9ebcf82..f56c56c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -42,7 +42,7 @@ dependencies = [ [[package]] name = "ai-control" -version = "0.2.0" +version = "0.3.0" dependencies = [ "base64 0.22.1", "glib", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 37b63f2..867f6ef 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ai-control" -version = "0.2.0" +version = "0.3.0" description = "Pool- und Session-Verwaltung für Claude Code" authors = ["marcus.hinz"] license = "MIT" @@ -46,6 +46,9 @@ rfd = { version = "0.15", default-features = false, features = ["gtk3"] } ksni = "0.1" [target.'cfg(target_os = "macos")'.dependencies] +# Der Installations-Dialog läuft vor dem Tauri-Setup, also ohne AppHandle und +# damit ohne das Dialog-Plugin. Unter macOS braucht rfd kein Backend-Feature. +rfd = { version = "0.15", default-features = false } objc2 = "0.6" objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSImage", "NSResponder", "NSRunningApplication"] } objc2-foundation = { version = "0.3", features = ["NSString"] } diff --git a/src-tauri/src/domain/settings.rs b/src-tauri/src/domain/settings.rs index 4a5face..49b648f 100644 --- a/src-tauri/src/domain/settings.rs +++ b/src-tauri/src/domain/settings.rs @@ -61,6 +61,20 @@ pub(crate) fn spellcheck_lang(paths: &Paths) -> String { .unwrap_or_else(|| "de".to_string()) } +/// Hat der Nutzer das Angebot, die App nach ~/Applications zu holen, schon +/// abgelehnt? (settings.json: moveOfferDismissed, nur macOS) +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +pub(crate) fn move_offer_dismissed(paths: &Paths) -> bool { + read_app_settings(paths) + .and_then(|v| v["moveOfferDismissed"].as_bool()) + .unwrap_or(false) +} + +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +pub(crate) fn set_move_offer_dismissed_in(paths: &Paths, dismissed: bool) -> Result<(), String> { + write_app_setting(paths, "moveOfferDismissed", serde_json::json!(dismissed)) +} + /// Einen Schlüssel setzen; übrige App-settings bleiben erhalten. /// /// Nur eine *fehlende* Datei rechtfertigt ein frisches Objekt. Unlesbar oder diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fde5d0a..aad9054 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -62,6 +62,13 @@ pub fn run() { std::process::exit(1); } + // Frisch aus dem DMG oder aus Downloads gestartet: einmal anbieten, sich + // nach ~/Applications zu holen. Nur die Haupt-App fragt — Terminal-Prozesse + // starten aus demselben Bundle, das dann längst am Ziel liegt. + if first.is_none() { + platform::offer_move_to_applications(&domain::paths::Paths::real()); + } + // generate_context! darf pro Crate nur einmal expandieren (_EMBED_INFO_PLIST). let context = tauri::generate_context!(); match first.as_deref() { diff --git a/src-tauri/src/platform/linux/mod.rs b/src-tauri/src/platform/linux/mod.rs index be2dc8e..461505c 100644 --- a/src-tauri/src/platform/linux/mod.rs +++ b/src-tauri/src/platform/linux/mod.rs @@ -48,6 +48,9 @@ pub(crate) fn reveal_path(path: &Path) { let _ = Command::new("xdg-open").arg(target).spawn(); } +/// Selbstinstallation gibt es nur unter macOS — hier installieren deb/rpm. +pub(crate) fn offer_move_to_applications(_paths: &crate::domain::paths::Paths) {} + // ---------- Desktop-Erkennung ---------- /// GNOME-Session? (XDG_CURRENT_DESKTOP enthält „GNOME"). Steuert diff --git a/src-tauri/src/platform/macos.rs b/src-tauri/src/platform/macos.rs index 0cd7270..34b4f84 100644 --- a/src-tauri/src/platform/macos.rs +++ b/src-tauri/src/platform/macos.rs @@ -2,7 +2,7 @@ //! Terminal-Prozess, Keychain über das security-CLI, nativer Tray. use std::io::Write; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use crate::domain::credentials::{ApikeyStore, APIKEY_SERVICE}; @@ -82,6 +82,127 @@ pub(crate) fn reveal_path(path: &Path) { let _ = Command::new("open").arg("-R").arg(path).spawn(); } +// ---------- Selbstinstallation ---------- + +/// App-Bundle zum Programmpfad: …/ai-control.app/Contents/MacOS/ai-control. +/// None, wenn das Binary nicht aus einem .app läuft (cargo run, CLI-Rollen). +fn bundle_dir(exe: &Path) -> Option<&Path> { + let dir = exe.parent()?.parent()?.parent()?; + (dir.extension()? == "app").then_some(dir) +} + +/// Wohin die App sich holen würde: nach ~/Applications, sofern sie noch in +/// keinem Programme-Ordner liegt und dort nichts gleichnamiges steht. Ein +/// Bundle im DMG (`/Volumes/…`) oder in Downloads fällt darunter. +fn move_target(exe: &Path, home: &Path) -> Option<(PathBuf, PathBuf)> { + let bundle = bundle_dir(exe)?; + let parent = bundle.parent()?; + let user_apps = home.join("Applications"); + if parent == user_apps || parent == Path::new("/Applications") { + return None; + } + let target = user_apps.join(bundle.file_name()?); + (!target.exists()).then(|| (bundle.to_path_buf(), target)) +} + +/// Setzt „nicht mehr fragen" in den App-Settings. +const MOVE_DISMISSED: &str = "moveOfferDismissed"; + +/// Einmalig anbieten, sich nach ~/Applications zu holen — der Weg dorthin +/// braucht kein Administrator-Passwort, anders als /Applications. Läuft die +/// App schon aus einem Programme-Ordner oder hat der Nutzer abgelehnt, +/// passiert nichts. Im Debug-Build nie (dort liegt das Bundle im target/). +pub(crate) fn offer_move_to_applications(paths: &Paths) { + if cfg!(debug_assertions) || crate::domain::settings::move_offer_dismissed(paths) { + return; + } + let Ok(exe) = std::env::current_exe() else { return }; + let Some((bundle, target)) = move_target(&exe, &paths.home) else { + return; + }; + + let deutsch = std::env::var("LANG").unwrap_or_default().starts_with("de"); + let (title, body, yes, no) = if deutsch { + ( + "aICentral installieren?", + "Die App läuft noch nicht aus einem Programme-Ordner. Soll sie sich nach „Applications\" im Benutzerordner holen und von dort neu starten?", + "Installieren", + "Später", + ) + } else { + ( + "Install aICentral?", + "The app is not running from an applications folder yet. Move it to \"Applications\" in your home folder and restart from there?", + "Install", + "Not now", + ) + }; + let antwort = rfd::MessageDialog::new() + .set_level(rfd::MessageLevel::Info) + .set_title(title) + .set_description(body) + .set_buttons(rfd::MessageButtons::OkCancelCustom(yes.into(), no.into())) + .show(); + if antwort != rfd::MessageDialogResult::Custom(yes.into()) { + let _ = crate::domain::settings::set_move_offer_dismissed_in(paths, true); + return; + } + + // ditto statt fs::copy: überträgt Rechte und erweiterte Attribute des + // Bundles am Stück. Scheitert es, bleibt alles wie es war. + if std::fs::create_dir_all(target.parent().unwrap()).is_err() + || !Command::new("/usr/bin/ditto") + .args([&bundle, &target]) + .status() + .is_ok_and(|s| s.success()) + { + return; + } + // Die Kopie erbt die Quarantäne des Downloads; ohne sie zu lösen käme beim + // Neustart erneut die Gatekeeper-Warnung für dieselbe, gerade laufende App. + let _ = Command::new("/usr/bin/xattr") + .args(["-dr", "com.apple.quarantine"]) + .arg(&target) + .status(); + let _ = Command::new(LSREGISTER).arg("-f").arg(&target).status(); + let _ = Command::new("open").arg("-n").arg(&target).status(); + std::process::exit(0); +} + +const LSREGISTER: &str = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister"; + +#[cfg(test)] +mod install_tests { + use super::*; + + fn exe_in(dir: &str) -> PathBuf { + Path::new(dir).join("ai-control.app/Contents/MacOS/ai-control") + } + + #[test] + fn angebot_aus_dmg_und_downloads() { + let home = Path::new("/Users/t"); + let (bundle, target) = move_target(&exe_in("/Volumes/ai-control"), home).unwrap(); + assert_eq!(bundle, Path::new("/Volumes/ai-control/ai-control.app")); + assert_eq!(target, Path::new("/Users/t/Applications/ai-control.app")); + assert!(move_target(&exe_in("/Users/t/Downloads"), home).is_some()); + } + + /// Aus einem Programme-Ordner heraus gibt es nichts anzubieten. + #[test] + fn kein_angebot_aus_programme_ordnern() { + let home = Path::new("/Users/t"); + assert!(move_target(&exe_in("/Users/t/Applications"), home).is_none()); + assert!(move_target(&exe_in("/Applications"), home).is_none()); + } + + /// Ohne .app-Bundle (cargo run, MCP-Rolle) kein Angebot. + #[test] + fn kein_angebot_ohne_bundle() { + assert!(move_target(Path::new("/w/target/release/ai-control"), Path::new("/Users/t")).is_none()); + } +} + // ---------- Desktop-Integration (nur Linux substantiell) ---------- pub(crate) fn write_terminal_desktop(_paths: &Paths, _project: &str, _cfg: &ProjectConfig) {} diff --git a/src-tauri/src/platform/windows.rs b/src-tauri/src/platform/windows.rs index 9d7bf78..33a1da8 100644 --- a/src-tauri/src/platform/windows.rs +++ b/src-tauri/src/platform/windows.rs @@ -44,6 +44,9 @@ pub(crate) fn focus_terminal(_pid: u32) {} pub(crate) fn set_app_id(_name: &str) {} +/// Selbstinstallation gibt es nur unter macOS — hier installiert der NSIS-Setup. +pub(crate) fn offer_move_to_applications(_paths: &crate::domain::paths::Paths) {} + pub(crate) fn reveal_path(path: &Path) { let _ = Command::new("explorer") .arg(format!("/select,{}", path.display())) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index d7e8225..8c87c8e 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "ai-control", - "version": "0.2.0", + "version": "0.3.0", "identifier": "com.aicontrol.app", "build": { "frontendDist": "../dist",