Version 0.3.0, Selbstinstallation nach ~/Applications, Gatekeeper im README

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) <noreply@anthropic.com>
This commit is contained in:
marcusH
2026-07-22 10:47:36 +02:00
parent 0faa1e48fd
commit ea08563411
10 changed files with 162 additions and 6 deletions
+6 -1
View File
@@ -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
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "ai-control",
"private": true,
"version": "0.2.0",
"version": "0.3.0",
"license": "MIT",
"type": "module",
"scripts": {
+1 -1
View File
@@ -42,7 +42,7 @@ dependencies = [
[[package]]
name = "ai-control"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"base64 0.22.1",
"glib",
+4 -1
View File
@@ -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"] }
+14
View File
@@ -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
+7
View File
@@ -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() {
+3
View File
@@ -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
+122 -1
View File
@@ -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) {}
+3
View File
@@ -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()))
+1 -1
View File
@@ -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",