Files
DOSSIER/launcher/src-tauri/src/lib.rs
T
karim 961b3c0396 Snapshot: Wand/Öffnung Multi-Surface-Select + Z-Drag + Brüstungs-Mitnahme
Stable working state after a long iteration session. The plugin now supports:
- Multi-Surface-Select für alle Element-Typen (Türen/Fenster/Treppen/Tragwerk)
- Wand-Z-Drag → unbound mode (UK/OK-Override, Wand vom Geschoss entkoppelt)
- Wand-Z-Drag nimmt verknüpfte Öffnungen mit (Brüstung += delta_z via Idle-Pfad)
- Öffnungs-XY-Drag snapt direktional auf Wand-Tangente
- Öffnungs-Z-Drag passt Brüstung an (Fenster sofort sync, Tür deferred)
- Wand-Delete kaskadiert Öffnungen (deferred via Idle, robust gegen _Rotate/_Move)
- Source-Cascade beim Öffnungs-Delete (deferred analog Wand-Kaskade)
- Listener-Cleanup robust gegen _reset_panels.py Reload (Refs in
  _dossier_runtime_event_refs gespeichert, vor Re-Install deregistriert)
- _count_same_id_type filtert IsDeleted (verhindert Source-Duplikat-Bug bei Move)
- Frontend: Brüstungs-Slider für Tür ("Schwelle"), Flügel-Block nur bei Fenster

Plus aus früherer Phase dieser Session:
- Dossier-Launcher Auto-Load via Rhinos StartupCommands-XML
- Default-Pfad zeigt auf gebundeltes startup.py (out-of-the-box für neue User)
- Splash-Window beim Plugin-Load mit native macOS rounded corners
- Diverse Launcher-Verbesserungen (Brüstungs-Default, tauri.conf, capabilities)

Known issue: bei Multi-Select-Move mit vielen Sub-Volumen kann sporadisch
"Unable to transform" auftreten (Rhinos Move-Operation kollidiert mit Wand-
Regen). Tür-spezifischer Defer-Pfad mildert das, Fenster läuft sync.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 01:50:45 +02:00

1030 lines
40 KiB
Rust

// 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<String>,
#[serde(rename = "lastOpened")]
last_opened: Option<String>,
#[serde(rename = "createdAt", default, skip_serializing_if = "Option::is_none")]
created_at: Option<String>,
#[serde(rename = "windowLayout", default, skip_serializing_if = "Option::is_none")]
window_layout: Option<String>,
#[serde(default)]
pinned: bool,
#[serde(rename = "tagIds", default)]
tag_ids: Vec<String>,
// 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<String>,
}
#[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<String>,
#[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<String>,
#[serde(default)]
tags: Vec<Tag>,
#[serde(rename = "viewColorPresets", default)]
view_color_presets: Vec<ColorPreset>,
// 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<String>,
}
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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] grid_line: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] grid_major: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] grid_x: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] grid_y: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] world_x: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] world_y: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] world_z: Option<String>,
}
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
struct DossierSettings {
#[serde(rename = "windowLayout", default)]
window_layout: Option<String>,
#[serde(rename = "autoApplyLayout", default)]
auto_apply_layout: bool,
#[serde(rename = "pendingApplyLayout", default, skip_serializing_if = "Option::is_none")]
pending_apply_layout: Option<String>,
// 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<String>,
// 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<LayerTemplate>,
// 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<Project> {
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<Project> {
list_recent_internal()
}
#[tauri::command]
fn save_recent(projects: Vec<Project>) -> 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<String>,
) -> 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<Option<ProjectConfig>, 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 <pfad>` 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#"<entry key="StartupCommands">"#;
let entry_close = "</entry>";
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 </entry>".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#"<child key="General">"#) {
// Case B: General-Subtree existiert, StartupCommands noch nicht — Entry rein.
let insert_at = general_pos + r#"<child key="General">"#.len();
let insertion = format!(
"\n <entry key=\"StartupCommands\">{cmd}</entry>"
);
format!(
"{}{insertion}{}",
&content[..insert_at],
&content[insert_at..]
)
} else if let Some(options_pos) = content.find(r#"<child key="Options">"#) {
// 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#"<child key="Options">"#.len();
let insertion = format!(
"\n <child key=\"General\">\n <entry key=\"StartupCommands\">{cmd}</entry>\n </child>"
);
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 <pfad>` 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<String>,
}
// Liefert das Projekt-Thumbnail als Base64-Data-URL falls vorhanden.
// Erwarteter Pfad: `<path>.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<serde_json::Value> {
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::<chrono::Utc>::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::<chrono::Utc>::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<String, String> {
fs::read_to_string(Path::new(&path))
.map_err(|e| format!("Lesen fehlgeschlagen: {e}"))
}
#[tauri::command]
fn create_snapshot(path: String) -> Result<String, String> {
// Legt einen Zeit-stempel-Snapshot der .3dm in <projectdir>/_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: `<basename>.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::Value, String> {
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/<GUID>.xml
// Der Dateiname ist eine GUID, der Display-Name steht im name="..." Attribut
// des Root-<RhinoUI>-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<String> {
// 1) `<RhinoUI ... name="XYZ">` (Mac-Pfad)
if let Some(start) = content.find("<RhinoUI") {
let after = &content[start..];
if let Some(end_tag) = after.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) `<locale_1033>XYZ</locale_1033>` als Fallback
if let Some(start) = content.find("<locale_1033>") {
let rest = &content[start + "<locale_1033>".len()..];
if let Some(end) = rest.find("</locale_1033>") {
let name = rest[..end].trim();
if !name.is_empty() {
return Some(name.to_string());
}
}
}
None
}
#[tauri::command]
fn list_window_layouts() -> Vec<String> {
let home = directories::UserDirs::new()
.map(|d| d.home_dir().to_path_buf())
.unwrap_or_else(|| PathBuf::from("~"));
let mut out: Vec<String> = Vec::new();
// Mac Rhino 8: workspaces/<GUID>.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<tauri::menu::Menu<tauri::Wry>> {
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<MenuItem<tauri::Wry>> = Vec::new();
for p in recent.iter().take(5) {
// ID = "open:<path>" — 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<tauri::Wry>> =
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);
}
});
}