Linux-Port: GNOME-Popup + Tray, Terminal-Schriftgröße global

- Popup als rahmenloses Fenster mit D-Bus-Relay (popup.html/ts, Popup.vue);
  GNOME-Extension schiebt es unter den Panel-Button
- Terminal: globale Schriftgröße via settings.json (terminalFontSize),
  Zoom mit Ctrl/Cmd +/-/0 im Terminal
- trayLinux-Icon, Capabilities, App.vue/terminal.html angepasst
This commit is contained in:
marcus hinz
2026-07-06 20:08:16 +02:00
parent 85c18eb8f0
commit 1df0da4d48
14 changed files with 1346 additions and 106 deletions
-45
View File
@@ -309,9 +309,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -329,9 +326,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -349,9 +343,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -369,9 +360,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -389,9 +377,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -409,9 +394,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -597,9 +579,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
@@ -617,9 +596,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
@@ -637,9 +613,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
@@ -657,9 +630,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
@@ -677,9 +647,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
@@ -1262,9 +1229,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1286,9 +1250,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1310,9 +1271,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1334,9 +1292,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<title>Projekte</title>
<style>
html,
body {
margin: 0;
background: transparent;
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/popup.ts"></script>
</body>
</html>
+439
View File
@@ -33,6 +33,7 @@ name = "ai-control"
version = "0.1.1"
dependencies = [
"base64 0.22.1",
"glib",
"keyring",
"log",
"objc2",
@@ -48,6 +49,7 @@ dependencies = [
"tauri-plugin-dialog",
"tauri-plugin-log",
"uuid",
"zbus",
]
[[package]]
@@ -103,6 +105,137 @@ version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
[[package]]
name = "async-broadcast"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
dependencies = [
"event-listener",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-channel"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
dependencies = [
"concurrent-queue",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-executor"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
dependencies = [
"async-task",
"concurrent-queue",
"fastrand",
"futures-lite",
"pin-project-lite",
"slab",
]
[[package]]
name = "async-io"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
dependencies = [
"autocfg",
"cfg-if",
"concurrent-queue",
"futures-io",
"futures-lite",
"parking",
"polling",
"rustix",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-lock"
version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
dependencies = [
"event-listener",
"event-listener-strategy",
"pin-project-lite",
]
[[package]]
name = "async-process"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
dependencies = [
"async-channel",
"async-io",
"async-lock",
"async-signal",
"async-task",
"blocking",
"cfg-if",
"event-listener",
"futures-lite",
"rustix",
]
[[package]]
name = "async-recursion"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "async-signal"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
dependencies = [
"async-io",
"async-lock",
"atomic-waker",
"cfg-if",
"futures-core",
"futures-io",
"rustix",
"signal-hook-registry",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-task"
version = "4.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
[[package]]
name = "async-trait"
version = "0.1.89"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "atk"
version = "0.18.2"
@@ -221,6 +354,19 @@ dependencies = [
"objc2",
]
[[package]]
name = "blocking"
version = "1.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
dependencies = [
"async-channel",
"async-task",
"futures-io",
"futures-lite",
"piper",
]
[[package]]
name = "borsh"
version = "1.7.0"
@@ -486,6 +632,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "concurrent-queue"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "cookie"
version = "0.18.1"
@@ -902,6 +1057,33 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "endi"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]]
name = "enumflags2"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
dependencies = [
"enumflags2_derive",
"serde",
]
[[package]]
name = "enumflags2_derive"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "env_filter"
version = "0.1.4"
@@ -929,6 +1111,37 @@ 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 = "event-listener"
version = "5.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
dependencies = [
"concurrent-queue",
"parking",
"pin-project-lite",
]
[[package]]
name = "event-listener-strategy"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
dependencies = [
"event-listener",
"pin-project-lite",
]
[[package]]
name = "fastrand"
version = "2.4.1"
@@ -1076,6 +1289,19 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
[[package]]
name = "futures-lite"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
dependencies = [
"fastrand",
"futures-core",
"futures-io",
"parking",
"pin-project-lite",
]
[[package]]
name = "futures-macro"
version = "0.3.32"
@@ -1433,6 +1659,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]]
name = "hex"
version = "0.4.3"
@@ -1921,6 +2153,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"
@@ -2347,6 +2585,16 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "ordered-stream"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
dependencies = [
"futures-core",
"pin-project-lite",
]
[[package]]
name = "pango"
version = "0.18.3"
@@ -2372,6 +2620,12 @@ dependencies = [
"system-deps",
]
[[package]]
name = "parking"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]]
name = "parking_lot"
version = "0.12.5"
@@ -2460,6 +2714,17 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "piper"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
dependencies = [
"atomic-waker",
"fastrand",
"futures-io",
]
[[package]]
name = "pkg-config"
version = "0.3.33"
@@ -2505,6 +2770,20 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "polling"
version = "3.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
dependencies = [
"cfg-if",
"concurrent-queue",
"hermit-abi",
"pin-project-lite",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "portable-pty"
version = "0.9.0"
@@ -2924,6 +3203,19 @@ dependencies = [
"semver",
]
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags 2.13.0",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "rustversion"
version = "1.0.22"
@@ -3269,6 +3561,16 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "simd-adler32"
version = "0.3.9"
@@ -3830,6 +4132,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.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "tendril"
version = "0.5.0"
@@ -4136,9 +4451,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"pin-project-lite",
"tracing-attributes",
"tracing-core",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
@@ -4188,6 +4515,17 @@ version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "uds_windows"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.61.2",
]
[[package]]
name = "unic-char-property"
version = "0.9.0"
@@ -5115,6 +5453,67 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zbus"
version = "5.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285"
dependencies = [
"async-broadcast",
"async-executor",
"async-io",
"async-lock",
"async-process",
"async-recursion",
"async-task",
"async-trait",
"blocking",
"enumflags2",
"event-listener",
"futures-core",
"futures-lite",
"hex",
"libc",
"ordered-stream",
"rustix",
"serde",
"serde_repr",
"tracing",
"uds_windows",
"uuid",
"windows-sys 0.61.2",
"winnow 1.0.3",
"zbus_macros",
"zbus_names",
"zvariant",
]
[[package]]
name = "zbus_macros"
version = "5.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.118",
"zbus_names",
"zvariant",
"zvariant_utils",
]
[[package]]
name = "zbus_names"
version = "4.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d"
dependencies = [
"serde",
"winnow 1.0.3",
"zvariant",
]
[[package]]
name = "zerocopy"
version = "0.8.52"
@@ -5214,3 +5613,43 @@ name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zvariant"
version = "5.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0"
dependencies = [
"endi",
"enumflags2",
"serde",
"winnow 1.0.3",
"zvariant_derive",
"zvariant_utils",
]
[[package]]
name = "zvariant_derive"
version = "5.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.118",
"zvariant_utils",
]
[[package]]
name = "zvariant_utils"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6"
dependencies = [
"proc-macro2",
"quote",
"serde",
"syn 2.0.118",
"winnow 1.0.3",
]
+4
View File
@@ -31,6 +31,10 @@ portable-pty = "0.9"
uuid = { version = "1", features = ["v4"] }
keyring = { version = "3", features = ["apple-native", "sync-secret-service", "windows-native"] }
[target.'cfg(target_os = "linux")'.dependencies]
zbus = "5"
glib = "0.18"
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6"
objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSImage", "NSResponder", "NSRunningApplication"] }
+7
View File
@@ -4,11 +4,18 @@
"description": "enables the default permissions",
"windows": [
"main",
"popup",
"term-*"
],
"permissions": [
"core:default",
"core:window:allow-start-dragging",
"core:window:allow-set-size",
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"core:window:allow-close",
"core:window:allow-hide",
"core:window:allow-start-resize-dragging",
"autostart:default",
"dialog:default"
]
Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

