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:
@@ -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
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ai-control",
|
||||
"private": true,
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Generated
+1
-1
@@ -42,7 +42,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ai-control"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"glib",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
Reference in New Issue
Block a user