// Verhindert ein extra Konsolen-Fenster auf Windows im Release-Build. #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use serde::{Deserialize, Serialize}; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; use tauri::{ menu::{Menu, MenuItem, PredefinedMenuItem}, tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, Emitter, Manager, }; // Statisches Modul-Manifest — in die Binary einkompiliert, sodass die App // keine externe Datei zur Laufzeit braucht. Wer Module aendert: modules.json // im launcher-Root bearbeiten, dann neu bauen. const MODULES_JSON: &str = include_str!("../../modules.json"); #[derive(Serialize, Deserialize, Clone, Debug)] struct Project { name: String, path: String, modules: Vec, #[serde(rename = "lastOpened")] last_opened: Option, #[serde(rename = "createdAt", default, skip_serializing_if = "Option::is_none")] created_at: Option, #[serde(rename = "windowLayout", default, skip_serializing_if = "Option::is_none")] window_layout: Option, #[serde(default)] pinned: bool, #[serde(rename = "tagIds", default)] tag_ids: Vec, // Geschwister-Dateien im selben Projekt-Ordner. Hauptdatei bleibt `path`; // diese hier sind zusaetzliche .3dm/PDF/sonstwas die zum Projekt gehoeren. #[serde(rename = "extraFiles", default)] extra_files: Vec, } #[derive(Serialize, Deserialize, Clone, Debug)] struct Tag { id: String, name: String, color: String, // hex incl. "#" } // Layer-Template — eine Sublayer-Definition fuer das Default-Schema // (00_RASTER, 01_VERMESSUNG, …). Wird vom Plugin beim FIRST_RUN an die // Ebenen-React-UI gesendet, ueberschreibt deren hardcoded INITIAL_EBENEN. #[derive(Serialize, Deserialize, Clone, Debug)] struct LayerTemplate { code: String, name: String, color: String, lw: f64, } // Viewport-Color-Preset — vollstaendiger Color-Set unter einem Namen. // Built-ins ("Rhino-Standard", "Dossier-Standard") sind im Frontend hardcoded; // custom Presets landen hier in den Settings, koennen exportiert/importiert // werden. #[derive(Serialize, Deserialize, Clone, Debug)] struct ColorPreset { id: String, name: String, colors: ViewportColors, } #[derive(Serialize, Deserialize, Clone, Debug)] struct ProjectConfig { name: String, modules: Vec, #[serde(rename = "dossierVersion")] dossier_version: String, } #[derive(Serialize, Deserialize, Clone, Debug)] struct Settings { #[serde(rename = "rhinoApp")] rhino_app: String, #[serde(rename = "templatePath", default)] template_path: Option, #[serde(default)] tags: Vec, #[serde(rename = "viewColorPresets", default)] view_color_presets: Vec, // Auto-Load: Plugin nach Rhino-Start via AppleScript triggern. // Default false — User aktiviert bewusst in Settings. #[serde(rename = "autoLoadPlugin", default)] auto_load_plugin: bool, // Pfad zur startup.py die geladen werden soll. Default: das DOSSIER-Repo // wo der Launcher steckt (siehe default_plugin_startup_path()). #[serde(rename = "pluginStartupPath", default)] plugin_startup_path: Option, } fn default_plugin_startup_path() -> String { // Im installierten App-Bundle liegt startup.py unter // /Applications/Dossier.app/Contents/Resources/rhino/startup.py // (Tauri kopiert via bundle.resources in tauri.conf.json dorthin). // Damit laeuft DOSSIER fuer neue User out-of-the-box, ohne dass sie den // Pfad in den Settings setzen muessen. Dev-Fallback: Repo-Pfad. if let Ok(exe) = std::env::current_exe() { if let Some(contents_dir) = exe.parent().and_then(|p| p.parent()) { let bundled = contents_dir.join("Resources/rhino/startup.py"); if bundled.is_file() { return bundled.to_string_lossy().into_owned(); } } } "/Users/karim/STUDIO/DOSSIER/rhino/startup.py".to_string() } #[tauri::command] fn get_default_plugin_startup_path() -> String { default_plugin_startup_path() } impl Default for Settings { fn default() -> Self { Self { rhino_app: "Rhinoceros 8".into(), template_path: None, tags: Vec::new(), view_color_presets: Vec::new(), // Default ON — mit korrektem Shebang in startup.py ist Auto-Load // silent (kein ScriptEditor) und lohnt sich daher als Default. auto_load_plugin: true, plugin_startup_path: None, } } } // Dossier-Settings: WIRD VON RHINO GELESEN. Pfad ist bewusst projekt-stabil // (~/Library/Application Support/Dossier/dossier_settings.json), damit // oberleiste.py sie ohne Tauri-Abhaengigkeit findet. // Viewport-Colors als Hex-Strings ("#RRGGBB"). Wenn None: Rhino-Default // behalten (keine Aenderung). #[derive(Serialize, Deserialize, Clone, Debug, Default)] struct ViewportColors { #[serde(default, skip_serializing_if = "Option::is_none")] background: Option, #[serde(default, skip_serializing_if = "Option::is_none")] grid_line: Option, #[serde(default, skip_serializing_if = "Option::is_none")] grid_major: Option, #[serde(default, skip_serializing_if = "Option::is_none")] grid_x: Option, #[serde(default, skip_serializing_if = "Option::is_none")] grid_y: Option, #[serde(default, skip_serializing_if = "Option::is_none")] world_x: Option, #[serde(default, skip_serializing_if = "Option::is_none")] world_y: Option, #[serde(default, skip_serializing_if = "Option::is_none")] world_z: Option, } #[derive(Serialize, Deserialize, Clone, Debug, Default)] struct DossierSettings { #[serde(rename = "windowLayout", default)] window_layout: Option, #[serde(rename = "autoApplyLayout", default)] auto_apply_layout: bool, #[serde(rename = "pendingApplyLayout", default, skip_serializing_if = "Option::is_none")] pending_apply_layout: Option, // Viewport-Colors (App-weit, nicht per Document). Plugin liest + applied // via Rhino.ApplicationSettings.AppearanceSettings. #[serde(rename = "viewportColors", default)] viewport_colors: ViewportColors, #[serde(rename = "autoApplyViewColors", default)] auto_apply_view_colors: bool, #[serde(rename = "pendingApplyViewColors", default)] pending_apply_view_colors: bool, // Display-Mode-Import: Liste von .ini-Pfaden, die Plugin nacheinander // via Rhino.Display.DisplayModeDescription.ImportFromFile importiert // und dann clearet. #[serde(rename = "pendingImportDisplayModes", default)] pending_import_display_modes: Vec, // Default-Sublayer-Schema fuer neue Projekte. Plugin (rhinopanel.py) liest // das beim FIRST_RUN und sendet's als defaultEbenen an die Ebenen-React. // Leere Liste = React faellt auf hardcoded INITIAL_EBENEN zurueck. #[serde(rename = "layerSchema", default)] layer_schema: Vec, // Wenn true: oberleiste-Plugin liest doc.Strings["dossier_ebenen"] und // schreibt das in layerSchema (ueberschreibt!) + cleart das Flag. // Erlaubt dem User "Aktuelles Rhino-Setup als Default speichern". #[serde(rename = "pendingExportEbenen", default)] pending_export_ebenen: bool, } fn dossier_dir() -> PathBuf { // ~/Library/Application Support/Dossier auf macOS, // entsprechend Plattform-Pendants sonst. let dir = directories::ProjectDirs::from("ch", "gabrielevarano", "Dossier") .map(|p| p.data_dir().to_path_buf()) .unwrap_or_else(|| PathBuf::from(".")); fs::create_dir_all(&dir).ok(); dir } fn recent_path() -> PathBuf { dossier_dir().join("recent.json") } fn settings_path() -> PathBuf { dossier_dir().join("settings.json") } fn dossier_settings_path() -> PathBuf { dossier_dir().join("dossier_settings.json") } fn load_settings() -> Settings { let p = settings_path(); if !p.exists() { return Settings::default(); } fs::read_to_string(&p) .ok() .and_then(|raw| serde_json::from_str(&raw).ok()) .unwrap_or_default() } fn load_dossier_settings() -> DossierSettings { let p = dossier_settings_path(); if !p.exists() { return DossierSettings::default(); } fs::read_to_string(&p) .ok() .and_then(|raw| serde_json::from_str(&raw).ok()) .unwrap_or_default() } fn list_recent_internal() -> Vec { let p = recent_path(); if !p.exists() { return vec![]; } let raw = fs::read_to_string(&p).unwrap_or_default(); serde_json::from_str(&raw).unwrap_or_default() } #[tauri::command] fn list_recent() -> Vec { list_recent_internal() } #[tauri::command] fn save_recent(projects: Vec) -> Result<(), String> { let p = recent_path(); let raw = serde_json::to_string_pretty(&projects).map_err(|e| e.to_string())?; fs::write(&p, raw).map_err(|e| format!("recent.json schreiben: {e}")) } #[tauri::command] fn write_project_config( path3dm: String, name: String, modules: Vec, ) -> Result<(), String> { let p = Path::new(&path3dm); let dir = p .parent() .ok_or_else(|| format!("Kein gueltiger Ordner aus Pfad: {path3dm}"))?; let config_path = dir.join("dossier.project.json"); let config = ProjectConfig { name, modules, dossier_version: env!("CARGO_PKG_VERSION").to_string(), }; let raw = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?; fs::write(&config_path, raw).map_err(|e| format!("project-config schreiben: {e}")) } #[tauri::command] fn read_project_config(path3dm: String) -> Result, String> { let p = Path::new(&path3dm); let dir = p.parent().ok_or_else(|| "Pfad ungueltig".to_string())?; let config_path = dir.join("dossier.project.json"); if !config_path.exists() { return Ok(None); } let raw = fs::read_to_string(&config_path).map_err(|e| e.to_string())?; let cfg: ProjectConfig = serde_json::from_str(&raw).map_err(|e| e.to_string())?; Ok(Some(cfg)) } fn plugin_loaded_marker_path() -> PathBuf { // startup.py schreibt diese Datei am Ende von `_load_all` (nach Panel- // Registrierung). Der Launcher pollt darauf und schliesst den Splash. dossier_dir().join("plugin_loaded.flag") } fn open_rhino_internal(app: &tauri::AppHandle, path3dm: &str) -> Result<(), String> { let settings = load_settings(); // XML-Edit nur sinnvoll wenn Rhino nicht laeuft (sonst ueberschreibt's // beim Beenden) UND der Eintrag fuer den naechsten Start eh schon greift. if settings.auto_load_plugin && !is_rhino_running() { let startup_path = settings.plugin_startup_path .clone() .unwrap_or_else(default_plugin_startup_path); if Path::new(&startup_path).is_file() { if let Err(e) = ensure_rhino_startup_command(&startup_path) { eprintln!("auto-load plugin: {e}"); } } else { eprintln!("auto-load plugin: Startup-Pfad nicht gefunden: {startup_path}"); } } // Splash NUR zeigen wenn Auto-Load aktiv (sonst gibt's nichts zu warten). let show_splash = settings.auto_load_plugin; let marker = plugin_loaded_marker_path(); if show_splash { let _ = fs::remove_file(&marker); if let Some(splash) = app.get_webview_window("splash") { let _ = splash.show(); } } Command::new("open") .args(["-a", &settings.rhino_app, path3dm]) .spawn() .map_err(|e| format!( "open-Befehl fehlgeschlagen ({}): {e}", settings.rhino_app ))?; if show_splash { let app_clone = app.clone(); std::thread::spawn(move || { let start = std::time::Instant::now(); let timeout = std::time::Duration::from_secs(90); loop { if marker.is_file() { break; } if start.elapsed() > timeout { eprintln!("splash: timeout — Plugin hat sich nicht zurueckgemeldet"); break; } std::thread::sleep(std::time::Duration::from_millis(250)); } let _ = fs::remove_file(&marker); if let Some(splash) = app_clone.get_webview_window("splash") { let _ = splash.hide(); } }); } Ok(()) } fn rhino_settings_xml_path() -> PathBuf { let home = std::env::var("HOME").map(PathBuf::from).unwrap_or_default(); home.join("Library/Application Support/McNeel/Rhinoceros/8.0/settings/settings-Scheme__Default.xml") } // Traegt `_RunPythonScript ` in Rhinos „Run these commands every time a // model is opened" Liste ein (XML-Setting `Options/General/StartupCommands`). // Idempotent: ist der exakte Command schon drin, passiert nichts. Existieren // andere Commands, wird unserer per Newline angehaengt. // // Warum dieser Weg statt AppleScript-Keystrokes: Rhino's native Startup- // Command-Liste ist persistent, layout-unabhaengig und braucht keine // Accessibility-Permission. Einmal gesetzt, laeuft das Plugin bei JEDEM // Rhino-Start — egal ob via Launcher oder direkt. // // Sicherheit: schreibt nur wenn Rhino NICHT laeuft (sonst ueberschreibt Rhino // beim Beenden unsere Aenderung). XML wird per simpler String-Manipulation // editiert, NICHT geparst + reserialisiert, um Rhinos Format 1:1 zu erhalten. fn ensure_rhino_startup_command(startup_path: &str) -> Result<(), String> { let xml_path = rhino_settings_xml_path(); if !xml_path.is_file() { return Err(format!( "Rhino-Settings-Datei nicht gefunden — Rhino mind. einmal starten + beenden: {}", xml_path.display() )); } if is_rhino_running() { return Err( "Rhino laeuft — bitte erst beenden, sonst ueberschreibt Rhino die \ Aenderung beim Schliessen." .into(), ); } // Dash + Quotes ist die einzige Form die Rhinos StartupCommands-Feld OHNE // File-Dialog ausfuehrt (verifiziert 2026-05-17 Mac Rhino 8). Die no-dash- // Form aus der interaktiven Command-Line oeffnet hier den Dialog. Trotz // des Dashes laesst Mac Rhino 8 in diesem Kontext den Shebang `#! python 3` // gelten und startet den CPython-3-Engine korrekt. let cmd = format!(r#"_-RunPythonScript "{startup_path}""#); let content = fs::read_to_string(&xml_path) .map_err(|e| format!("Settings lesen: {e}"))?; let entry_open = r#""#; let entry_close = ""; let new_content = if let Some(start) = content.find(entry_open) { // Case A: Eintrag existiert bereits — anhaengen falls noch nicht drin. let value_start = start + entry_open.len(); let value_end_rel = content[value_start..] .find(entry_close) .ok_or_else(|| "StartupCommands-Entry ohne schliessendes ".to_string())?; let current = &content[value_start..value_start + value_end_rel]; if current.lines().any(|l| l.trim() == cmd) { return Ok(()); // schon drin, idempotent } let new_value = if current.trim().is_empty() { cmd.clone() } else { format!("{current}\n{cmd}") }; format!( "{}{new_value}{}", &content[..value_start], &content[value_start + value_end_rel..] ) } else if let Some(general_pos) = content.find(r#""#) { // Case B: General-Subtree existiert, StartupCommands noch nicht — Entry rein. let insert_at = general_pos + r#""#.len(); let insertion = format!( "\n {cmd}" ); format!( "{}{insertion}{}", &content[..insert_at], &content[insert_at..] ) } else if let Some(options_pos) = content.find(r#""#) { // Case C: Weder StartupCommands noch General-Subtree vorhanden — // ganzen Subtree neu anlegen. Rhino cleant den General-Subtree wenn er // leer ist, daher landen wir hier nach jedem "Eintrag im UI geleert". let insert_at = options_pos + r#""#.len(); let insertion = format!( "\n \n {cmd}\n " ); format!( "{}{insertion}{}", &content[..insert_at], &content[insert_at..] ) } else { return Err( "Konnte Eintrag nicht setzen — Rhino-Settings haben unerwartetes Format \ (Options-Subtree fehlt)." .into(), ); }; fs::write(&xml_path, new_content) .map_err(|e| format!("Settings schreiben: {e}"))?; Ok(()) } #[tauri::command] fn open_rhino(app: tauri::AppHandle, path3dm: String) -> Result<(), String> { open_rhino_internal(&app, &path3dm) } #[tauri::command] fn trigger_plugin_load_now() -> Result<(), String> { // Schreibt den `_RunPythonScript ` Eintrag in Rhinos Startup-Command- // Liste. Greift beim naechsten Rhino-Start automatisch. Idempotent. let settings = load_settings(); let startup_path = settings.plugin_startup_path .unwrap_or_else(default_plugin_startup_path); if !Path::new(&startup_path).is_file() { return Err(format!("Startup-Pfad nicht gefunden: {startup_path}")); } ensure_rhino_startup_command(&startup_path) } #[derive(Serialize, Clone, Debug, Default)] struct FileMeta { exists: bool, size: u64, #[serde(rename = "modifiedIso")] modified_iso: Option, } // Liefert das Projekt-Thumbnail als Base64-Data-URL falls vorhanden. // Erwarteter Pfad: `.thumb.png` (z.B. /foo/bar.3dm -> /foo/bar.3dm.thumb.png). // Base64 statt asset://-Protokoll weil's keine zusaetzliche Tauri-Config braucht // und Thumbnails klein genug sind (typisch 5-20 KB). #[tauri::command] fn read_thumbnail(path: String) -> Option { use std::io::Read; let thumb_path = format!("{}.thumb.png", path); let p = Path::new(&thumb_path); if !p.is_file() { return None; } let mut buf = Vec::new(); if fs::File::open(p).and_then(|mut f| f.read_to_end(&mut buf)).is_err() { return None; } // Manuelles Base64-Encoding — keine extra Crate noetig const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; let mut encoded = String::with_capacity((buf.len() + 2) / 3 * 4); for chunk in buf.chunks(3) { let b0 = chunk[0]; let b1 = if chunk.len() > 1 { chunk[1] } else { 0 }; let b2 = if chunk.len() > 2 { chunk[2] } else { 0 }; encoded.push(ALPHABET[(b0 >> 2) as usize] as char); encoded.push(ALPHABET[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char); if chunk.len() > 1 { encoded.push(ALPHABET[(((b1 & 0x0f) << 2) | (b2 >> 6)) as usize] as char); } else { encoded.push('='); } if chunk.len() > 2 { encoded.push(ALPHABET[(b2 & 0x3f) as usize] as char); } else { encoded.push('='); } } let mtime = fs::metadata(p).ok() .and_then(|m| m.modified().ok()) .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| d.as_secs() as i64) .and_then(|s| chrono::DateTime::::from_timestamp(s, 0)) .map(|d| d.to_rfc3339()); Some(serde_json::json!({ "dataUrl": format!("data:image/png;base64,{}", encoded), "modifiedIso": mtime, })) } #[tauri::command] fn get_file_meta(path: String) -> FileMeta { let p = Path::new(&path); let meta = match fs::metadata(p) { Ok(m) => m, Err(_) => return FileMeta::default(), }; let size = meta.len(); let modified_iso = meta.modified().ok().and_then(|t| { let secs = t.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs() as i64; chrono::DateTime::::from_timestamp(secs, 0).map(|d| d.to_rfc3339()) }); FileMeta { exists: true, size, modified_iso } } #[tauri::command] fn write_text_file(path: String, content: String) -> Result<(), String> { let p = Path::new(&path); if let Some(parent) = p.parent() { fs::create_dir_all(parent).map_err(|e| format!("Parent-Ordner: {e}"))?; } fs::write(p, content).map_err(|e| format!("Schreiben fehlgeschlagen: {e}")) } #[tauri::command] fn read_text_file(path: String) -> Result { fs::read_to_string(Path::new(&path)) .map_err(|e| format!("Lesen fehlgeschlagen: {e}")) } #[tauri::command] fn create_snapshot(path: String) -> Result { // Legt einen Zeit-stempel-Snapshot der .3dm in /_snapshots/ an. // Default-Lebenswerk-Schutz: schnell, unbeobachtet, ohne Rhino-Restart. let src = Path::new(&path); if !src.is_file() { return Err(format!("Quelle nicht gefunden: {path}")); } let parent = src.parent() .ok_or_else(|| "Pfad hat keinen Parent-Ordner".to_string())?; let snap_dir = parent.join("_snapshots"); fs::create_dir_all(&snap_dir).map_err(|e| format!("_snapshots/ anlegen: {e}"))?; let stem = src.file_stem().and_then(|s| s.to_str()).unwrap_or("snapshot"); let ext = src.extension().and_then(|s| s.to_str()).unwrap_or("3dm"); let ts = chrono::Local::now().format("%Y-%m-%d_%H%M%S"); let target = snap_dir.join(format!("{}_{}.{}", stem, ts, ext)); fs::copy(src, &target).map_err(|e| format!("Snapshot fehlgeschlagen: {e}"))?; Ok(target.to_string_lossy().to_string()) } #[tauri::command] fn show_in_finder(path: String) -> Result<(), String> { // -R offenbart die Datei im Finder (Parent-Ordner wird geoeffnet, Datei // selektiert). Funktioniert auch wenn der Pfad nicht existiert. Command::new("open") .args(["-R", &path]) .spawn() .map_err(|e| format!("Finder-Reveal fehlgeschlagen: {e}"))?; Ok(()) } #[tauri::command] fn is_project_open(path: String) -> bool { // Mac Rhino legt einen Lock neben der .3dm an: `.3dm.rhl`. // Existenz dieses Files = die .3dm ist gerade geoeffnet. let lock_path = format!("{}.rhl", path); Path::new(&lock_path).is_file() } #[tauri::command] fn is_rhino_running() -> bool { // pgrep -x matcht exakte Process-Names. Mac Rhinos Prozess heisst // "Rhinoceros" (Bundle-Identifier in App-Bundle). Wir akzeptieren beide // 8er und ggf. 9er. Command::new("pgrep") .args(["-x", "Rhinoceros"]) .output() .map(|out| out.status.success()) .unwrap_or(false) } #[tauri::command] fn copy_template_to(target_path: String) -> Result<(), String> { // Kopiert die in den Settings eingestellte Template-3dm nach target_path. // Wenn keine Template gesetzt ist oder die Quelle nicht existiert, wird // einfach eine leere .3dm angelegt — der User bekommt zumindest eine // Datei, die Rhino oeffnen kann. (Eine echte leere Rhino-3dm waere ein // Bytes-Blob; wir lassen das Erstellen Rhino ueberlassen, indem wir keine // Datei anlegen und Rhino mit -newdoc starten waere... nein, wir kopieren // nur wenn Template existiert. Wenn nicht, gibt's einen Hinweis-Fehler.) let settings = load_settings(); let tpl = settings.template_path .as_ref() .ok_or_else(|| "Keine Template-Datei in den Einstellungen gesetzt".to_string())?; let src = Path::new(tpl); if !src.is_file() { return Err(format!("Template-Datei nicht gefunden: {tpl}")); } let dst = Path::new(&target_path); if dst.exists() { return Err(format!("Zieldatei existiert bereits: {target_path}")); } if let Some(parent) = dst.parent() { fs::create_dir_all(parent).map_err(|e| format!("Zielordner anlegen: {e}"))?; } fs::copy(src, dst).map_err(|e| format!("Kopieren fehlgeschlagen: {e}"))?; Ok(()) } #[tauri::command] fn read_settings() -> Settings { load_settings() } #[tauri::command] fn save_settings(settings: Settings) -> Result<(), String> { let raw = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?; fs::write(settings_path(), raw).map_err(|e| format!("settings.json schreiben: {e}")) } #[tauri::command] fn read_modules_manifest() -> Result { serde_json::from_str(MODULES_JSON).map_err(|e| e.to_string()) } // --- Window-Layouts (Rhino) ------------------------------------------------ // Mac Rhino 8 speichert Layouts als XML in // ~/Library/Application Support/McNeel/Rhinoceros/8.0/settings/Scheme__Default/workspaces/.xml // Der Dateiname ist eine GUID, der Display-Name steht im name="..." Attribut // des Root--Elements. Windows Rhino nutzt .rwl-Dateien — wir behalten // den alten Pfad als Fallback fuer den Fall, dass das Format aendert. fn extract_layout_name_from_xml(content: &str) -> Option { // 1) `` (Mac-Pfad) if let Some(start) = content.find("') { let header = &after[..end_tag]; if let Some(npos) = header.find("name=\"") { let rest = &header[npos + 6..]; if let Some(qend) = rest.find('"') { let name = rest[..qend].trim(); if !name.is_empty() { return Some(name.to_string()); } } } } } // 2) `XYZ` als Fallback if let Some(start) = content.find("") { let rest = &content[start + "".len()..]; if let Some(end) = rest.find("") { let name = rest[..end].trim(); if !name.is_empty() { return Some(name.to_string()); } } } None } #[tauri::command] fn list_window_layouts() -> Vec { let home = directories::UserDirs::new() .map(|d| d.home_dir().to_path_buf()) .unwrap_or_else(|| PathBuf::from("~")); let mut out: Vec = Vec::new(); // Mac Rhino 8: workspaces/.xml let workspaces = home.join( "Library/Application Support/McNeel/Rhinoceros/8.0/settings/Scheme__Default/workspaces", ); if workspaces.is_dir() { if let Ok(entries) = fs::read_dir(&workspaces) { for entry in entries.flatten() { let p = entry.path(); if p.extension().and_then(|s| s.to_str()).map(|s| s.eq_ignore_ascii_case("xml")) .unwrap_or(false) { if let Ok(content) = fs::read_to_string(&p) { if let Some(name) = extract_layout_name_from_xml(&content) { if !out.contains(&name) { out.push(name); } } } } } } } // Legacy/Windows-Fallback: .rwl-Dateien let legacy_dirs = [ "Library/Application Support/McNeel/Rhinoceros/8.0/UI/MainWindowLayouts", "Library/Application Support/McNeel/Rhinoceros/8.0/MainWindowLayouts", "Library/Application Support/McNeel/Rhinoceros/8.0/UI/Layouts", ]; for rel in legacy_dirs.iter() { let dir = home.join(rel); if !dir.is_dir() { continue; } if let Ok(entries) = fs::read_dir(&dir) { for entry in entries.flatten() { let p = entry.path(); if p.extension().and_then(|s| s.to_str()).map(|s| s.eq_ignore_ascii_case("rwl")) .unwrap_or(false) { if let Some(stem) = p.file_stem().and_then(|s| s.to_str()) { let name = stem.to_string(); if !out.contains(&name) { out.push(name); } } } } } } out.sort(); out } #[tauri::command] fn read_dossier_settings() -> DossierSettings { load_dossier_settings() } #[tauri::command] fn save_dossier_settings(settings: DossierSettings) -> Result<(), String> { let raw = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?; fs::write(dossier_settings_path(), raw) .map_err(|e| format!("dossier_settings.json schreiben: {e}")) } #[tauri::command] fn request_ebenen_export() -> Result<(), String> { // Setzt das Flag — Plugin pickt's beim naechsten Idle auf, liest // doc.Strings["dossier_ebenen"] und schreibt's in layerSchema. let mut cfg = load_dossier_settings(); cfg.pending_export_ebenen = true; let raw = serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string())?; fs::write(dossier_settings_path(), raw) .map_err(|e| format!("dossier_settings.json schreiben: {e}")) } #[tauri::command] fn apply_view_colors_now() -> Result<(), String> { // Flag setzen — Plugin pickt's im Idle auf und appliert die aktuell // gespeicherten Colors via Rhino API. let mut cfg = load_dossier_settings(); cfg.pending_apply_view_colors = true; let raw = serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string())?; fs::write(dossier_settings_path(), raw) .map_err(|e| format!("dossier_settings.json schreiben: {e}")) } #[tauri::command] fn queue_import_display_mode(path: String) -> Result<(), String> { // Haengt einen .ini-Pfad an pendingImportDisplayModes an. Plugin importiert // beim naechsten Idle, leert dann die Liste. if !Path::new(&path).is_file() { return Err(format!("Datei nicht gefunden: {path}")); } let mut cfg = load_dossier_settings(); cfg.pending_import_display_modes.push(path); let raw = serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string())?; fs::write(dossier_settings_path(), raw) .map_err(|e| format!("dossier_settings.json schreiben: {e}")) } #[tauri::command] fn apply_layout_now(name: String) -> Result<(), String> { // Setzt das pendingApplyLayout-Flag, behaelt sonst alle Settings bei. // oberleiste.py's Idle-Handler pollt den Wert, fuehrt das Layout aus und // loescht das Flag. So funktioniert das "Jetzt anwenden" auch bei // bereits laufender Rhino-Instanz. let mut cfg = load_dossier_settings(); cfg.pending_apply_layout = Some(name); let raw = serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string())?; fs::write(dossier_settings_path(), raw) .map_err(|e| format!("dossier_settings.json schreiben: {e}")) } // --- Window / Tray --------------------------------------------------------- fn show_main_window(app: &tauri::AppHandle) { if let Some(window) = app.get_webview_window("main") { let _ = window.show(); let _ = window.unminimize(); let _ = window.set_focus(); } } // Tray-Menu mit den letzten 5 Projekten + Standard-Eintraegen. Wird beim // Startup gebaut und bei refresh_tray_menu (nach save_recent) neu erstellt. fn build_tray_menu(app: &tauri::AppHandle) -> tauri::Result> { let show = MenuItem::with_id(app, "show", "Dossier öffnen", true, None::<&str>)?; let sep1 = PredefinedMenuItem::separator(app)?; let new_proj = MenuItem::with_id(app, "nav:new", "Neues Projekt …", true, None::<&str>)?; let settings = MenuItem::with_id(app, "nav:settings", "Einstellungen", true, None::<&str>)?; let check_update = MenuItem::with_id(app, "nav:check-update", "Nach Updates suchen", true, None::<&str>)?; let sep2 = PredefinedMenuItem::separator(app)?; let quit = MenuItem::with_id(app, "quit", "Beenden", true, Some("Cmd+Q"))?; let recent = list_recent_internal(); let mut recent_items: Vec> = Vec::new(); for p in recent.iter().take(5) { // ID = "open:" — Path kann Leerzeichen/Slashes haben, das macht // Tauri Menu-IDs nicht aus. Wir extrahieren den Pfad spaeter zurueck. let id = format!("open:{}", p.path); let label = format!("Öffnen — {}", p.name); recent_items.push(MenuItem::with_id(app, &id, &label, true, None::<&str>)?); } let mut items: Vec<&dyn tauri::menu::IsMenuItem> = vec![&show, &sep1, &new_proj, &settings, &check_update]; let sep_recent; if !recent_items.is_empty() { sep_recent = PredefinedMenuItem::separator(app)?; items.push(&sep_recent); for item in &recent_items { items.push(item); } } items.push(&sep2); items.push(&quit); Menu::with_items(app, &items) } #[tauri::command] fn refresh_tray_menu(app: tauri::AppHandle) -> Result<(), String> { let menu = build_tray_menu(&app).map_err(|e| e.to_string())?; if let Some(tray) = app.tray_by_id("main") { tray.set_menu(Some(menu)).map_err(|e| e.to_string())?; } Ok(()) } #[cfg(target_os = "macos")] fn apply_macos_rounded_corners(window: &tauri::WebviewWindow, radius: f64) { use cocoa::appkit::NSWindow; use cocoa::base::{id, NO, YES}; use objc::{class, msg_send, sel, sel_impl}; let ns_window_ptr = match window.ns_window() { Ok(p) => p as id, Err(_) => return, }; unsafe { // NSWindow: opaque off + clearColor background — sodass das contentView // (mit dem clipping layer) wirklich sichtbar wird, statt von einem // weissen Window-Background ueberdeckt zu werden. let _: () = msg_send![ns_window_ptr, setOpaque: NO]; let clear: id = msg_send![class!(NSColor), clearColor]; ns_window_ptr.setBackgroundColor_(clear); // contentView: wantsLayer + Layer mit cornerRadius — clipt alle // Subviews (inkl. WkWebView) auf das abgerundete Rechteck. let content_view: id = msg_send![ns_window_ptr, contentView]; let _: () = msg_send![content_view, setWantsLayer: YES]; let layer: id = msg_send![content_view, layer]; let _: () = msg_send![layer, setCornerRadius: radius]; let _: () = msg_send![layer, setMasksToBounds: YES]; } } #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let is_quitting = Arc::new(AtomicBool::new(false)); tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_process::init()) .invoke_handler(tauri::generate_handler![ list_recent, save_recent, write_project_config, read_project_config, open_rhino, trigger_plugin_load_now, get_default_plugin_startup_path, show_in_finder, is_rhino_running, is_project_open, copy_template_to, get_file_meta, read_thumbnail, create_snapshot, write_text_file, read_text_file, read_modules_manifest, read_settings, save_settings, list_window_layouts, read_dossier_settings, save_dossier_settings, apply_layout_now, apply_view_colors_now, queue_import_display_mode, request_ebenen_export, refresh_tray_menu, ]) .on_window_event({ let is_quitting = is_quitting.clone(); move |window, event| { if let tauri::WindowEvent::CloseRequested { api, .. } = event { if !is_quitting.load(Ordering::SeqCst) { // Fenster schliessen versteckt — App bleibt im Tray // verfuegbar, bis explizit "Beenden" gewaehlt wird. api.prevent_close(); let _ = window.hide(); } } } }) .setup({ let is_quitting = is_quitting.clone(); move |app| { // Splash-Window: macOS NSWindow transparent + Layer-cornerRadius // setzen. Tauris `transparent: true` allein gibt weisse Ecken, // weil WkWebView per default opaque rendert. Wir machen NSWindow // explizit non-opaque + setzen contentView.layer.cornerRadius + // masksToBounds — das clipt den gesamten Inhalt rund. #[cfg(target_os = "macos")] if let Some(splash) = app.get_webview_window("splash") { apply_macos_rounded_corners(&splash, 16.0); } let menu = build_tray_menu(app.handle())?; let is_quitting_menu = is_quitting.clone(); let _tray = TrayIconBuilder::with_id("main") .icon(app.default_window_icon().unwrap().clone()) .icon_as_template(true) .menu(&menu) .show_menu_on_left_click(false) .on_menu_event(move |app, event| { let id = event.id.as_ref(); if id == "show" { show_main_window(app); } else if id == "quit" { is_quitting_menu.store(true, Ordering::SeqCst); app.exit(0); } else if let Some(view) = id.strip_prefix("nav:") { show_main_window(app); let _ = app.emit("dossier:navigate", view.to_string()); } else if let Some(path) = id.strip_prefix("open:") { // Direkt Rhino starten — kein Show des Launchers // damit der Tray-Shortcut wirklich schnell ist. let path = path.to_string(); if let Err(e) = open_rhino_internal(app, &path) { eprintln!("Tray open failed: {e}"); } // Recent-Liste lastOpened aktualisieren let mut recent = list_recent_internal(); let now = chrono::Utc::now().to_rfc3339(); for p in recent.iter_mut() { if p.path == path { p.last_opened = Some(now.clone()); } } // Sortieren: zuletzt geoeffnete oben recent.sort_by(|a, b| b.last_opened.cmp(&a.last_opened)); if let Ok(raw) = serde_json::to_string_pretty(&recent) { let _ = fs::write(recent_path(), raw); } // Tray neu bauen, sonst spiegelt das Menue nicht // die neue Reihenfolge wider. if let Ok(menu) = build_tray_menu(app) { if let Some(t) = app.tray_by_id("main") { let _ = t.set_menu(Some(menu)); } } } }) .on_tray_icon_event(|tray, event| { if let TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, .. } = event { show_main_window(tray.app_handle()); } }) .build(app)?; Ok(()) } }) .build(tauri::generate_context!()) .expect("Fehler beim Starten der Tauri-App") .run(move |_app, event| { if let tauri::RunEvent::ExitRequested { .. } = event { is_quitting.store(true, Ordering::SeqCst); } }); }