+310 -53
View File
@@ -340,12 +340,12 @@ fn spawn_session_watcher(app: tauri::AppHandle) {
let handle = app.clone();
app
.run_on_main_thread(move || {
let menu = tray_menu(&handle).expect("Tray-Menü nicht aufbaubar");
handle
.tray_by_id("main")
.expect("Tray-Icon fehlt")
.set_menu(Some(menu))
.expect("Tray-Menü nicht setzbar");
// Nur wenn ein Tray existiert (macOS). Unter Linux pollt das Popup selbst.
if let Some(tray) = handle.tray_by_id("main") {
if let Ok(menu) = tray_menu(&handle) {
let _ = tray.set_menu(Some(menu));
}
}
})
.expect("Main-Thread nicht erreichbar");
state = current;
@@ -556,6 +556,8 @@ fn create_project_full_in(
if todo {
set_todo_in(paths, name, true)?;
}
#[cfg(target_os = "linux")]
write_terminal_desktop(paths, name, &cfg.terminal);
Ok(())
}
@@ -618,7 +620,15 @@ fn add_project_in(paths: &Paths, path: &str) -> Result<(), String> {
.to_string_lossy()
.into_owned();
check_name(&name)?;
register_project(paths, &name, &dir)
register_project(paths, &name, &dir)?;
#[cfg(target_os = "linux")]
{
let cfg = read_project_config_in(paths, &name)
.map(|c| c.terminal)
.unwrap_or_default();
write_terminal_desktop(paths, &name, &cfg);
}
Ok(())
}
#[cfg(test)]
@@ -739,7 +749,10 @@ fn delete_project_in(paths: &Paths, name: &str, delete_work_dirs: bool) -> Resul
}
}
fs::remove_dir_all(&dir).map_err(|e| e.to_string())?;
unregister_project(paths, name)
unregister_project(paths, name)?;
#[cfg(target_os = "linux")]
remove_terminal_desktop(paths, name);
Ok(())
}
fn read_project_config_in(paths: &Paths, project: &str) -> Result<ProjectConfig, String> {
@@ -809,7 +822,10 @@ fn set_terminal_config_in(
}
}
cfg.terminal = terminal;
write_project_config_in(paths, project, &cfg)
write_project_config_in(paths, project, &cfg)?;
#[cfg(target_os = "linux")]
write_terminal_desktop(paths, project, &cfg.terminal);
Ok(())
}
/// Icon-Pfad einer Projekt-Config auflösen: relative Namen liegen im
@@ -822,6 +838,78 @@ fn resolve_icon_path(paths: &Paths, icon: &str) -> PathBuf {
}
}
/// ~/.local/share/applications — Ziel der pro-Terminal-.desktop-Dateien.
/// paths-abhängig (nicht $HOME direkt), damit Tests in ihr tmp-home schreiben.
#[cfg(target_os = "linux")]
fn applications_dir(paths: &Paths) -> PathBuf {
paths.home.join(".local/share/applications")
}
/// Schreibt/aktualisiert die .desktop eines Projekts: dash-to-dock ordnet dem
/// Fenster (app_id `aicontrol-<projekt>`, via set_prgname) darüber das Projekt-
/// Icon zu. NoDisplay=true hält den App-Starter sauber.
#[cfg(target_os = "linux")]
pub(crate) fn write_terminal_desktop(paths: &Paths, project: &str, cfg: &TerminalConfig) {
let dir = applications_dir(paths);
if fs::create_dir_all(&dir).is_err() {
return;
}
let exec = std::env::current_exe()
.ok()
.and_then(|p| p.to_str().map(str::to_string))
.unwrap_or_else(|| "ai-control".into());
let icon_line = cfg
.icon
.as_deref()
.map(|i| resolve_icon_path(paths, i))
.filter(|p| p.exists())
.and_then(|p| p.to_str().map(str::to_string))
.map(|p| format!("Icon={p}\n"))
.unwrap_or_default();
let content = format!(
"[Desktop Entry]\nType=Application\nName={project}\nExec={exec} --terminal {project}\n\
{icon_line}StartupWMClass=aicontrol-{project}\nNoDisplay=true\n"
);
let _ = fs::write(dir.join(format!("aicontrol-{project}.desktop")), content);
}
/// Entfernt die .desktop eines Projekts (beim Löschen).
#[cfg(target_os = "linux")]
pub(crate) fn remove_terminal_desktop(paths: &Paths, project: &str) {
let _ = fs::remove_file(applications_dir(paths).join(format!("aicontrol-{project}.desktop")));
}
/// Beim App-Start: für jedes registrierte Projekt die .desktop neu schreiben und
/// verwaiste (kein registriertes Projekt mehr) entfernen. Dadurch sind sie immer
/// vorhanden und aktuell, bevor überhaupt ein Terminal startet.
#[cfg(target_os = "linux")]
fn sync_all_desktops(paths: &Paths) {
let Ok(reg) = load_registry(paths) else {
return;
};
for project in reg.keys() {
let cfg = read_project_config_in(paths, project)
.map(|c| c.terminal)
.unwrap_or_default();
write_terminal_desktop(paths, project, &cfg);
}
let dir = applications_dir(paths);
if let Ok(entries) = fs::read_dir(&dir) {
for e in entries.flatten() {
let name = e.file_name();
let Some(name) = name.to_str() else { continue };
if let Some(project) = name
.strip_prefix("aicontrol-")
.and_then(|n| n.strip_suffix(".desktop"))
{
if !reg.contains_key(project) {
let _ = fs::remove_file(e.path());
}
}
}
}
}
/// Icon eines Projekts als PNG-data-URL für die Übersicht; ICNS wird per
/// sips nach PNG konvertiert, weil der Browser ICNS nicht rendert.
#[tauri::command]
@@ -837,20 +925,7 @@ fn project_icon(project: String) -> Result<Option<String>, String> {
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("icns"));
let png = if is_icns {
let tmp = std::env::temp_dir().join(format!("ai-control-icon-{project}.png"));
let out = std::process::Command::new("sips")
.args(["-s", "format", "png"])
.arg(&path)
.arg("--out")
.arg(&tmp)
.output()
.map_err(|e| format!("sips: {e}"))?;
if !out.status.success() {
return Err(format!("sips: {}", String::from_utf8_lossy(&out.stderr)));
}
let bytes = fs::read(&tmp).map_err(|e| format!("{}: {e}", tmp.display()))?;
let _ = fs::remove_file(&tmp);
bytes
icns_to_png_bytes(&path, &project)?
} else {
fs::read(&path).map_err(|e| format!("{}: {e}", path.display()))?
};
@@ -904,17 +979,49 @@ fn menu_icon(
Ok(tauri::image::Image::new_owned(canvas, W as u32, H as u32))
}
/// Projekt-Icon als 36×36-RGBA (sips konvertiert, auch ICNS).
/// Projekt-Icon als 36×36-RGBA. PNG wird von tauri geladen und skaliert;
/// ICNS läuft über sips (nur macOS).
fn project_icon_rgba_36(
paths: &Paths,
project: &str,
icon: &str,
) -> Result<tauri::image::Image<'static>, String> {
let src = resolve_icon_path(paths, icon);
let is_icns = src
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("icns"));
let img = if is_icns {
tauri::image::Image::from_bytes(&icns_to_png_bytes(&src, project)?).map_err(|e| e.to_string())?
} else {
tauri::image::Image::from_path(&src).map_err(|e| format!("{}: {e}", src.display()))?
};
Ok(scale_rgba(&img, 36))
}
/// Nearest-Neighbor-Skalierung eines RGBA-Bildes auf ein Quadrat der Kantenlänge `size`.
fn scale_rgba(img: &tauri::image::Image, size: usize) -> tauri::image::Image<'static> {
let (sw, sh) = (img.width() as usize, img.height() as usize);
let src = img.rgba();
let mut out = vec![0u8; size * size * 4];
for y in 0..size {
for x in 0..size {
let sx = x * sw / size;
let sy = y * sh / size;
let si = (sy * sw + sx) * 4;
let di = (y * size + x) * 4;
out[di..di + 4].copy_from_slice(&src[si..si + 4]);
}
}
tauri::image::Image::new_owned(out, size as u32, size as u32)
}
/// ICNS → PNG-Bytes via sips (nur macOS; Linux hat kein ICNS).
fn icns_to_png_bytes(src: &std::path::Path, project: &str) -> Result<Vec<u8>, String> {
let tmp = std::env::temp_dir().join(format!("ai-control-tray-icon-{project}.png"));
let out = Command::new("sips")
.args(["-s", "format", "png", "-z", "36", "36"])
.arg(&src)
.args(["-s", "format", "png"])
.arg(src)
.arg("--out")
.arg(&tmp)
.output()
@@ -924,7 +1031,7 @@ fn project_icon_rgba_36(
}
let bytes = fs::read(&tmp).map_err(|e| format!("{}: {e}", tmp.display()))?;
let _ = fs::remove_file(&tmp);
tauri::image::Image::from_bytes(&bytes).map_err(|e| e.to_string())
Ok(bytes)
}
/// Terminal-Einstellungen eines Projekts, für den Terminal-Prozess.
@@ -1818,6 +1925,33 @@ fn set_sync_setting(enabled: bool) -> Result<(), String> {
set_sync_on_session_end_in(&Paths::real(), enabled)
}
/// Terminal-Schriftgröße (settings.json: terminalFontSize), ein Wert für alle
/// Terminals. Default 13.
#[tauri::command]
fn terminal_font_size() -> u32 {
fs::read_to_string(Paths::real().config_dir().join(APP_SETTINGS_FILE))
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
.and_then(|v| v["terminalFontSize"].as_u64())
.map(|n| n as u32)
.unwrap_or(13)
}
/// Setzt die Schriftgröße; erhält übrige App-settings.
#[tauri::command]
fn set_terminal_font_size(size: u32) -> Result<(), String> {
let paths = Paths::real();
let path = paths.config_dir().join(APP_SETTINGS_FILE);
let mut v: serde_json::Value = fs::read_to_string(&path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_else(|| serde_json::json!({}));
v["terminalFontSize"] = serde_json::json!(size);
fs::create_dir_all(paths.config_dir()).map_err(|e| e.to_string())?;
let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
fs::write(&path, raw + "\n").map_err(|e| format!("{}: {e}", path.display()))
}
/// Verlinkt die synced Runtime eines bestehenden Pools. Nur bei idlem Pool —
/// sonst würde das Transkript der laufenden Session ersetzt.
#[tauri::command]
@@ -1839,6 +1973,10 @@ pub fn run() {
// damit jedes Terminal ein eigenes Dock-Icon bekommt.
Some("--terminal") => {
let project = args.next().expect("--terminal braucht einen Projektnamen");
// Linux: eigene Wayland-app_id pro Terminal-Prozess -> eigener Dock-Eintrag
// (muss vor dem GTK-Init stehen). Test, ob set_prgname die app_id setzt.
#[cfg(target_os = "linux")]
glib::set_prgname(Some(format!("aicontrol-{project}").as_str()));
let icon = terminal_config(&project)
.expect("Projekt-Config nicht lesbar")
.icon
@@ -1908,6 +2046,72 @@ fn start_or_focus(app: &tauri::AppHandle, project: &str) {
}
}
/// Tray-/Popup-Klick: Projekt starten oder das laufende Terminal fokussieren.
#[tauri::command]
fn start_or_focus_cmd(app: tauri::AppHandle, project: String) {
start_or_focus(&app, &project);
}
/// Popup-Fußzeile „Öffnen": das Hauptfenster zeigen.
#[tauri::command]
fn open_main_window(app: tauri::AppHandle) {
use tauri::Manager;
if let Some(w) = app.get_webview_window("main") {
let _ = w.show();
let _ = w.set_focus();
}
}
/// Popup-Fußzeile „Beenden": App verlassen.
#[tauri::command]
fn quit_app(app: tauri::AppHandle) {
app.exit(0);
}
/// D-Bus-Relay für die GNOME-Extension: Show()/Hide() steuern das Popup-Fenster.
/// Die Extension ruft Show() beim Panel-Klick und positioniert das Fenster selbst.
#[cfg(target_os = "linux")]
struct PopupService {
app: tauri::AppHandle,
}
#[cfg(target_os = "linux")]
#[zbus::interface(name = "com.aicontrol.Popup1")]
impl PopupService {
fn show(&self) {
use tauri::Manager;
let app = self.app.clone();
let win = app.clone();
let _ = app.run_on_main_thread(move || {
if let Some(w) = win.get_webview_window("popup") {
let _ = w.show();
let _ = w.set_focus();
}
});
}
fn hide(&self) {
use tauri::Manager;
let app = self.app.clone();
let win = app.clone();
let _ = app.run_on_main_thread(move || {
if let Some(w) = win.get_webview_window("popup") {
let _ = w.hide();
}
});
}
}
#[cfg(target_os = "linux")]
fn serve_popup_dbus(app: tauri::AppHandle) -> zbus::Result<()> {
let _conn = zbus::blocking::connection::Builder::session()?
.name("com.aicontrol.Popup")?
.serve_at("/com/aicontrol/Popup", PopupService { app })?
.build()?;
loop {
std::thread::park();
}
}
/// Haupt-App: reine Tray-App ohne Dock-Eintrag.
fn main_builder() -> tauri::Builder<tauri::Wry> {
use tauri::Manager;
@@ -1935,38 +2139,77 @@ fn main_builder() -> tauri::Builder<tauri::Wry> {
// hält das Tray-Menü aktuell.
spawn_session_watcher(app.handle().clone());
// Alle pro-Terminal-.desktop-Dateien neu schreiben + verwaiste entfernen,
// damit sie da sind, bevor ein Terminal startet.
#[cfg(target_os = "linux")]
sync_all_desktops(&Paths::real());
// Fenster im Code statt in tauri.conf.json, damit der Terminal-Prozess
// (gleiches Binary, gleiche Config) kein main-Fenster anlegt.
tauri::WebviewWindowBuilder::new(app, "main", tauri::WebviewUrl::default())
let main_win = tauri::WebviewWindowBuilder::new(app, "main", tauri::WebviewUrl::default())
.title("ai-control")
.inner_size(800.0, 600.0)
.visible(false)
.build()?;
.visible(false);
// Wie die Terminal-Fenster: eigener Header als Titelleiste. macOS behält die
// Ampel (Overlay), Linux ist dekorationslos (eigene Fensterknöpfe im Header).
#[cfg(target_os = "macos")]
let main_win = main_win
.title_bar_style(tauri::TitleBarStyle::Overlay)
.hidden_title(true);
#[cfg(target_os = "linux")]
let main_win = main_win.decorations(false);
main_win.build()?;
let menu = tray_menu(app.handle())?;
tauri::tray::TrayIconBuilder::with_id("main")
// include_bytes! statt default_window_icon: cargo trackt die Datei,
// neu generierte Icons landen damit sicher im nächsten Build.
.icon(tauri::image::Image::from_bytes(include_bytes!(
"../icons/trayTemplate.png"
))?)
// Template-Icon: macOS färbt es passend zur Menüleiste ein.
.icon_as_template(true)
.menu(&menu)
.on_menu_event(|app, event| match event.id.as_ref() {
"open" => {
let w = app.get_webview_window("main").unwrap();
w.show().unwrap();
w.set_focus().unwrap();
}
"quit" => app.exit(0),
id => {
if let Some(project) = id.strip_prefix("project:") {
start_or_focus(app, project);
// macOS: natives Tray-Menü (rendert Icons pro Eintrag). Unter Linux/GNOME
// übernimmt die Shell-Extension den Panel-Button + Popup — kein Tray hier,
// sonst erschiene das aIC-Icon doppelt.
#[cfg(target_os = "macos")]
{
let menu = tray_menu(app.handle())?;
tauri::tray::TrayIconBuilder::with_id("main")
.icon(tauri::image::Image::from_bytes(include_bytes!(
"../icons/trayTemplate.png"
))?)
.icon_as_template(true)
.menu(&menu)
.on_menu_event(|app, event| match event.id.as_ref() {
"open" => {
let w = app.get_webview_window("main").unwrap();
w.show().unwrap();
w.set_focus().unwrap();
}
"quit" => app.exit(0),
id => {
if let Some(project) = id.strip_prefix("project:") {
start_or_focus(app, project);
}
}
})
.build(app)?;
}
// Linux/GNOME: rahmenloses Popup-Fenster + D-Bus-Relay. Die Shell-Extension
// (ai-control-popup@local) ruft Show() und schiebt das Fenster unter ihren
// Panel-Button — GNOME liefert der App selbst keinen Tray-Klick.
#[cfg(target_os = "linux")]
{
tauri::WebviewWindowBuilder::new(app, "popup", tauri::WebviewUrl::App("popup.html".into()))
.title("ai-control-popup")
.inner_size(320.0, 300.0)
.decorations(false)
.visible(false)
.transparent(true)
.skip_taskbar(true)
.always_on_top(true)
.resizable(false)
.build()?;
let app_dbus = app.handle().clone();
std::thread::spawn(move || {
if let Err(e) = serve_popup_dbus(app_dbus) {
eprintln!("D-Bus-Popup-Dienst: {e}");
}
})
.build(app)?;
});
}
Ok(())
})
@@ -1976,6 +2219,13 @@ fn main_builder() -> tauri::Builder<tauri::Wry> {
api.prevent_close();
window.hide().unwrap();
}
// Popup schließt bei Fokusverlust.
#[cfg(target_os = "linux")]
if let tauri::WindowEvent::Focused(false) = event {
if window.label() == "popup" {
let _ = window.hide();
}
}
})
.invoke_handler(tauri::generate_handler![
list_projects,
@@ -2002,8 +2252,13 @@ fn main_builder() -> tauri::Builder<tauri::Wry> {
usage_stats,
stop_project,
restart_project,
start_or_focus_cmd,
open_main_window,
quit_app,
sync_setting,
set_sync_setting,
terminal_font_size,
set_terminal_font_size,
link_pool_runtime,
oauth_login,
keychain_status,
@@ -2036,7 +2291,9 @@ fn terminal_builder(project: String) -> tauri::Builder<tauri::Wry> {
// Header im Terminal-Prozess: Projektliste, Icon und Pool-Name.
list_projects,
project_icon,
pool_label
pool_label,
terminal_font_size,
set_terminal_font_size
])
}
+28
View File
@@ -40,6 +40,7 @@ pub fn open_terminal(app: AppHandle, project: String) -> Result<(), String> {
/// Tritt die Aktivierung an den nächsten startenden Prozess mit dieser
/// Bundle-ID ab (der Terminal-Prozess läuft unter derselben).
#[cfg(target_os = "macos")]
fn yield_activation_to_bundle(bundle_id: &str) {
use objc2::MainThreadMarker;
use objc2_app_kit::NSApplication;
@@ -50,8 +51,13 @@ fn yield_activation_to_bundle(bundle_id: &str) {
.yieldActivationToApplicationWithBundleIdentifier(&NSString::from_str(bundle_id));
}
/// Linux kennt keine kooperative Aktivierungsabtretung.
#[cfg(target_os = "linux")]
fn yield_activation_to_bundle(_bundle_id: &str) {}
/// Selbst-Aktivierung des frisch gestarteten Terminal-Prozesses (Ready-Event);
/// die Gegenseite hat vorher per yield abgetreten.
#[cfg(target_os = "macos")]
pub fn activate_self(app: &AppHandle) {
use objc2::MainThreadMarker;
use objc2_app_kit::NSApplication;
@@ -62,6 +68,14 @@ pub fn activate_self(app: &AppHandle) {
}
}
/// Linux: Fenster ohne NSApplication über die Tauri-API fokussieren.
#[cfg(target_os = "linux")]
pub fn activate_self(app: &AppHandle) {
if let Some(window) = app.webview_windows().values().next() {
window.set_focus().expect("Terminal-Fenster nicht fokussierbar");
}
}
/// Fenster-Hintergrund je Theme — muss zu den Theme-Definitionen in
/// terminal.ts passen, sonst blitzt beim Öffnen die falsche Farbe auf.
fn theme_background(theme: &str) -> (u8, u8, u8) {
@@ -95,9 +109,14 @@ pub fn set_dock_icon(path: &str) {
}
}
/// Linux kennt kein Dock-Icon pro Prozess.
#[cfg(target_os = "linux")]
pub fn set_dock_icon(_path: &str) {}
/// Holt das Terminal-Fenster eines laufenden Projekts in den Vordergrund:
/// aktiviert den Terminal-Prozess über seine PID. Aktivierung ist seit
/// macOS 14 kooperativ — die Tray-App tritt sie vorher ab.
#[cfg(target_os = "macos")]
pub fn focus_terminal(pid: u32) {
use objc2::MainThreadMarker;
use objc2_app_kit::{NSApplication, NSApplicationActivationOptions, NSRunningApplication};
@@ -111,6 +130,11 @@ pub fn focus_terminal(pid: u32) {
term.activateWithOptions(NSApplicationActivationOptions::ActivateAllWindows);
}
/// Linux: Fremdprozess-Fokus über die PID steht ohne NSRunningApplication
/// nicht zur Verfügung.
#[cfg(target_os = "linux")]
pub fn focus_terminal(_pid: u32) {}
/// Baut das Terminal-Fenster des Terminal-Prozesses. Die PTY entsteht erst,
/// wenn das Fenster geladen ist und `term_start` ruft — so gehen keine
/// Ausgaben verloren, bevor der Event-Listener steht.
@@ -136,6 +160,10 @@ pub fn build_window(
.title_bar_style(tauri::TitleBarStyle::Overlay)
.hidden_title(true);
// Linux/GNOME: keine GTK-Deko — eigene Titelleiste im Header (terminal.html).
#[cfg(target_os = "linux")]
let builder = builder.decorations(false);
builder.build()?;
Ok(())
}
+162 -2
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { enable, disable, isEnabled } from "@tauri-apps/plugin-autostart";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { useI18n } from "vue-i18n";
import ProjectList from "./components/ProjectList.vue";
import PoolList from "./components/PoolList.vue";
@@ -13,6 +14,29 @@ const { locale } = useI18n();
onMounted(async () => {
autostart.value = await isEnabled();
// macOS nutzt die native Ampel (Overlay); sonst eigene Fensterknöpfe im Header
// (Linux ist dekorationslos, siehe lib.rs).
const isMac = /Mac|Macintosh/.test(navigator.userAgent);
document.documentElement.dataset.platform = isMac ? "mac" : "other";
if (!isMac) {
const win = getCurrentWebviewWindow();
document
.getElementById("win-min")
?.addEventListener("click", () => win.minimize());
document
.getElementById("win-max")
?.addEventListener("click", () => win.toggleMaximize());
document
.getElementById("win-close")
?.addEventListener("click", () => win.close());
for (const g of document.querySelectorAll<HTMLElement>(".grip")) {
g.addEventListener("mousedown", (e) => {
e.preventDefault();
win.startResizeDragging(g.dataset.dir as never);
});
}
}
});
async function toggleAutostart() {
@@ -26,8 +50,8 @@ async function toggleAutostart() {
</script>
<template>
<header>
<h1>ai-control</h1>
<header data-tauri-drag-region>
<h1 data-tauri-drag-region>ai-control</h1>
<nav>
<button :class="{ active: tab === 'projects' }" @click="tab = 'projects'">
{{ $t("app.projects") }}
@@ -49,10 +73,146 @@ async function toggleAutostart() {
{{ $t("app.autostart") }}
</label>
</div>
<div id="winbtns">
<button class="winbtn" id="win-min" aria-label="Minimieren">
<svg width="12" height="12" viewBox="0 0 12 12"><line x1="2" y1="6.5" x2="10" y2="6.5" /></svg>
</button>
<button class="winbtn" id="win-max" aria-label="Maximieren">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><rect x="2.5" y="2.5" width="7" height="7" rx="1" /></svg>
</button>
<button class="winbtn close" id="win-close" aria-label="Schließen">
<svg width="12" height="12" viewBox="0 0 12 12"><line x1="2.7" y1="2.7" x2="9.3" y2="9.3" /><line x1="9.3" y1="2.7" x2="2.7" y2="9.3" /></svg>
</button>
</div>
</header>
<main>
<ProjectList v-if="tab === 'projects'" />
<PoolList v-else-if="tab === 'pools'" />
<UsageList v-else />
</main>
<div id="resize-grips" aria-hidden="true">
<div class="grip n" data-dir="North"></div>
<div class="grip s" data-dir="South"></div>
<div class="grip w" data-dir="West"></div>
<div class="grip e" data-dir="East"></div>
<div class="grip nw" data-dir="NorthWest"></div>
<div class="grip ne" data-dir="NorthEast"></div>
<div class="grip sw" data-dir="SouthWest"></div>
<div class="grip se" data-dir="SouthEast"></div>
</div>
</template>
<style>
/* macOS: Platz links für die Ampel, eigene Fensterknöpfe aus. */
:root[data-platform="mac"] header {
padding-left: 80px;
}
:root[data-platform="mac"] #winbtns {
display: none;
}
/* Linux/Windows: Fensterknöpfe rechtsbündig bis zum Rand. */
:root[data-platform="other"] header {
padding-right: 0;
}
#winbtns {
display: flex;
align-self: stretch;
margin: -0.85rem 0 -0.85rem 0.5rem;
}
.winbtn {
width: 46px;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 0;
background: transparent;
color: var(--subtext);
cursor: default;
transition: background 0.12s ease;
}
.winbtn svg {
stroke: currentColor;
stroke-width: 1.2;
opacity: 0.85;
}
.winbtn:hover {
background: var(--surface0);
color: var(--text);
}
.winbtn.close:hover {
background: #f38ba8;
color: var(--crust);
}
/* Resize-Zonen für das dekorationslose Fenster (nur Linux/Windows). */
#resize-grips {
display: none;
}
:root[data-platform="other"] #resize-grips {
display: block;
}
.grip {
position: fixed;
z-index: 100;
}
.grip.n {
top: 0;
left: 0;
right: 0;
height: 4px;
cursor: ns-resize;
}
.grip.s {
bottom: 0;
left: 0;
right: 0;
height: 4px;
cursor: ns-resize;
}
.grip.w {
top: 0;
bottom: 0;
left: 0;
width: 4px;
cursor: ew-resize;
}
.grip.e {
top: 0;
bottom: 0;
right: 0;
width: 4px;
cursor: ew-resize;
}
.grip.nw {
top: 0;
left: 0;
width: 9px;
height: 9px;
cursor: nwse-resize;
z-index: 101;
}
.grip.ne {
top: 0;
right: 0;
width: 9px;
height: 9px;
cursor: nesw-resize;
z-index: 101;
}
.grip.sw {
bottom: 0;
left: 0;
width: 9px;
height: 9px;
cursor: nesw-resize;
z-index: 101;
}
.grip.se {
bottom: 0;
right: 0;
width: 9px;
height: 9px;
cursor: nwse-resize;
z-index: 101;
}
</style>
+182
View File
@@ -0,0 +1,182 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { LogicalSize } from "@tauri-apps/api/dpi";
interface Project {
name: string;
running: boolean;
terminal: { theme: string | null; icon: string | null; title: string | null };
}
const WIDTH = 300;
const projects = ref<Project[]>([]);
const icons = ref<Record<string, string>>({});
const wrapRef = ref<HTMLElement>();
const win = getCurrentWebviewWindow();
async function refresh() {
try {
projects.value = await invoke<Project[]>("list_projects");
for (const p of projects.value) {
if (p.terminal.icon && !(p.name in icons.value)) {
icons.value[p.name] =
(await invoke<string | null>("project_icon", { project: p.name })) ?? "";
}
}
} catch (e) {
console.error(e);
}
}
async function pick(p: Project) {
await invoke("start_or_focus_cmd", { project: p.name });
await win.hide();
}
async function openMain() {
await invoke("open_main_window");
await win.hide();
}
async function quit() {
await invoke("quit_app");
}
// Fenster nur so hoch wie der Inhalt: bei jeder Layout-Änderung nachziehen.
let ro: ResizeObserver | undefined;
let timer: number;
onMounted(() => {
refresh();
timer = window.setInterval(refresh, 2000);
if (wrapRef.value) {
ro = new ResizeObserver(() => {
const h = Math.ceil(wrapRef.value!.getBoundingClientRect().height);
if (h > 0) win.setSize(new LogicalSize(WIDTH, h));
});
ro.observe(wrapRef.value);
}
});
onUnmounted(() => {
window.clearInterval(timer);
ro?.disconnect();
});
</script>
<template>
<div ref="wrapRef" class="wrap">
<ul class="list">
<li v-for="p in projects" :key="p.name" class="row" @click="pick(p)">
<span class="dot" :class="{ on: p.running }"></span>
<img v-if="icons[p.name]" class="ic" :src="icons[p.name]" />
<span v-else class="ic ph"></span>
<span class="nm">{{ p.name }}</span>
</li>
<li v-if="projects.length === 0" class="empty">Keine Projekte</li>
</ul>
<footer class="ft">
<button class="fbtn" @click="openMain">Öffnen</button>
<button class="fbtn danger" @click="quit">Beenden</button>
</footer>
</div>
</template>
<style scoped>
.wrap {
width: 300px;
box-sizing: border-box;
background: #1e1e2e;
color: #cdd6f4;
border: 1px solid #313244;
border-radius: 12px;
overflow: hidden;
font:
14px/1.3 system-ui,
-apple-system,
sans-serif;
}
.list {
list-style: none;
margin: 0;
padding: 5px;
}
.row {
display: flex;
align-items: center;
gap: 11px;
padding: 7px 9px;
border-radius: 9px;
cursor: pointer;
transition: background 0.12s ease;
}
.row:hover {
background: #313244;
}
.dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: #585b70;
flex: none;
}
.dot.on {
background: #a6e3a1;
box-shadow: 0 0 7px #a6e3a1a0;
}
.ic {
width: 34px;
height: 34px;
border-radius: 8px;
flex: none;
object-fit: cover;
}
.ic.ph {
background: #313244;
}
.nm {
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.empty {
padding: 20px;
text-align: center;
color: #585b70;
}
.ft {
display: flex;
gap: 6px;
padding: 5px 6px 6px;
border-top: 1px solid #313244;
}
.fbtn {
flex: 1;
padding: 8px;
border: none;
border-radius: 8px;
background: #313244;
color: #cdd6f4;
font: inherit;
font-weight: 500;
cursor: pointer;
transition: background 0.12s ease;
}
.fbtn:hover {
background: #45475a;
}
.fbtn.danger {
color: #f38ba8;
}
.fbtn.danger:hover {
background: #45303a;
}
</style>
+4
View File
@@ -0,0 +1,4 @@
import { createApp } from "vue";
import Popup from "./components/Popup.vue";
createApp(Popup).mount("#app");
+52 -3
View File
@@ -29,6 +29,29 @@ window.addEventListener("unhandledrejection", (e) =>
const project = new URLSearchParams(location.search).get("project")!;
const win = getCurrentWebviewWindow();
// macOS nutzt die native Ampel (Overlay-Titelbar); sonst eigene Fensterknöpfe
// im Header (Linux ist dekorationslos, siehe terminal.rs).
const isMac = /Mac|Macintosh/.test(navigator.userAgent);
document.documentElement.dataset.platform = isMac ? "mac" : "other";
if (!isMac) {
document
.getElementById("win-min")
?.addEventListener("click", () => win.minimize());
document
.getElementById("win-max")
?.addEventListener("click", () => win.toggleMaximize());
document
.getElementById("win-close")
?.addEventListener("click", () => win.close());
// Resize-Zonen: dekorationslose Fenster haben keinen nativen Rand.
for (const g of document.querySelectorAll<HTMLElement>(".grip")) {
g.addEventListener("mousedown", (e) => {
e.preventDefault();
win.startResizeDragging(g.dataset.dir as never);
});
}
}
// Fensterhintergrund beim Öffnen kommt aus terminal.rs (theme_background);
// dort nicht gepflegte Themes fallen auf Mocha zurück (nur kurzer Moment
// bis die Webview lädt).
@@ -427,11 +450,15 @@ header.style.background = picked.header;
header.style.borderBottomColor = picked.border;
header.style.color = theme.foreground;
// Globale Schriftgröße (settings.json: terminalFontSize), ein Wert für alle.
const DEFAULT_FONT_SIZE = 13;
let fontSize = await invoke<number>("terminal_font_size");
const term = new Terminal({
// Ligatur-Addon nutzt die Character-Joiner-API (Proposed API)
allowProposedApi: true,
fontFamily: '"JetBrains Mono", Menlo, monospace',
fontSize: 13,
fontSize,
fontWeightBold: "600",
lineHeight: 1.0,
letterSpacing: 0,
@@ -443,6 +470,14 @@ const term = new Terminal({
const fit = new FitAddon();
term.loadAddon(fit);
// Ctrl/Cmd + +/-/0: Schriftgröße global anpassen, sofort im aktuellen Fenster.
function applyFontSize(px: number) {
fontSize = Math.max(8, Math.min(24, px));
term.options.fontSize = fontSize;
fit.fit();
invoke("set_terminal_font_size", { size: fontSize });
}
// Shift+Enter als CSI-u-Sequenz senden (Zeilenumbruch in Claude Code,
// auch bei nicht-leerer Zeile). Neben keydown muss auch keypress unter-
// drückt werden, sonst sendet xterm.js zusätzlich \r und submittet damit.
@@ -451,13 +486,27 @@ term.attachCustomKeyEventHandler((e) => {
if (e.type === "keydown") invoke("term_write", { data: "\x1b[13;2u" });
return false;
}
if ((e.ctrlKey || e.metaKey) && !e.altKey) {
if (e.key === "+" || e.key === "=") {
if (e.type === "keydown") applyFontSize(fontSize + 1);
return false;
}
if (e.key === "-" || e.key === "_") {
if (e.type === "keydown") applyFontSize(fontSize - 1);
return false;
}
if (e.key === "0") {
if (e.type === "keydown") applyFontSize(DEFAULT_FONT_SIZE);
return false;
}
}
return true;
});
// Font muss geladen sein, bevor WebGL den Glyphen-Atlas aufbaut —
// beide verwendeten Gewichte (400 normal, 600 bold).
await document.fonts.load('13px "JetBrains Mono"');
await document.fonts.load('600 13px "JetBrains Mono"');
await document.fonts.load(`${fontSize}px "JetBrains Mono"`);
await document.fonts.load(`600 ${fontSize}px "JetBrains Mono"`);
term.open(document.getElementById("term")!);
// Reihenfolge laut Addon-Doku: Ligaturen vor WebGL aktivieren.
term.loadAddon(new LigaturesAddon());
+139 -3
View File
@@ -16,13 +16,13 @@
flex-direction: column;
}
header {
height: 44px;
height: 40px;
flex: none;
display: flex;
align-items: center;
gap: 10px;
/* links Platz für die macOS-Ampel (Overlay-Titelbar) */
padding: 0 16px 0 84px;
/* Linux: schmaler Rand links; macOS: Ampel-Platz (Override unten) */
padding: 0 0 0 16px;
background: #181825;
border-bottom: 1px solid #313244;
color: #cdd6f4;
@@ -34,6 +34,14 @@
-webkit-user-select: none;
cursor: default;
}
/* macOS: Platz links für die Ampel, eigene Fensterknöpfe aus. */
:root[data-platform="mac"] header {
padding-left: 84px;
padding-right: 16px;
}
:root[data-platform="mac"] #winbtns {
display: none;
}
#project-icon {
width: 18px;
height: 18px;
@@ -53,6 +61,113 @@
#pool:empty {
display: none;
}
/* Eigene Fensterknöpfe (Linux): rechtsbündig, volle Header-Höhe. */
#winbtns {
margin-left: 8px;
display: flex;
align-self: stretch;
}
#pool:empty + #winbtns {
margin-left: auto;
}
.winbtn {
width: 44px;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: inherit;
cursor: default;
transition: background 0.12s ease;
}
.winbtn svg {
stroke: currentColor;
stroke-width: 1.2;
opacity: 0.8;
}
.winbtn:hover {
background: #313244;
}
.winbtn:hover svg {
opacity: 1;
}
.winbtn.close:hover {
background: #f38ba8;
color: #11111b;
}
/* Resize-Zonen für das dekorationslose Fenster (nur Linux). */
#resize-grips {
display: none;
}
:root[data-platform="other"] #resize-grips {
display: block;
}
.grip {
position: fixed;
z-index: 100;
}
.grip.n {
top: 0;
left: 0;
right: 0;
height: 4px;
cursor: ns-resize;
}
.grip.s {
bottom: 0;
left: 0;
right: 0;
height: 4px;
cursor: ns-resize;
}
.grip.w {
top: 0;
bottom: 0;
left: 0;
width: 4px;
cursor: ew-resize;
}
.grip.e {
top: 0;
bottom: 0;
right: 0;
width: 4px;
cursor: ew-resize;
}
.grip.nw {
top: 0;
left: 0;
width: 9px;
height: 9px;
cursor: nwse-resize;
z-index: 101;
}
.grip.ne {
top: 0;
right: 0;
width: 9px;
height: 9px;
cursor: nesw-resize;
z-index: 101;
}
.grip.sw {
bottom: 0;
left: 0;
width: 9px;
height: 9px;
cursor: nesw-resize;
z-index: 101;
}
.grip.se {
bottom: 0;
right: 0;
width: 9px;
height: 9px;
cursor: nwse-resize;
z-index: 101;
}
#term {
flex: 1;
min-height: 0;
@@ -75,8 +190,29 @@
<img id="project-icon" hidden data-tauri-drag-region />
<span id="project-name" data-tauri-drag-region></span>
<span id="pool"></span>
<div id="winbtns">
<button class="winbtn" id="win-min" aria-label="Minimieren">
<svg width="12" height="12" viewBox="0 0 12 12"><line x1="2" y1="6.5" x2="10" y2="6.5" /></svg>
</button>
<button class="winbtn" id="win-max" aria-label="Maximieren">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><rect x="2.5" y="2.5" width="7" height="7" rx="1" /></svg>
</button>
<button class="winbtn close" id="win-close" aria-label="Schließen">
<svg width="12" height="12" viewBox="0 0 12 12"><line x1="2.7" y1="2.7" x2="9.3" y2="9.3" /><line x1="9.3" y1="2.7" x2="2.7" y2="9.3" /></svg>
</button>
</div>
</header>
<div id="term"></div>
<div id="resize-grips" aria-hidden="true">
<div class="grip n" data-dir="North"></div>
<div class="grip s" data-dir="South"></div>
<div class="grip w" data-dir="West"></div>
<div class="grip e" data-dir="East"></div>
<div class="grip nw" data-dir="NorthWest"></div>
<div class="grip ne" data-dir="NorthEast"></div>
<div class="grip sw" data-dir="SouthWest"></div>
<div class="grip se" data-dir="SouthEast"></div>
</div>
<script type="module" src="/src/terminal.ts"></script>
</body>
</html>
+1
View File
@@ -17,6 +17,7 @@ export default defineConfig(async () => ({
input: {
main: new URL("./index.html", import.meta.url).pathname,
terminal: new URL("./terminal.html", import.meta.url).pathname,
popup: new URL("./popup.html", import.meta.url).pathname,
},
},
},