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>
This commit is contained in:
+1
-1
@@ -25,7 +25,7 @@ Damit die Module bei jedem Rhino-Start automatisch laden:
|
||||
2. `Rhinoceros 8` → `Preferences` → `General` → **Startup commands**
|
||||
3. Folgende Zeile eintragen:
|
||||
```
|
||||
_-RunPythonScript "/Users/karim/STUDIO/rhino-panel/rhino/startup.py"
|
||||
_-RunPythonScript "/Users/karim/STUDIO/DOSSIER/rhino/startup.py"
|
||||
```
|
||||
4. OK → Rhino neu starten
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Dossier</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Archivo+Black&family=DM+Mono:ital,wght@0,300;0,400;0,500;1,300&family=Playfair+Display:ital,wght@0,400;0,700;1,400&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"version": "0.1.0",
|
||||
"notes": "Dossier 0.1.0",
|
||||
"pub_date": "2026-05-17T01:00:34Z",
|
||||
"version": "0.6.3",
|
||||
"notes": "Silent Plugin Auto-Load via _-RunPythonScript + korrigierter Shebang #! python 3 — kein ScriptEditor mehr",
|
||||
"pub_date": "2026-05-17T14:26:39Z",
|
||||
"platforms": {
|
||||
"darwin-aarch64": {
|
||||
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRNFYzbUN3TE44QnZxVWhTaXhKKzZrUnBPTzA5NSt4TGduMlFZam9qN2RnRXlvYU9iOVFBa2NTbVRGNjF1TUNNeE5CeEgrTTh3S0pUVnlGTytOSngyTWRhYjEyZXBMNVFVPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzc4OTc5NjM0CWZpbGU6RG9zc2llci5hcHAudGFyLmd6CmZYUGlwaXZ6Zkhpdittcm5PN04rMVVhSENlaW1GMFZ0WHh3VUlEc01LT0kwaUJNTzBIeS9OTUNjTTZ5ZVBlYyt5VFlqZThRVjZ6OXRnaXBydDlxOUJRPT0K",
|
||||
"url": "https://git.kgva.ch/karim/DOSSIER/releases/download/0.1.0/Dossier.app.tar.gz"
|
||||
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRNFYzbUN3TE44QnNuNmhvcmJZcTdFbklSYllMcmQvYTI0NDhVRFlPWGN0YXFmSWxXSW9XSE1zbmJTWEpRdTZSc0xlcFg1VVZ5bGUvaEhSTGlhL2NjNi96NzVQZ0R0bWc4PQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzc5MDI3OTk5CWZpbGU6RG9zc2llci5hcHAudGFyLmd6CjkzQXZKMDlBbndqeTlHa2hBY2NwUGJNckJXZXNCbWhNRkZ2bW9VeDlSZ0JiK1lVRzBVTHdqK0V5T0NXNFlBMWdJRXRHRStKWnVtNG1WcWx2T1pkMUNnPT0K",
|
||||
"url": "https://git.kgva.ch/karim/DOSSIER/releases/download/0.6.3/Dossier.app.tar.gz"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+22
-2
@@ -1,15 +1,17 @@
|
||||
{
|
||||
"name": "dossier-launcher",
|
||||
"version": "0.1.0",
|
||||
"version": "0.5.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dossier-launcher",
|
||||
"version": "0.1.0",
|
||||
"version": "0.5.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.0.0",
|
||||
"@tauri-apps/plugin-process": "^2.0.0",
|
||||
"@tauri-apps/plugin-updater": "^2.0.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6"
|
||||
},
|
||||
@@ -1058,6 +1060,24 @@
|
||||
"@tauri-apps/api": "^2.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-process": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-process/-/plugin-process-2.3.1.tgz",
|
||||
"integrity": "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-updater": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.10.1.tgz",
|
||||
"integrity": "sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.10.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "dossier-launcher",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.6.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -12,6 +12,8 @@
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.0.0",
|
||||
"@tauri-apps/plugin-process": "^2.0.0",
|
||||
"@tauri-apps/plugin-updater": "^2.0.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Dossier lädt</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Archivo+Black&family=DM+Mono:ital,wght@0,300;0,400;0,500&display=swap" rel="stylesheet" />
|
||||
<style>
|
||||
:root {
|
||||
--accent: #5fa896;
|
||||
--accent-soft: #6fb5a3;
|
||||
--accent-deep: #2f5d54;
|
||||
--paper: #ffffff;
|
||||
--paper-mute: rgba(255, 255, 255, 0.72);
|
||||
--paper-faint: rgba(255, 255, 255, 0.45);
|
||||
--font-display: Krungthep, 'Archivo Black', sans-serif;
|
||||
--font-mono: 'DM Mono', 'Menlo', monospace;
|
||||
}
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: transparent !important;
|
||||
color: var(--paper);
|
||||
overflow: hidden;
|
||||
font-family: var(--font-mono);
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
cursor: default;
|
||||
}
|
||||
.frame {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 24px 28px 22px;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
gap: 0;
|
||||
background:
|
||||
radial-gradient(120% 140% at 0% 0%, var(--accent-soft) 0%, var(--accent) 55%, var(--accent-deep) 130%);
|
||||
border-radius: 16px;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.18);
|
||||
}
|
||||
.brand-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.brand {
|
||||
font-family: var(--font-display);
|
||||
font-size: 30px;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1;
|
||||
color: var(--paper);
|
||||
}
|
||||
.brand-dot {
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
.version {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.10em;
|
||||
color: var(--paper-mute);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.status-row {
|
||||
align-self: end;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.10em;
|
||||
color: var(--paper);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.dot-pulse {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--paper);
|
||||
animation: pulse 1.6s ease-out infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(255,255,255,0.55); transform: scale(1); }
|
||||
70% { box-shadow: 0 0 0 9px rgba(255,255,255,0); transform: scale(1.05); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(255,255,255,0); transform: scale(1); }
|
||||
}
|
||||
.bar {
|
||||
position: relative;
|
||||
height: 2px;
|
||||
width: 100%;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.bar::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -35%;
|
||||
width: 35%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, var(--paper), transparent);
|
||||
animation: slide 1.6s linear infinite;
|
||||
}
|
||||
@keyframes slide {
|
||||
to { left: 100%; }
|
||||
}
|
||||
.meta-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
font-size: 9.5px;
|
||||
letter-spacing: 0.14em;
|
||||
color: var(--paper-faint);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="frame">
|
||||
<div class="brand-row">
|
||||
<div class="brand">DOSSIER<span class="brand-dot">.</span></div>
|
||||
<div class="version">v0.6.3</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="status-row">
|
||||
<span class="dot-pulse"></span>
|
||||
<span>Plugin lädt — Panels werden platziert</span>
|
||||
</div>
|
||||
<div class="bar"></div>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<span>AGPL-3.0 · Karim Gabriele Varano</span>
|
||||
<span>Rhino 8 · CPy 3.9</span>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Executable
+104
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build + sign Dossier-Launcher fuer den Updater, und emit latest.json.
|
||||
#
|
||||
# Voraussetzungen:
|
||||
# - Private Key liegt unter ~/.tauri/dossier_updater.key
|
||||
# (einmalig erzeugen mit:
|
||||
# npx tauri signer generate -w ~/.tauri/dossier_updater.key)
|
||||
# - Version wurde in src-tauri/tauri.conf.json + package.json hochgezaehlt
|
||||
#
|
||||
# Ablauf:
|
||||
# 1) npx tauri build (mit Signing-Env)
|
||||
# 2) liest die erzeugte .sig-Datei
|
||||
# 3) schreibt latest.json im launcher-Root mit URLs auf Gitea-Release-Assets
|
||||
#
|
||||
# Danach manuell:
|
||||
# - auf Gitea einen Release mit Tag <VERSION> erstellen
|
||||
# - die .app.tar.gz und (optional) die .dmg als Assets hochladen
|
||||
# - latest.json committen + auf main pushen
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
KEY_PATH="${TAURI_SIGNING_PRIVATE_KEY_PATH:-$HOME/.tauri/dossier_updater.key}"
|
||||
GITEA_REPO="https://git.kgva.ch/karim/DOSSIER"
|
||||
|
||||
# WICHTIG: `npx tauri ...` ohne Namespace zieht ausserhalb dieses Verzeichnisses
|
||||
# das uralte tauri@0.15 von npm (das kein `signer` kennt). Wir nutzen den
|
||||
# expliziten Paketnamen @tauri-apps/cli — der funktioniert von ueberall und
|
||||
# auch innerhalb dieses Verzeichnisses, wo die devDependency ohnehin vorhanden ist.
|
||||
TAURI_CLI="npx --yes @tauri-apps/cli"
|
||||
|
||||
if [ ! -f "$KEY_PATH" ]; then
|
||||
echo "Private Key fehlt: $KEY_PATH" >&2
|
||||
echo "Einmalig erzeugen mit:" >&2
|
||||
echo " $TAURI_CLI signer generate -w $KEY_PATH" >&2
|
||||
echo "und den public key (.pub) in src-tauri/tauri.conf.json -> plugins.updater.pubkey eintragen." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION=$(node -p "require('./src-tauri/tauri.conf.json').version")
|
||||
PKG_VERSION=$(node -p "require('./package.json').version")
|
||||
if [ "$VERSION" != "$PKG_VERSION" ]; then
|
||||
echo "Version mismatch: tauri.conf.json=$VERSION package.json=$PKG_VERSION" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ARCH=$(uname -m)
|
||||
case "$ARCH" in
|
||||
arm64|aarch64) PLATFORM_KEY="darwin-aarch64" ;;
|
||||
x86_64) PLATFORM_KEY="darwin-x86_64" ;;
|
||||
*) echo "Unsupported arch: $ARCH" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
echo "→ Build Dossier $VERSION ($PLATFORM_KEY)"
|
||||
TAURI_SIGNING_PRIVATE_KEY="$(cat "$KEY_PATH")" \
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD="" \
|
||||
$TAURI_CLI build
|
||||
|
||||
BUNDLE_DIR="src-tauri/target/release/bundle/macos"
|
||||
TAR_GZ=$(ls "$BUNDLE_DIR"/*.app.tar.gz 2>/dev/null | head -n1 || true)
|
||||
SIG_FILE="${TAR_GZ}.sig"
|
||||
|
||||
if [ -z "$TAR_GZ" ] || [ ! -f "$SIG_FILE" ]; then
|
||||
echo "Bundle oder Signatur fehlt in $BUNDLE_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ASSET_NAME=$(basename "$TAR_GZ")
|
||||
ASSET_URL_NAME=$(printf '%s' "$ASSET_NAME" | sed 's/ /%20/g')
|
||||
SIGNATURE=$(cat "$SIG_FILE")
|
||||
PUB_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
DOWNLOAD_URL="$GITEA_REPO/releases/download/$VERSION/$ASSET_URL_NAME"
|
||||
|
||||
NOTES=${RELEASE_NOTES:-"Dossier $VERSION"}
|
||||
|
||||
cat > latest.json <<EOF
|
||||
{
|
||||
"version": "$VERSION",
|
||||
"notes": $(node -e "process.stdout.write(JSON.stringify(process.argv[1]))" "$NOTES"),
|
||||
"pub_date": "$PUB_DATE",
|
||||
"platforms": {
|
||||
"$PLATFORM_KEY": {
|
||||
"signature": $(node -e "process.stdout.write(JSON.stringify(process.argv[1]))" "$SIGNATURE"),
|
||||
"url": "$DOWNLOAD_URL"
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
echo
|
||||
echo "✓ Build fertig"
|
||||
echo " Bundle: $TAR_GZ"
|
||||
echo " Signatur: $SIG_FILE"
|
||||
echo " DMG: $(ls src-tauri/target/release/bundle/dmg/*.dmg 2>/dev/null | head -n1 || echo '(keine DMG gefunden)')"
|
||||
echo " Platform: $PLATFORM_KEY"
|
||||
echo " latest.json wurde im launcher-Root geschrieben."
|
||||
echo
|
||||
echo "Nächste Schritte:"
|
||||
echo " 1) Auf Gitea Release mit Tag $VERSION erstellen und folgende Assets hochladen:"
|
||||
echo " - $ASSET_NAME"
|
||||
echo " - (optional) DMG für Erstinstallation"
|
||||
echo " 2) latest.json committen + auf main pushen:"
|
||||
echo " git add launcher/latest.json && git commit -m 'Release $VERSION' && git push origin main"
|
||||
Generated
+494
-7
@@ -47,6 +47,15 @@ version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||
dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atk"
|
||||
version = "0.18.2"
|
||||
@@ -124,6 +133,12 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
@@ -323,6 +338,35 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cocoa"
|
||||
version = "0.26.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad36507aeb7e16159dfe68db81ccc27571c3ccd4b76fb2fb72fc59e7a4b1b64c"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"block",
|
||||
"cocoa-foundation",
|
||||
"core-foundation",
|
||||
"core-graphics 0.24.0",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
"objc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cocoa-foundation"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81411967c50ee9a1fc11365f8c585f863a22a9697c89239c452292c40ba79b0d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"block",
|
||||
"core-foundation",
|
||||
"core-graphics-types",
|
||||
"objc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
@@ -359,6 +403,19 @@ version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "core-graphics"
|
||||
version = "0.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"core-foundation",
|
||||
"core-graphics-types",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-graphics"
|
||||
version = "0.25.0"
|
||||
@@ -520,6 +577,17 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "2.1.1"
|
||||
@@ -656,15 +724,19 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dossier-launcher"
|
||||
version = "0.1.0"
|
||||
version = "0.6.3"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"cocoa",
|
||||
"directories",
|
||||
"objc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-updater",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -755,6 +827,16 @@ dependencies = [
|
||||
"typeid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.4.1"
|
||||
@@ -780,6 +862,16 @@ dependencies = [
|
||||
"rustc_version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
@@ -1322,6 +1414,21 @@ dependencies = [
|
||||
"want",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.27.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
|
||||
dependencies = [
|
||||
"http",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"rustls",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.20"
|
||||
@@ -1577,6 +1684,36 @@ dependencies = [
|
||||
"windows-sys 0.45.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.22.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"combine",
|
||||
"jni-macros",
|
||||
"jni-sys 0.4.1",
|
||||
"log",
|
||||
"simd_cesu8",
|
||||
"thiserror 2.0.18",
|
||||
"walkdir",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-macros"
|
||||
version = "0.22.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
"simd_cesu8",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys"
|
||||
version = "0.3.1"
|
||||
@@ -1714,6 +1851,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.2"
|
||||
@@ -1735,6 +1878,15 @@ version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "malloc_buf"
|
||||
version = "0.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.38.0"
|
||||
@@ -1767,6 +1919,12 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "minisign-verify"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -1876,6 +2034,15 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1"
|
||||
dependencies = [
|
||||
"malloc_buf",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2"
|
||||
version = "0.6.4"
|
||||
@@ -2015,6 +2182,18 @@ dependencies = [
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-osa-kit"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-quartz-core"
|
||||
version = "0.3.2"
|
||||
@@ -2078,12 +2257,32 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "osakit"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
"objc2-osa-kit",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pango"
|
||||
version = "0.18.3"
|
||||
@@ -2465,15 +2664,20 @@ dependencies = [
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"rustls-platform-verifier",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
@@ -2509,6 +2713,20 @@ dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.2"
|
||||
@@ -2524,6 +2742,92 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.40"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
|
||||
dependencies = [
|
||||
"openssl-probe",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
|
||||
dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
|
||||
dependencies = [
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
"jni 0.22.4",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-platform-verifier-android",
|
||||
"rustls-webpki",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier-android"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
@@ -2539,6 +2843,15 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "0.8.22"
|
||||
@@ -2596,6 +2909,29 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework-sys"
|
||||
version = "2.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "selectors"
|
||||
version = "0.36.1"
|
||||
@@ -2806,6 +3142,22 @@ version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||
|
||||
[[package]]
|
||||
name = "simd_cesu8"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33"
|
||||
dependencies = [
|
||||
"rustc_version",
|
||||
"simdutf8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simdutf8"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.3"
|
||||
@@ -2918,6 +3270,12 @@ version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "swift-rs"
|
||||
version = "1.0.7"
|
||||
@@ -2992,7 +3350,7 @@ dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"block2",
|
||||
"core-foundation",
|
||||
"core-graphics",
|
||||
"core-graphics 0.25.0",
|
||||
"crossbeam-channel",
|
||||
"dbus",
|
||||
"dispatch2",
|
||||
@@ -3001,7 +3359,7 @@ dependencies = [
|
||||
"gdkwayland-sys",
|
||||
"gdkx11-sys",
|
||||
"gtk",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"libc",
|
||||
"log",
|
||||
"ndk",
|
||||
@@ -3034,6 +3392,17 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
"xattr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.12.16"
|
||||
@@ -3057,7 +3426,7 @@ dependencies = [
|
||||
"gtk",
|
||||
"heck 0.5.0",
|
||||
"http",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"libc",
|
||||
"log",
|
||||
"mime",
|
||||
@@ -3211,6 +3580,49 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-process"
|
||||
version = "2.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a"
|
||||
dependencies = [
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-updater"
|
||||
version = "2.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"dirs",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"http",
|
||||
"infer",
|
||||
"log",
|
||||
"minisign-verify",
|
||||
"osakit",
|
||||
"percent-encoding",
|
||||
"reqwest",
|
||||
"rustls",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tar",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"tokio",
|
||||
"url",
|
||||
"windows-sys 0.60.2",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.11.1"
|
||||
@@ -3221,7 +3633,7 @@ dependencies = [
|
||||
"dpi",
|
||||
"gtk",
|
||||
"http",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"objc2",
|
||||
"objc2-ui-kit",
|
||||
"objc2-web-kit",
|
||||
@@ -3244,7 +3656,7 @@ checksum = "a3989df2ae1c476404fe0a2e8ffc4cfbde97e51efd613c2bb5355fbc9ab52cf0"
|
||||
dependencies = [
|
||||
"gtk",
|
||||
"http",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
@@ -3311,6 +3723,19 @@ dependencies = [
|
||||
"toml 1.1.2+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tendril"
|
||||
version = "0.5.0"
|
||||
@@ -3431,6 +3856,16 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
|
||||
dependencies = [
|
||||
"rustls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
@@ -3727,6 +4162,12 @@ version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
@@ -4019,6 +4460,15 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webview2-com"
|
||||
version = "0.38.2"
|
||||
@@ -4258,6 +4708,15 @@ dependencies = [
|
||||
"windows-targets 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
@@ -4698,7 +5157,7 @@ dependencies = [
|
||||
"gtk",
|
||||
"http",
|
||||
"javascriptcore-rs",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"libc",
|
||||
"ndk",
|
||||
"objc2",
|
||||
@@ -4745,6 +5204,16 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "1.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.2"
|
||||
@@ -4789,6 +5258,12 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.4"
|
||||
@@ -4822,6 +5297,18 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "4.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"crc32fast",
|
||||
"indexmap 2.14.0",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "dossier-launcher"
|
||||
version = "0.1.0"
|
||||
version = "0.6.3"
|
||||
description = "Dossier — Projekt-Launcher fuer Rhino"
|
||||
authors = ["Karim Gabriele Varano"]
|
||||
edition = "2021"
|
||||
@@ -13,13 +13,22 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri = { version = "2", features = ["tray-icon"] }
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-process = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
directories = "5"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
# macOS-only: native NSWindow-Calls fuer abgerundete Splash-Ecken.
|
||||
# Cocoa + objc sind die etablierten low-level Bindings; Tauris transparent:true
|
||||
# Window haette sonst weisse Ecken weil WkWebView per default opaque ist.
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
cocoa = "0.26"
|
||||
objc = "0.2"
|
||||
|
||||
[profile.release]
|
||||
panic = "abort"
|
||||
codegen-units = 1
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability fuer das Hauptfenster",
|
||||
"windows": ["main"],
|
||||
"description": "Capability fuer Haupt- und Splash-Fenster",
|
||||
"windows": ["main", "splash"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"dialog:default"
|
||||
"core:webview:allow-print",
|
||||
"dialog:default",
|
||||
"updater:default",
|
||||
"process:allow-restart"
|
||||
]
|
||||
}
|
||||
|
||||
+869
-14
@@ -1,10 +1,18 @@
|
||||
// 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
|
||||
@@ -18,6 +26,47 @@ struct Project {
|
||||
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)]
|
||||
@@ -30,21 +79,115 @@ struct ProjectConfig {
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
struct Settings {
|
||||
// App-Name oder absoluter Pfad zur Rhino-App. `open -a` akzeptiert beides:
|
||||
// "Rhinoceros 8" -> sucht in /Applications
|
||||
// "/Applications/Rhino 8.app" oder ein Beta-Build irgendwo anders.
|
||||
#[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.
|
||||
@@ -63,6 +206,10 @@ 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() {
|
||||
@@ -74,8 +221,18 @@ fn load_settings() -> Settings {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn list_recent() -> Vec<Project> {
|
||||
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![];
|
||||
@@ -84,6 +241,11 @@ fn list_recent() -> Vec<Project> {
|
||||
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();
|
||||
@@ -124,18 +286,349 @@ fn read_project_config(path3dm: String) -> Result<Option<ProjectConfig>, String>
|
||||
Ok(Some(cfg))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn open_rhino(path3dm: String) -> Result<(), String> {
|
||||
// macOS: `open -a <app> <file>`. `<app>` kann ein App-Name (in /Applications
|
||||
// gesucht) oder ein absoluter Pfad zur .app sein. Aus den Settings — Default
|
||||
// "Rhinoceros 8", falls der User nichts angepasst hat.
|
||||
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])
|
||||
.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(())
|
||||
}
|
||||
|
||||
@@ -155,20 +648,382 @@ 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
|
||||
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,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("Fehler beim Starten der Tauri-App");
|
||||
.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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Dossier",
|
||||
"version": "0.1.0",
|
||||
"version": "0.6.3",
|
||||
"identifier": "ch.gabrielevarano.dossier",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -14,12 +14,28 @@
|
||||
{
|
||||
"label": "main",
|
||||
"title": "Dossier",
|
||||
"width": 920,
|
||||
"height": 640,
|
||||
"minWidth": 720,
|
||||
"minHeight": 480,
|
||||
"width": 1080,
|
||||
"height": 720,
|
||||
"minWidth": 880,
|
||||
"minHeight": 520,
|
||||
"resizable": true,
|
||||
"fullscreen": false
|
||||
},
|
||||
{
|
||||
"label": "splash",
|
||||
"url": "splash.html",
|
||||
"title": "Dossier lädt",
|
||||
"width": 440,
|
||||
"height": 190,
|
||||
"center": true,
|
||||
"alwaysOnTop": true,
|
||||
"decorations": false,
|
||||
"resizable": false,
|
||||
"skipTaskbar": true,
|
||||
"visible": false,
|
||||
"transparent": true,
|
||||
"shadow": true,
|
||||
"focus": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
@@ -29,14 +45,26 @@
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["app", "dmg"],
|
||||
"createUpdaterArtifacts": true,
|
||||
"icon": ["icons/icon.png"],
|
||||
"copyright": "© 2026 Karim Gabriele Varano",
|
||||
"category": "DeveloperTool",
|
||||
"shortDescription": "Dossier Launcher",
|
||||
"longDescription": "Projekt-Launcher fuer das Dossier-Plugin in Rhino 8.",
|
||||
"macOS": {
|
||||
"signingIdentity": "-"
|
||||
},
|
||||
"resources": {
|
||||
"../../dist": "dist",
|
||||
"../../rhino": "rhino"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"endpoints": [
|
||||
"https://git.kgva.ch/karim/DOSSIER/raw/branch/main/latest.json"
|
||||
],
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDY3Q0IzQzA4Mjc5NTczOApSV1E0VjNtQ3dMTjhCamZqbElWdDBlQnNNU3ZEZDg0bEp0aGtyRnN1M2ZKZTdJYzV0TUJEUnhxRQo="
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1831
-97
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,100 @@
|
||||
// Material-Symbols-Outlined-style Icons als Inline-SVG. Keine Font-Loads,
|
||||
// kein Codepoint-Mapping — sauber zu themen via currentColor + stroke-width.
|
||||
|
||||
const PATHS = {
|
||||
folder: (
|
||||
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" />
|
||||
),
|
||||
edit: (
|
||||
<>
|
||||
<path d="M4 20h4l10.5-10.5a2.121 2.121 0 0 0-3-3L5 17v3Z" />
|
||||
<path d="m13.5 6.5 3 3" />
|
||||
</>
|
||||
),
|
||||
plus: (
|
||||
<>
|
||||
<path d="M12 5v14" />
|
||||
<path d="M5 12h14" />
|
||||
</>
|
||||
),
|
||||
search: (
|
||||
<>
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="m20 20-3.5-3.5" />
|
||||
</>
|
||||
),
|
||||
close: (
|
||||
<>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="M6 6l12 12" />
|
||||
</>
|
||||
),
|
||||
settings: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9c.36.13.73.31 1.05.55" />
|
||||
</>
|
||||
),
|
||||
layers: (
|
||||
<>
|
||||
<path d="m12 2 9 5-9 5-9-5 9-5Z" />
|
||||
<path d="m3 12 9 5 9-5" />
|
||||
<path d="m3 17 9 5 9-5" />
|
||||
</>
|
||||
),
|
||||
trash: (
|
||||
<>
|
||||
<path d="M3 6h18" />
|
||||
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
<path d="m19 6-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||
</>
|
||||
),
|
||||
refresh: (
|
||||
<>
|
||||
<path d="M21 12a9 9 0 1 1-3.5-7.1" />
|
||||
<path d="M21 4v5h-5" />
|
||||
</>
|
||||
),
|
||||
pin: (
|
||||
<>
|
||||
<path d="M9 4h6l-1 5 3 3v2H7v-2l3-3-1-5Z" />
|
||||
<path d="M12 14v6" />
|
||||
</>
|
||||
),
|
||||
pin_filled: (
|
||||
<path d="M9 4h6l-1 5 3 3v2h-4v6h-2v-6H7v-2l3-3-1-5Z" fill="currentColor" stroke="none" />
|
||||
),
|
||||
snapshot: (
|
||||
<>
|
||||
<path d="M3 9a2 2 0 0 1 2-2h2.5l1.7-2h5.6l1.7 2H19a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V9Z" />
|
||||
<circle cx="12" cy="13" r="3.5" />
|
||||
</>
|
||||
),
|
||||
tag: (
|
||||
<>
|
||||
<path d="M3 12V4h8l10 10-8 8L3 12Z" />
|
||||
<circle cx="8" cy="8" r="1.4" fill="currentColor" stroke="none" />
|
||||
</>
|
||||
),
|
||||
chevron_down: <path d="m6 9 6 6 6-6" />,
|
||||
chevron_up: <path d="m6 15 6-6 6 6" />,
|
||||
};
|
||||
|
||||
export default function Icon({ name, size = 16, strokeWidth = 1.6, style }) {
|
||||
const path = PATHS[name];
|
||||
if (!path) return null;
|
||||
return (
|
||||
<svg
|
||||
width={size} height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ flexShrink: 0, ...style }}
|
||||
>
|
||||
{path}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { checkForAppUpdate, installAppUpdate, skipUpdateVersion, isTauri } from "../utils/updater.js";
|
||||
|
||||
export default function UpdateNotifier() {
|
||||
const [update, setUpdate] = useState(null);
|
||||
const [state, setState] = useState("idle");
|
||||
const [downloaded, setDownloaded] = useState(0);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const runCheck = useCallback(async ({ silent }) => {
|
||||
if (!isTauri()) return;
|
||||
try {
|
||||
setState("checking");
|
||||
setError(null);
|
||||
const res = await checkForAppUpdate({ respectSkip: silent });
|
||||
if (!res.available) {
|
||||
setUpdate(null);
|
||||
setState("idle");
|
||||
return;
|
||||
}
|
||||
setUpdate(res.update);
|
||||
setState("available");
|
||||
} catch (e) {
|
||||
console.error("Update-Check fehlgeschlagen:", e);
|
||||
setError(String(e?.message || e));
|
||||
setState("idle");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => runCheck({ silent: true }), 1500);
|
||||
return () => clearTimeout(t);
|
||||
}, [runCheck]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => runCheck({ silent: false });
|
||||
window.addEventListener("dossier:check-update", handler);
|
||||
return () => window.removeEventListener("dossier:check-update", handler);
|
||||
}, [runCheck]);
|
||||
|
||||
const install = async () => {
|
||||
if (!update) return;
|
||||
try {
|
||||
setState("downloading");
|
||||
setDownloaded(0);
|
||||
setTotal(0);
|
||||
await installAppUpdate(update, (event) => {
|
||||
if (event.event === "Started") {
|
||||
setTotal(event.data.contentLength || 0);
|
||||
} else if (event.event === "Progress") {
|
||||
setDownloaded((d) => d + (event.data.chunkLength || 0));
|
||||
} else if (event.event === "Finished") {
|
||||
setState("installing");
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Update-Installation fehlgeschlagen:", e);
|
||||
setError(String(e?.message || e));
|
||||
setState("available");
|
||||
}
|
||||
};
|
||||
|
||||
const skipVersion = () => {
|
||||
skipUpdateVersion(update?.version);
|
||||
setUpdate(null);
|
||||
setState("idle");
|
||||
};
|
||||
|
||||
const later = () => {
|
||||
setUpdate(null);
|
||||
setState("idle");
|
||||
};
|
||||
|
||||
if (!isTauri() || !update || state === "idle" || state === "checking") return null;
|
||||
|
||||
const isBusy = state === "downloading" || state === "installing";
|
||||
const pct = total > 0 ? Math.min(100, Math.round((downloaded / total) * 100)) : null;
|
||||
|
||||
return (
|
||||
<div className="dialog-bg" style={{ zIndex: 300 }}>
|
||||
<div className="dialog" style={{ width: 460, maxWidth: "92vw" }}>
|
||||
<header style={{ background: "var(--bg-panel)" }}>
|
||||
<div style={{ fontSize: 10, letterSpacing: "0.18em", color: "var(--accent)", marginBottom: 4, fontWeight: 600 }}>
|
||||
UPDATE VERFÜGBAR
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "baseline", gap: 10 }}>
|
||||
<div style={{ fontSize: 18, color: "var(--text)", fontWeight: 500 }}>
|
||||
Dossier {update.version}
|
||||
</div>
|
||||
{update.currentVersion && (
|
||||
<div style={{ fontSize: 11, color: "var(--text-muted)" }}>von {update.currentVersion}</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="body">
|
||||
{update.body && (
|
||||
<div style={{ display: "flex", gap: 12 }}>
|
||||
<div style={{ width: 3, flexShrink: 0, background: "var(--accent)", borderRadius: 2 }} />
|
||||
<div style={{ fontSize: 12, color: "var(--text-muted)", lineHeight: 1.55, whiteSpace: "pre-wrap" }}>{update.body}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div style={{ padding: "10px 12px", background: "rgba(200, 112, 80, 0.12)", border: "1px solid var(--danger)", borderRadius: 6, fontSize: 12, color: "var(--danger)" }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isBusy && (
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: "var(--text-muted)", marginBottom: 6, letterSpacing: "0.04em" }}>
|
||||
{state === "downloading"
|
||||
? (pct !== null ? `Wird heruntergeladen … ${pct}%` : "Wird heruntergeladen …")
|
||||
: "Wird installiert …"}
|
||||
</div>
|
||||
<div style={{ height: 4, background: "var(--bg-elev)", borderRadius: 2, overflow: "hidden" }}>
|
||||
<div style={{
|
||||
height: "100%",
|
||||
width: pct !== null ? `${pct}%` : "100%",
|
||||
background: "var(--accent)",
|
||||
transition: "width 0.2s",
|
||||
animation: pct === null ? "dossier-update-pulse 1.2s ease-in-out infinite" : undefined,
|
||||
}} />
|
||||
</div>
|
||||
<style>{`@keyframes dossier-update-pulse { 0%,100% { opacity: 0.5; } 50% { opacity: 1; } }`}</style>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer style={{ flexDirection: "column", gap: 8, alignItems: "stretch" }}>
|
||||
<button className="primary pill" style={{ width: "100%" }} onClick={install} disabled={isBusy}>
|
||||
{isBusy ? "Bitte warten …" : "Jetzt installieren"}
|
||||
</button>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button style={{ flex: 1 }} onClick={later} disabled={isBusy}>Später</button>
|
||||
<button style={{ flex: 1 }} onClick={skipVersion} disabled={isBusy}>Diese Version überspringen</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+912
-97
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
||||
// Shared helpers fuer den Tauri-Updater. Verwendet vom Auto-Check Modal
|
||||
// (UpdateNotifier) und dem manuellen Check in den Einstellungen.
|
||||
|
||||
export const SKIP_KEY = "dossier_update_skipped_version";
|
||||
export const LAST_CHECK_KEY = "dossier_update_last_check";
|
||||
|
||||
export function isTauri() {
|
||||
return typeof window !== "undefined" && !!window.__TAURI_INTERNALS__;
|
||||
}
|
||||
|
||||
export async function checkForAppUpdate({ respectSkip = true } = {}) {
|
||||
if (!isTauri()) return { available: false, isTauri: false };
|
||||
const { check } = await import("@tauri-apps/plugin-updater");
|
||||
const result = await check();
|
||||
localStorage.setItem(LAST_CHECK_KEY, new Date().toISOString());
|
||||
if (!result?.available) return { available: false, update: null, isTauri: true };
|
||||
if (respectSkip && localStorage.getItem(SKIP_KEY) === result.version) {
|
||||
return { available: false, update: result, isTauri: true, skipped: true };
|
||||
}
|
||||
return { available: true, update: result, isTauri: true };
|
||||
}
|
||||
|
||||
export async function installAppUpdate(update, onProgress) {
|
||||
await update.downloadAndInstall(onProgress);
|
||||
const { relaunch } = await import("@tauri-apps/plugin-process");
|
||||
await relaunch();
|
||||
}
|
||||
|
||||
export function skipUpdateVersion(version) {
|
||||
if (version) localStorage.setItem(SKIP_KEY, version);
|
||||
}
|
||||
|
||||
export function getLastUpdateCheck() {
|
||||
return localStorage.getItem(LAST_CHECK_KEY);
|
||||
}
|
||||
|
||||
export function formatLastCheck(iso) {
|
||||
if (!iso) return "noch nie";
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString("de-CH", {
|
||||
day: "2-digit", month: "long", year: "numeric",
|
||||
hour: "2-digit", minute: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return "—";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user