beta-0.8
This commit is contained in:
Generated
+10
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.11.1",
|
"@tauri-apps/api": "^2.11.1",
|
||||||
|
"@tauri-apps/plugin-autostart": "^2.5.1",
|
||||||
"@tauri-apps/plugin-opener": "^2.5.4",
|
"@tauri-apps/plugin-opener": "^2.5.4",
|
||||||
"vue": "^3.5.39"
|
"vue": "^3.5.39"
|
||||||
},
|
},
|
||||||
@@ -661,6 +662,15 @@
|
|||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tauri-apps/plugin-autostart": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-autostart/-/plugin-autostart-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-zS/xx7yzveCcotkA+8TqkI2lysmG2wvQXv2HGAVExITmnFfHAdj1arGsbbfs3o6EktRHf6l34pJxc3YGG2mg7w==",
|
||||||
|
"license": "MIT OR Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tauri-apps/plugin-opener": {
|
"node_modules/@tauri-apps/plugin-opener": {
|
||||||
"version": "2.5.4",
|
"version": "2.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.11.1",
|
"@tauri-apps/api": "^2.11.1",
|
||||||
|
"@tauri-apps/plugin-autostart": "^2.5.1",
|
||||||
"@tauri-apps/plugin-opener": "^2.5.4",
|
"@tauri-apps/plugin-opener": "^2.5.4",
|
||||||
"vue": "^3.5.39"
|
"vue": "^3.5.39"
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+189
-7
@@ -79,12 +79,17 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
|
|||||||
name = "app"
|
name = "app"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
|
"getrandom 0.2.17",
|
||||||
"log",
|
"log",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
|
"tauri-plugin-autostart",
|
||||||
"tauri-plugin-log",
|
"tauri-plugin-log",
|
||||||
|
"ureq",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -122,6 +127,17 @@ version = "1.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "auto-launch"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1f012b8cc0c850f34117ec8252a44418f2e34a2cf501de89e29b241ae5f79471"
|
||||||
|
dependencies = [
|
||||||
|
"dirs 4.0.0",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
"winreg 0.10.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "autocfg"
|
name = "autocfg"
|
||||||
version = "1.5.1"
|
version = "1.5.1"
|
||||||
@@ -670,13 +686,33 @@ dependencies = [
|
|||||||
"crypto-common",
|
"crypto-common",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dirs"
|
||||||
|
version = "4.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059"
|
||||||
|
dependencies = [
|
||||||
|
"dirs-sys 0.3.7",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dirs"
|
name = "dirs"
|
||||||
version = "6.0.0"
|
version = "6.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
|
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dirs-sys",
|
"dirs-sys 0.5.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dirs-sys"
|
||||||
|
version = "0.3.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"redox_users 0.4.6",
|
||||||
|
"winapi",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -687,7 +723,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"option-ext",
|
"option-ext",
|
||||||
"redox_users",
|
"redox_users 0.5.2",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -814,7 +850,7 @@ dependencies = [
|
|||||||
"rustc_version",
|
"rustc_version",
|
||||||
"toml 1.1.2+spec-1.1.0",
|
"toml 1.1.2+spec-1.1.0",
|
||||||
"vswhom",
|
"vswhom",
|
||||||
"winreg",
|
"winreg 0.55.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2529,6 +2565,17 @@ dependencies = [
|
|||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "redox_users"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
"libredox",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_users"
|
name = "redox_users"
|
||||||
version = "0.5.2"
|
version = "0.5.2"
|
||||||
@@ -2632,6 +2679,20 @@ dependencies = [
|
|||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[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]]
|
[[package]]
|
||||||
name = "rkyv"
|
name = "rkyv"
|
||||||
version = "0.7.46"
|
version = "0.7.46"
|
||||||
@@ -2693,6 +2754,41 @@ dependencies = [
|
|||||||
"semver",
|
"semver",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls"
|
||||||
|
version = "0.23.41"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"once_cell",
|
||||||
|
"ring",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"rustls-webpki",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-pki-types"
|
||||||
|
version = "1.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
|
||||||
|
dependencies = [
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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]]
|
[[package]]
|
||||||
name = "rustversion"
|
name = "rustversion"
|
||||||
version = "1.0.22"
|
version = "1.0.22"
|
||||||
@@ -3099,6 +3195,12 @@ version = "0.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "subtle"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "swift-rs"
|
name = "swift-rs"
|
||||||
version = "1.0.7"
|
version = "1.0.7"
|
||||||
@@ -3237,7 +3339,7 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
"cookie",
|
"cookie",
|
||||||
"dirs",
|
"dirs 6.0.0",
|
||||||
"dunce",
|
"dunce",
|
||||||
"embed_plist",
|
"embed_plist",
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
@@ -3287,7 +3389,7 @@ checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"cargo_toml",
|
"cargo_toml",
|
||||||
"dirs",
|
"dirs 6.0.0",
|
||||||
"glob",
|
"glob",
|
||||||
"heck 0.5.0",
|
"heck 0.5.0",
|
||||||
"json-patch",
|
"json-patch",
|
||||||
@@ -3357,6 +3459,20 @@ dependencies = [
|
|||||||
"walkdir",
|
"walkdir",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-autostart"
|
||||||
|
version = "2.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "459383cebc193cdd03d1ba4acc40f2c408a7abce419d64bdcd2d745bc2886f70"
|
||||||
|
dependencies = [
|
||||||
|
"auto-launch",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-log"
|
name = "tauri-plugin-log"
|
||||||
version = "2.8.0"
|
version = "2.8.0"
|
||||||
@@ -3804,7 +3920,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc"
|
checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"dirs",
|
"dirs 6.0.0",
|
||||||
"libappindicator",
|
"libappindicator",
|
||||||
"muda",
|
"muda",
|
||||||
"objc2",
|
"objc2",
|
||||||
@@ -3890,6 +4006,30 @@ version = "1.13.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "untrusted"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ureq"
|
||||||
|
version = "2.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
|
"flate2",
|
||||||
|
"log",
|
||||||
|
"once_cell",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"url",
|
||||||
|
"webpki-roots 0.26.11",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "url"
|
name = "url"
|
||||||
version = "2.5.8"
|
version = "2.5.8"
|
||||||
@@ -4151,6 +4291,24 @@ dependencies = [
|
|||||||
"system-deps",
|
"system-deps",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webpki-roots"
|
||||||
|
version = "0.26.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||||
|
dependencies = [
|
||||||
|
"webpki-roots 1.0.8",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webpki-roots"
|
||||||
|
version = "1.0.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
|
||||||
|
dependencies = [
|
||||||
|
"rustls-pki-types",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webview2-com"
|
name = "webview2-com"
|
||||||
version = "0.38.2"
|
version = "0.38.2"
|
||||||
@@ -4381,6 +4539,15 @@ dependencies = [
|
|||||||
"windows-targets 0.42.2",
|
"windows-targets 0.42.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[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]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.59.0"
|
version = "0.59.0"
|
||||||
@@ -4562,6 +4729,15 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winreg"
|
||||||
|
version = "0.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
|
||||||
|
dependencies = [
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winreg"
|
name = "winreg"
|
||||||
version = "0.55.0"
|
version = "0.55.0"
|
||||||
@@ -4594,7 +4770,7 @@ dependencies = [
|
|||||||
"block2",
|
"block2",
|
||||||
"cookie",
|
"cookie",
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"dirs",
|
"dirs 6.0.0",
|
||||||
"dom_query",
|
"dom_query",
|
||||||
"dpi",
|
"dpi",
|
||||||
"dunce",
|
"dunce",
|
||||||
@@ -4722,6 +4898,12 @@ dependencies = [
|
|||||||
"synstructure",
|
"synstructure",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zeroize"
|
||||||
|
version = "1.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerotrie"
|
name = "zerotrie"
|
||||||
version = "0.2.4"
|
version = "0.2.4"
|
||||||
|
|||||||
@@ -21,5 +21,10 @@ tauri-build = { version = "2.6.3", features = [] }
|
|||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
tauri = { version = "2.11.3", features = [] }
|
tauri = { version = "2.11.3", features = ["tray-icon"] }
|
||||||
tauri-plugin-log = "2"
|
tauri-plugin-log = "2"
|
||||||
|
tauri-plugin-autostart = "2"
|
||||||
|
ureq = { version = "2", features = ["json"] }
|
||||||
|
base64 = "0.22"
|
||||||
|
sha2 = "0.10"
|
||||||
|
getrandom = "0.2"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"main"
|
"main"
|
||||||
],
|
],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default"
|
"core:default",
|
||||||
|
"autostart:default"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,636 @@
|
|||||||
|
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::fs;
|
||||||
|
use std::io::{BufRead, BufReader, Write};
|
||||||
|
use std::net::TcpListener;
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Command;
|
||||||
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
const CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
||||||
|
const AUTH_URL: &str = "https://claude.ai/oauth/authorize";
|
||||||
|
const TOKEN_URL: &str = "https://console.anthropic.com/v1/oauth/token";
|
||||||
|
const SCOPES: &str = "org:create_api_key user:profile user:inference";
|
||||||
|
|
||||||
|
/// Feste Dateinamen im Pool-Ordner. Der Pool-Ordner selbst ist das
|
||||||
|
/// CLAUDE_CONFIG_DIR; claude liest die .credentials.json dort direkt.
|
||||||
|
const CREDENTIALS_FILE: &str = ".credentials.json";
|
||||||
|
const APIKEY_FILE: &str = "apikey";
|
||||||
|
const POOL_FILE: &str = "pool.json";
|
||||||
|
const PROJECT_FILE: &str = "ai-control.json";
|
||||||
|
|
||||||
|
/// Wurzelpfade; in Tests mit temporärem home instanziierbar.
|
||||||
|
struct Paths {
|
||||||
|
home: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Paths {
|
||||||
|
fn real() -> Self {
|
||||||
|
Paths {
|
||||||
|
home: PathBuf::from(std::env::var("HOME").expect("HOME nicht gesetzt")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn projects_dir(&self) -> PathBuf {
|
||||||
|
self.home.join("claude-projects")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pools_dir(&self) -> PathBuf {
|
||||||
|
self.home.join(".config").join("ai-control").join("pools")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pool_dir(&self, pool: &str) -> PathBuf {
|
||||||
|
self.pools_dir().join(pool)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn project_config(&self, project: &str) -> PathBuf {
|
||||||
|
self.projects_dir().join(project).join(PROJECT_FILE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct Project {
|
||||||
|
name: String,
|
||||||
|
path: String,
|
||||||
|
pool: Option<String>,
|
||||||
|
running: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct Pool {
|
||||||
|
name: String,
|
||||||
|
#[serde(rename = "credentialType")]
|
||||||
|
credential_type: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct ProjectConfig {
|
||||||
|
pool: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bundle_id(project: &str) -> String {
|
||||||
|
format!("com.mitchellh.ghostty.{project}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_running(project: &str) -> bool {
|
||||||
|
let out = Command::new("lsappinfo")
|
||||||
|
.args(["find", &format!("bundleid={}", bundle_id(project))])
|
||||||
|
.output();
|
||||||
|
match out {
|
||||||
|
Ok(o) => !String::from_utf8_lossy(&o.stdout).trim().is_empty(),
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_name(name: &str) -> Result<(), String> {
|
||||||
|
if name.trim().is_empty() || name.contains('/') || name.contains("..") {
|
||||||
|
return Err(format!("ungültiger Name: {name}"));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_pool(paths: &Paths, pool: &str) -> Result<Pool, String> {
|
||||||
|
let cfg_path = paths.pool_dir(pool).join(POOL_FILE);
|
||||||
|
let raw = fs::read_to_string(&cfg_path)
|
||||||
|
.map_err(|e| format!("{}: {e}", cfg_path.display()))?;
|
||||||
|
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", cfg_path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Projekte ----------
|
||||||
|
|
||||||
|
fn list_projects_in(paths: &Paths) -> Result<Vec<Project>, String> {
|
||||||
|
let mut projects = Vec::new();
|
||||||
|
let entries = fs::read_dir(paths.projects_dir()).map_err(|e| e.to_string())?;
|
||||||
|
for entry in entries {
|
||||||
|
let entry = entry.map_err(|e| e.to_string())?;
|
||||||
|
let path = entry.path();
|
||||||
|
if !path.join(".claude").is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let name = entry.file_name().to_string_lossy().into_owned();
|
||||||
|
let cfg_path = path.join(PROJECT_FILE);
|
||||||
|
let pool = if cfg_path.is_file() {
|
||||||
|
let raw = fs::read_to_string(&cfg_path).map_err(|e| e.to_string())?;
|
||||||
|
let cfg: ProjectConfig = serde_json::from_str(&raw)
|
||||||
|
.map_err(|e| format!("{}: {e}", cfg_path.display()))?;
|
||||||
|
Some(cfg.pool)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
projects.push(Project {
|
||||||
|
running: is_running(&name),
|
||||||
|
path: path.to_string_lossy().into_owned(),
|
||||||
|
name,
|
||||||
|
pool,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
projects.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
Ok(projects)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_project_in(paths: &Paths, name: &str) -> Result<(), String> {
|
||||||
|
check_name(name)?;
|
||||||
|
let dir = paths.projects_dir().join(name);
|
||||||
|
if dir.exists() {
|
||||||
|
return Err(format!("Projekt existiert bereits: {name}"));
|
||||||
|
}
|
||||||
|
fs::create_dir_all(dir.join(".claude")).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_project_in(paths: &Paths, name: &str) -> Result<(), String> {
|
||||||
|
check_name(name)?;
|
||||||
|
let dir = paths.projects_dir().join(name);
|
||||||
|
if !dir.is_dir() {
|
||||||
|
return Err(format!("Projekt nicht gefunden: {name}"));
|
||||||
|
}
|
||||||
|
fs::remove_dir_all(&dir).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assign_pool_in(paths: &Paths, project: &str, pool: &str) -> Result<(), String> {
|
||||||
|
if !paths.pool_dir(pool).join(POOL_FILE).is_file() {
|
||||||
|
return Err(format!("Pool existiert nicht: {pool}"));
|
||||||
|
}
|
||||||
|
let cfg = ProjectConfig { pool: pool.to_string() };
|
||||||
|
let raw = serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string())?;
|
||||||
|
fs::write(paths.project_config(project), raw + "\n").map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unassign_pool_in(paths: &Paths, project: &str) -> Result<(), String> {
|
||||||
|
let cfg_path = paths.project_config(project);
|
||||||
|
fs::remove_file(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Namen aller Projekte, die `pool` zugeordnet haben.
|
||||||
|
fn projects_using_pool(paths: &Paths, pool: &str) -> Result<Vec<String>, String> {
|
||||||
|
let mut users = Vec::new();
|
||||||
|
let entries = fs::read_dir(paths.projects_dir()).map_err(|e| e.to_string())?;
|
||||||
|
for entry in entries {
|
||||||
|
let entry = entry.map_err(|e| e.to_string())?;
|
||||||
|
let cfg_path = entry.path().join(PROJECT_FILE);
|
||||||
|
if !cfg_path.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let raw = fs::read_to_string(&cfg_path).map_err(|e| e.to_string())?;
|
||||||
|
let cfg: ProjectConfig = serde_json::from_str(&raw).map_err(|e| e.to_string())?;
|
||||||
|
if cfg.pool == pool {
|
||||||
|
users.push(entry.file_name().to_string_lossy().into_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
users.sort();
|
||||||
|
Ok(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Pools ----------
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct PoolInfo {
|
||||||
|
name: String,
|
||||||
|
#[serde(rename = "credentialType")]
|
||||||
|
credential_type: String,
|
||||||
|
projects: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_pools_in(paths: &Paths) -> Result<Vec<PoolInfo>, String> {
|
||||||
|
let mut pools = Vec::new();
|
||||||
|
let entries = fs::read_dir(paths.pools_dir()).map_err(|e| e.to_string())?;
|
||||||
|
for entry in entries {
|
||||||
|
let entry = entry.map_err(|e| e.to_string())?;
|
||||||
|
let cfg_path = entry.path().join(POOL_FILE);
|
||||||
|
if !cfg_path.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let raw = fs::read_to_string(&cfg_path).map_err(|e| e.to_string())?;
|
||||||
|
let pool: Pool =
|
||||||
|
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", cfg_path.display()))?;
|
||||||
|
pools.push(PoolInfo {
|
||||||
|
projects: projects_using_pool(paths, &pool.name)?,
|
||||||
|
name: pool.name,
|
||||||
|
credential_type: pool.credential_type,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
pools.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
Ok(pools)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_new_pool(paths: &Paths, name: &str) -> Result<PathBuf, String> {
|
||||||
|
check_name(name)?;
|
||||||
|
let dir = paths.pool_dir(name);
|
||||||
|
if dir.exists() {
|
||||||
|
return Err(format!("Pool existiert bereits: {name}"));
|
||||||
|
}
|
||||||
|
Ok(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_pool_json(dir: &PathBuf, name: &str, credential_type: &str) -> Result<(), String> {
|
||||||
|
fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||||
|
let pool = Pool {
|
||||||
|
name: name.to_string(),
|
||||||
|
credential_type: credential_type.to_string(),
|
||||||
|
};
|
||||||
|
let raw = serde_json::to_string_pretty(&pool).map_err(|e| e.to_string())?;
|
||||||
|
fs::write(dir.join(POOL_FILE), raw + "\n").map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_secret_file(path: &PathBuf, content: &str) -> Result<(), String> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
fs::write(path, content).map_err(|e| e.to_string())?;
|
||||||
|
fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legt einen apikey-Pool an: Key-Datei (0600), settings.json mit
|
||||||
|
/// apiKeyHelper auf diese Datei, pool.json.
|
||||||
|
fn create_apikey_pool_in(paths: &Paths, name: &str, key: &str) -> Result<(), String> {
|
||||||
|
let dir = check_new_pool(paths, name)?;
|
||||||
|
let key = key.trim();
|
||||||
|
if key.is_empty() {
|
||||||
|
return Err("leerer API-Key".into());
|
||||||
|
}
|
||||||
|
let key_path = dir.join(APIKEY_FILE);
|
||||||
|
write_secret_file(&key_path, &format!("{key}\n"))?;
|
||||||
|
let settings = serde_json::json!({
|
||||||
|
"apiKeyHelper": format!("cat '{}'", key_path.display()),
|
||||||
|
});
|
||||||
|
let raw = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?;
|
||||||
|
fs::write(dir.join("settings.json"), raw + "\n").map_err(|e| e.to_string())?;
|
||||||
|
write_pool_json(&dir, name, "apikey")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legt einen oauth-Pool an: erst Login (schreibt .credentials.json in den
|
||||||
|
/// Pool-Ordner), dann – nur bei Erfolg – pool.json.
|
||||||
|
fn create_oauth_pool_in(
|
||||||
|
paths: &Paths,
|
||||||
|
name: &str,
|
||||||
|
login: impl FnOnce(&PathBuf) -> Result<(), String>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let dir = check_new_pool(paths, name)?;
|
||||||
|
login(&dir.join(CREDENTIALS_FILE))?;
|
||||||
|
write_pool_json(&dir, name, "oauth")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Löscht einen Pool samt Ordner (inkl. Credentials); nur wenn kein Projekt ihn nutzt.
|
||||||
|
fn delete_pool_in(paths: &Paths, name: &str) -> Result<(), String> {
|
||||||
|
let dir = paths.pool_dir(name);
|
||||||
|
if !dir.join(POOL_FILE).is_file() {
|
||||||
|
return Err(format!("Pool nicht gefunden: {name}"));
|
||||||
|
}
|
||||||
|
let users = projects_using_pool(paths, name)?;
|
||||||
|
if !users.is_empty() {
|
||||||
|
return Err(format!("Pool ist Projekten zugeordnet: {}", users.join(", ")));
|
||||||
|
}
|
||||||
|
fs::remove_dir_all(&dir).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schreibt den API-Key eines apikey-Pools neu (0600).
|
||||||
|
fn set_apikey_in(paths: &Paths, pool: &str, key: &str) -> Result<(), String> {
|
||||||
|
let p = read_pool(paths, pool)?;
|
||||||
|
if p.credential_type != "apikey" {
|
||||||
|
return Err(format!("Pool {pool} ist kein apikey-Pool"));
|
||||||
|
}
|
||||||
|
let key = key.trim();
|
||||||
|
if key.is_empty() {
|
||||||
|
return Err("leerer API-Key".into());
|
||||||
|
}
|
||||||
|
write_secret_file(&paths.pool_dir(pool).join(APIKEY_FILE), &format!("{key}\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn oauth_credential_path(paths: &Paths, pool: &str) -> Result<PathBuf, String> {
|
||||||
|
let p = read_pool(paths, pool)?;
|
||||||
|
if p.credential_type != "oauth" {
|
||||||
|
return Err(format!("Pool {pool} ist kein oauth-Pool"));
|
||||||
|
}
|
||||||
|
Ok(paths.pool_dir(pool).join(CREDENTIALS_FILE))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Tauri-Commands ----------
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn list_projects() -> Result<Vec<Project>, String> {
|
||||||
|
list_projects_in(&Paths::real())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn create_project(name: String) -> Result<(), String> {
|
||||||
|
create_project_in(&Paths::real(), &name)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn delete_project(name: String) -> Result<(), String> {
|
||||||
|
delete_project_in(&Paths::real(), &name)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn list_pools() -> Result<Vec<PoolInfo>, String> {
|
||||||
|
list_pools_in(&Paths::real())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn create_oauth_pool(name: String) -> Result<(), String> {
|
||||||
|
create_oauth_pool_in(&Paths::real(), &name, run_oauth_flow)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn create_apikey_pool(name: String, key: String) -> Result<(), String> {
|
||||||
|
create_apikey_pool_in(&Paths::real(), &name, &key)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn delete_pool(name: String) -> Result<(), String> {
|
||||||
|
delete_pool_in(&Paths::real(), &name)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn assign_pool(project: String, pool: String) -> Result<(), String> {
|
||||||
|
assign_pool_in(&Paths::real(), &project, &pool)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn unassign_pool(project: String) -> Result<(), String> {
|
||||||
|
unassign_pool_in(&Paths::real(), &project)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn set_apikey(pool: String, key: String) -> Result<(), String> {
|
||||||
|
set_apikey_in(&Paths::real(), &pool, &key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Meldet einen bestehenden oauth-Pool neu an.
|
||||||
|
#[tauri::command]
|
||||||
|
fn oauth_login(pool: String) -> Result<(), String> {
|
||||||
|
run_oauth_flow(&oauth_credential_path(&Paths::real(), &pool)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Erneuert das Access-Token über das gespeicherte Refresh-Token.
|
||||||
|
#[tauri::command]
|
||||||
|
fn oauth_refresh(pool: String) -> Result<(), String> {
|
||||||
|
let cred_path = oauth_credential_path(&Paths::real(), &pool)?;
|
||||||
|
let raw = fs::read_to_string(&cred_path)
|
||||||
|
.map_err(|e| format!("{}: {e}", cred_path.display()))?;
|
||||||
|
let v: serde_json::Value = serde_json::from_str(&raw).map_err(|e| e.to_string())?;
|
||||||
|
let refresh = v["claudeAiOauth"]["refreshToken"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or("kein refreshToken in Credential-Datei")?;
|
||||||
|
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"grant_type": "refresh_token",
|
||||||
|
"refresh_token": refresh,
|
||||||
|
"client_id": CLIENT_ID,
|
||||||
|
});
|
||||||
|
exchange_and_store(body, &cred_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_project(project: &str) -> Result<(), String> {
|
||||||
|
// Läuft die UI selbst in einer Ghostty-App, erbt `open` deren
|
||||||
|
// XDG_CONFIG_HOME und kalt gestartete Apps läsen die falsche Config.
|
||||||
|
let out = Command::new("open")
|
||||||
|
.env_remove("XDG_CONFIG_HOME")
|
||||||
|
.args(["-b", &bundle_id(project)])
|
||||||
|
.output()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if out.status.success() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn start_project(project: String) -> Result<(), String> {
|
||||||
|
open_project(&project)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Beendet die Ghostty-App des Projekts (regulärer Quit, Ghostty darf noch
|
||||||
|
/// nachfragen), wartet auf das Prozessende und startet sie neu.
|
||||||
|
#[tauri::command]
|
||||||
|
fn restart_project(project: String) -> Result<(), String> {
|
||||||
|
let out = Command::new("osascript")
|
||||||
|
.args(["-e", &format!("quit app id \"{}\"", bundle_id(&project))])
|
||||||
|
.output()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if !out.status.success() {
|
||||||
|
return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
|
||||||
|
}
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(30);
|
||||||
|
while is_running(&project) {
|
||||||
|
if Instant::now() > deadline {
|
||||||
|
return Err(format!("{project} hat sich nach 30 s nicht beendet"));
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(250));
|
||||||
|
}
|
||||||
|
open_project(&project)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- OAuth-Flow ----------
|
||||||
|
|
||||||
|
fn pct(s: &str) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
for b in s.bytes() {
|
||||||
|
match b {
|
||||||
|
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||||
|
out.push(b as char)
|
||||||
|
}
|
||||||
|
_ => out.push_str(&format!("%{:02X}", b)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn random_urlsafe(len: usize) -> String {
|
||||||
|
let mut buf = vec![0u8; len];
|
||||||
|
getrandom::getrandom(&mut buf).expect("getrandom");
|
||||||
|
URL_SAFE_NO_PAD.encode(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_ms() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.expect("Systemzeit")
|
||||||
|
.as_millis() as u64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schreibt die Credential-Datei im Claude-Code-Format (0600).
|
||||||
|
fn write_credentials_file(
|
||||||
|
cred_path: &PathBuf,
|
||||||
|
creds: &serde_json::Value,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let raw = serde_json::to_string_pretty(creds).map_err(|e| e.to_string())?;
|
||||||
|
write_secret_file(cred_path, &(raw + "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tauscht Code/Refresh-Token gegen Tokens und schreibt die Credential-Datei.
|
||||||
|
fn exchange_and_store(
|
||||||
|
body: serde_json::Value,
|
||||||
|
cred_path: &PathBuf,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let resp = ureq::post(TOKEN_URL)
|
||||||
|
.set("Content-Type", "application/json")
|
||||||
|
.send_json(body);
|
||||||
|
let resp = match resp {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(ureq::Error::Status(code, r)) => {
|
||||||
|
let txt = r.into_string().unwrap_or_default();
|
||||||
|
return Err(format!("Token-Endpoint {code}: {txt}"));
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e.to_string()),
|
||||||
|
};
|
||||||
|
let v: serde_json::Value = resp.into_json().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let access = v["access_token"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or("access_token fehlt in Antwort")?;
|
||||||
|
let refresh = v["refresh_token"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or("refresh_token fehlt in Antwort")?;
|
||||||
|
let expires_in = v["expires_in"].as_u64().unwrap_or(0);
|
||||||
|
let scopes: Vec<String> = v["scope"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or(SCOPES)
|
||||||
|
.split_whitespace()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let creds = serde_json::json!({
|
||||||
|
"claudeAiOauth": {
|
||||||
|
"accessToken": access,
|
||||||
|
"refreshToken": refresh,
|
||||||
|
"expiresAt": now_ms() + expires_in * 1000,
|
||||||
|
"scopes": scopes,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
write_credentials_file(cred_path, &creds)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Startet den Authorization-Code-Flow mit PKCE, öffnet den Browser und
|
||||||
|
/// nimmt den Redirect auf einem lokalen Loopback-Port entgegen; schreibt
|
||||||
|
/// die Tokens nach `cred_path`.
|
||||||
|
fn run_oauth_flow(cred_path: &PathBuf) -> Result<(), String> {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").map_err(|e| e.to_string())?;
|
||||||
|
listener.set_nonblocking(true).map_err(|e| e.to_string())?;
|
||||||
|
let port = listener.local_addr().map_err(|e| e.to_string())?.port();
|
||||||
|
let redirect_uri = format!("http://localhost:{port}/callback");
|
||||||
|
|
||||||
|
let verifier = random_urlsafe(32);
|
||||||
|
let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));
|
||||||
|
let state = random_urlsafe(32);
|
||||||
|
|
||||||
|
let auth_url = format!(
|
||||||
|
"{AUTH_URL}?code=true&client_id={CLIENT_ID}&response_type=code\
|
||||||
|
&redirect_uri={ru}&scope={sc}&code_challenge={challenge}\
|
||||||
|
&code_challenge_method=S256&state={st}",
|
||||||
|
ru = pct(&redirect_uri),
|
||||||
|
sc = pct(SCOPES),
|
||||||
|
st = pct(&state),
|
||||||
|
);
|
||||||
|
|
||||||
|
Command::new("open")
|
||||||
|
.arg(&auth_url)
|
||||||
|
.status()
|
||||||
|
.map_err(|e| format!("Browser öffnen: {e}"))?;
|
||||||
|
|
||||||
|
let (code, got_state) = wait_for_callback(&listener)?;
|
||||||
|
if got_state != state {
|
||||||
|
return Err("state stimmt nicht überein".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": code,
|
||||||
|
"state": got_state,
|
||||||
|
"client_id": CLIENT_ID,
|
||||||
|
"redirect_uri": redirect_uri,
|
||||||
|
"code_verifier": verifier,
|
||||||
|
});
|
||||||
|
exchange_and_store(body, cred_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pollt den Loopback-Listener bis zum Redirect mit `code`/`state`.
|
||||||
|
fn wait_for_callback(listener: &TcpListener) -> Result<(String, String), String> {
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(180);
|
||||||
|
loop {
|
||||||
|
if Instant::now() > deadline {
|
||||||
|
return Err("Login-Timeout (kein Redirect nach 180 s)".into());
|
||||||
|
}
|
||||||
|
match listener.accept() {
|
||||||
|
Ok((mut stream, _)) => {
|
||||||
|
let mut reader = BufReader::new(&stream);
|
||||||
|
let mut line = String::new();
|
||||||
|
reader.read_line(&mut line).map_err(|e| e.to_string())?;
|
||||||
|
// "GET /callback?code=...&state=... HTTP/1.1"
|
||||||
|
let target = line.split_whitespace().nth(1).unwrap_or("");
|
||||||
|
let query = target.split_once('?').map(|(_, q)| q).unwrap_or("");
|
||||||
|
let mut code = String::new();
|
||||||
|
let mut state = String::new();
|
||||||
|
for pair in query.split('&') {
|
||||||
|
if let Some(v) = pair.strip_prefix("code=") {
|
||||||
|
code = pct_decode(v);
|
||||||
|
} else if let Some(v) = pair.strip_prefix("state=") {
|
||||||
|
state = pct_decode(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let ok = !code.is_empty();
|
||||||
|
let body = if ok {
|
||||||
|
"Anmeldung erfolgreich. Fenster kann geschlossen werden."
|
||||||
|
} else {
|
||||||
|
"Kein Code im Redirect."
|
||||||
|
};
|
||||||
|
let _ = stream.write_all(
|
||||||
|
format!(
|
||||||
|
"HTTP/1.1 200 OK\r\nContent-Type: text/plain; charset=utf-8\r\n\
|
||||||
|
Content-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||||
|
body.len(),
|
||||||
|
body
|
||||||
|
)
|
||||||
|
.as_bytes(),
|
||||||
|
);
|
||||||
|
if ok {
|
||||||
|
return Ok((code, state));
|
||||||
|
}
|
||||||
|
// Nebenanfrage (z. B. favicon) ignorieren und weiter warten.
|
||||||
|
}
|
||||||
|
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||||
|
std::thread::sleep(Duration::from_millis(100));
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pct_decode(s: &str) -> String {
|
||||||
|
let bytes = s.as_bytes();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut i = 0;
|
||||||
|
while i < bytes.len() {
|
||||||
|
match bytes[i] {
|
||||||
|
b'%' if i + 2 < bytes.len() => {
|
||||||
|
let h = u8::from_str_radix(&s[i + 1..i + 3], 16).unwrap_or(b'%');
|
||||||
|
out.push(h);
|
||||||
|
i += 3;
|
||||||
|
}
|
||||||
|
b'+' => {
|
||||||
|
out.push(b' ');
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
c => {
|
||||||
|
out.push(c);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String::from_utf8_lossy(&out).into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_autostart::init(
|
||||||
|
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
|
||||||
|
None,
|
||||||
|
))
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
if cfg!(debug_assertions) {
|
if cfg!(debug_assertions) {
|
||||||
app.handle().plugin(
|
app.handle().plugin(
|
||||||
@@ -9,8 +639,328 @@ pub fn run() {
|
|||||||
.build(),
|
.build(),
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Nur Tray-Icon in der Menüleiste, kein Dock-Eintrag.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
app.set_activation_policy(tauri::ActivationPolicy::Accessory);
|
||||||
|
|
||||||
|
let open = tauri::menu::MenuItem::with_id(app, "open", "Öffnen", true, None::<&str>)?;
|
||||||
|
let quit = tauri::menu::MenuItem::with_id(app, "quit", "Beenden", true, None::<&str>)?;
|
||||||
|
let menu = tauri::menu::Menu::with_items(app, &[&open, &quit])?;
|
||||||
|
tauri::tray::TrayIconBuilder::with_id("main")
|
||||||
|
.icon(app.default_window_icon().unwrap().clone())
|
||||||
|
.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),
|
||||||
|
_ => {}
|
||||||
|
})
|
||||||
|
.build(app)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
|
// Fenster schließen versteckt nur; Beenden geht übers Tray-Menü.
|
||||||
|
.on_window_event(|window, event| {
|
||||||
|
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||||
|
api.prevent_close();
|
||||||
|
window.hide().unwrap();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
list_projects,
|
||||||
|
create_project,
|
||||||
|
delete_project,
|
||||||
|
list_pools,
|
||||||
|
create_oauth_pool,
|
||||||
|
create_apikey_pool,
|
||||||
|
delete_pool,
|
||||||
|
assign_pool,
|
||||||
|
unassign_pool,
|
||||||
|
start_project,
|
||||||
|
restart_project,
|
||||||
|
oauth_login,
|
||||||
|
oauth_refresh,
|
||||||
|
set_apikey
|
||||||
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Tests ----------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
|
|
||||||
|
static N: AtomicU32 = AtomicU32::new(0);
|
||||||
|
|
||||||
|
/// Frisches Pseudo-Home pro Test; claude-projects existiert wie auf dem
|
||||||
|
/// echten System.
|
||||||
|
fn tmp_paths() -> Paths {
|
||||||
|
let dir = std::env::temp_dir().join(format!(
|
||||||
|
"ai-control-test-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
N.fetch_add(1, Ordering::SeqCst)
|
||||||
|
));
|
||||||
|
fs::create_dir_all(dir.join("claude-projects")).unwrap();
|
||||||
|
Paths { home: dir }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dummy_creds() -> serde_json::Value {
|
||||||
|
serde_json::json!({
|
||||||
|
"claudeAiOauth": {
|
||||||
|
"accessToken": "at-test",
|
||||||
|
"refreshToken": "rt-test",
|
||||||
|
"expiresAt": 1_999_999_999_000u64,
|
||||||
|
"scopes": ["user:inference"],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_oauth_pool(p: &Paths, name: &str) {
|
||||||
|
create_oauth_pool_in(p, name, |cred| {
|
||||||
|
write_credentials_file(cred, &dummy_creds())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mode(path: &PathBuf) -> u32 {
|
||||||
|
fs::metadata(path).unwrap().permissions().mode() & 0o777
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Pool anlegen --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apikey_pool_anlegen() {
|
||||||
|
let p = tmp_paths();
|
||||||
|
create_apikey_pool_in(&p, "kunde", "sk-test-123").unwrap();
|
||||||
|
let dir = p.pool_dir("kunde");
|
||||||
|
|
||||||
|
let pool: Pool =
|
||||||
|
serde_json::from_str(&fs::read_to_string(dir.join(POOL_FILE)).unwrap()).unwrap();
|
||||||
|
assert_eq!(pool.name, "kunde");
|
||||||
|
assert_eq!(pool.credential_type, "apikey");
|
||||||
|
|
||||||
|
let key_path = dir.join(APIKEY_FILE);
|
||||||
|
assert_eq!(fs::read_to_string(&key_path).unwrap(), "sk-test-123\n");
|
||||||
|
assert_eq!(mode(&key_path), 0o600);
|
||||||
|
|
||||||
|
let settings: serde_json::Value =
|
||||||
|
serde_json::from_str(&fs::read_to_string(dir.join("settings.json")).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
settings["apiKeyHelper"].as_str().unwrap(),
|
||||||
|
format!("cat '{}'", key_path.display())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn oauth_pool_anlegen() {
|
||||||
|
let p = tmp_paths();
|
||||||
|
make_oauth_pool(&p, "privat");
|
||||||
|
let dir = p.pool_dir("privat");
|
||||||
|
|
||||||
|
let pool: Pool =
|
||||||
|
serde_json::from_str(&fs::read_to_string(dir.join(POOL_FILE)).unwrap()).unwrap();
|
||||||
|
assert_eq!(pool.credential_type, "oauth");
|
||||||
|
|
||||||
|
let cred_path = dir.join(CREDENTIALS_FILE);
|
||||||
|
let creds: serde_json::Value =
|
||||||
|
serde_json::from_str(&fs::read_to_string(&cred_path).unwrap()).unwrap();
|
||||||
|
assert_eq!(creds["claudeAiOauth"]["accessToken"], "at-test");
|
||||||
|
assert_eq!(mode(&cred_path), 0o600);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn oauth_pool_login_fehlschlag_legt_nichts_an() {
|
||||||
|
let p = tmp_paths();
|
||||||
|
let err = create_oauth_pool_in(&p, "privat", |_| Err("Login abgebrochen".into()));
|
||||||
|
assert!(err.is_err());
|
||||||
|
assert!(!p.pool_dir("privat").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pool_doppelt_anlegen_scheitert() {
|
||||||
|
let p = tmp_paths();
|
||||||
|
create_apikey_pool_in(&p, "kunde", "sk-1").unwrap();
|
||||||
|
let err = create_apikey_pool_in(&p, "kunde", "sk-2").unwrap_err();
|
||||||
|
assert!(err.contains("existiert bereits"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pool_leerer_key_scheitert() {
|
||||||
|
let p = tmp_paths();
|
||||||
|
assert!(create_apikey_pool_in(&p, "kunde", " ").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pool_name_mit_slash_scheitert() {
|
||||||
|
let p = tmp_paths();
|
||||||
|
assert!(create_apikey_pool_in(&p, "a/b", "sk-1").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Pool ändern --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apikey_aendern() {
|
||||||
|
let p = tmp_paths();
|
||||||
|
create_apikey_pool_in(&p, "kunde", "sk-alt").unwrap();
|
||||||
|
set_apikey_in(&p, "kunde", "sk-neu").unwrap();
|
||||||
|
let key_path = p.pool_dir("kunde").join(APIKEY_FILE);
|
||||||
|
assert_eq!(fs::read_to_string(&key_path).unwrap(), "sk-neu\n");
|
||||||
|
assert_eq!(mode(&key_path), 0o600);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apikey_aendern_auf_oauth_pool_scheitert() {
|
||||||
|
let p = tmp_paths();
|
||||||
|
make_oauth_pool(&p, "privat");
|
||||||
|
assert!(set_apikey_in(&p, "privat", "sk-1").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn oauth_relogin_ueberschreibt_credentials() {
|
||||||
|
let p = tmp_paths();
|
||||||
|
make_oauth_pool(&p, "privat");
|
||||||
|
let cred_path = oauth_credential_path(&p, "privat").unwrap();
|
||||||
|
let mut neu = dummy_creds();
|
||||||
|
neu["claudeAiOauth"]["accessToken"] = "at-neu".into();
|
||||||
|
write_credentials_file(&cred_path, &neu).unwrap();
|
||||||
|
let creds: serde_json::Value =
|
||||||
|
serde_json::from_str(&fs::read_to_string(&cred_path).unwrap()).unwrap();
|
||||||
|
assert_eq!(creds["claudeAiOauth"]["accessToken"], "at-neu");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn oauth_credential_path_auf_apikey_pool_scheitert() {
|
||||||
|
let p = tmp_paths();
|
||||||
|
create_apikey_pool_in(&p, "kunde", "sk-1").unwrap();
|
||||||
|
assert!(oauth_credential_path(&p, "kunde").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Pool löschen --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pool_loeschen() {
|
||||||
|
let p = tmp_paths();
|
||||||
|
create_apikey_pool_in(&p, "kunde", "sk-1").unwrap();
|
||||||
|
delete_pool_in(&p, "kunde").unwrap();
|
||||||
|
assert!(!p.pool_dir("kunde").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pool_loeschen_mit_zuordnung_scheitert() {
|
||||||
|
let p = tmp_paths();
|
||||||
|
create_apikey_pool_in(&p, "kunde", "sk-1").unwrap();
|
||||||
|
create_project_in(&p, "proj").unwrap();
|
||||||
|
assign_pool_in(&p, "proj", "kunde").unwrap();
|
||||||
|
let err = delete_pool_in(&p, "kunde").unwrap_err();
|
||||||
|
assert!(err.contains("proj"));
|
||||||
|
assert!(p.pool_dir("kunde").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pool_loeschen_unbekannt_scheitert() {
|
||||||
|
let p = tmp_paths();
|
||||||
|
assert!(delete_pool_in(&p, "gibtsnicht").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Projekt zuordnen / rausnehmen / wechseln --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn projekt_zuordnen() {
|
||||||
|
let p = tmp_paths();
|
||||||
|
create_apikey_pool_in(&p, "kunde", "sk-1").unwrap();
|
||||||
|
create_project_in(&p, "proj").unwrap();
|
||||||
|
assign_pool_in(&p, "proj", "kunde").unwrap();
|
||||||
|
|
||||||
|
let cfg: ProjectConfig =
|
||||||
|
serde_json::from_str(&fs::read_to_string(p.project_config("proj")).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(cfg.pool, "kunde");
|
||||||
|
|
||||||
|
let pools = list_pools_in(&p).unwrap();
|
||||||
|
assert_eq!(pools[0].projects, vec!["proj"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn projekt_zuordnen_pool_fehlt_scheitert() {
|
||||||
|
let p = tmp_paths();
|
||||||
|
create_project_in(&p, "proj").unwrap();
|
||||||
|
assert!(assign_pool_in(&p, "proj", "gibtsnicht").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn projekt_rausnehmen() {
|
||||||
|
let p = tmp_paths();
|
||||||
|
create_apikey_pool_in(&p, "kunde", "sk-1").unwrap();
|
||||||
|
create_project_in(&p, "proj").unwrap();
|
||||||
|
assign_pool_in(&p, "proj", "kunde").unwrap();
|
||||||
|
unassign_pool_in(&p, "proj").unwrap();
|
||||||
|
assert!(!p.project_config("proj").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn projekt_rausnehmen_ohne_zuordnung_scheitert() {
|
||||||
|
let p = tmp_paths();
|
||||||
|
create_project_in(&p, "proj").unwrap();
|
||||||
|
assert!(unassign_pool_in(&p, "proj").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wechsel oauth → apikey: das Projekt sieht nur den Pool-Namen,
|
||||||
|
/// der Credential-Typ ist ihm egal.
|
||||||
|
#[test]
|
||||||
|
fn pool_wechseln_typ_ist_projekt_egal() {
|
||||||
|
let p = tmp_paths();
|
||||||
|
make_oauth_pool(&p, "privat");
|
||||||
|
create_apikey_pool_in(&p, "kunde", "sk-1").unwrap();
|
||||||
|
create_project_in(&p, "proj").unwrap();
|
||||||
|
|
||||||
|
assign_pool_in(&p, "proj", "privat").unwrap();
|
||||||
|
assign_pool_in(&p, "proj", "kunde").unwrap();
|
||||||
|
|
||||||
|
let raw = fs::read_to_string(p.project_config("proj")).unwrap();
|
||||||
|
let cfg: serde_json::Value = serde_json::from_str(&raw).unwrap();
|
||||||
|
assert_eq!(cfg, serde_json::json!({ "pool": "kunde" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Projekt anlegen / löschen --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn projekt_anlegen() {
|
||||||
|
let p = tmp_paths();
|
||||||
|
create_project_in(&p, "proj").unwrap();
|
||||||
|
assert!(p.projects_dir().join("proj").join(".claude").is_dir());
|
||||||
|
|
||||||
|
let projects = list_projects_in(&p).unwrap();
|
||||||
|
assert_eq!(projects.len(), 1);
|
||||||
|
assert_eq!(projects[0].name, "proj");
|
||||||
|
assert_eq!(projects[0].pool, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn projekt_doppelt_anlegen_scheitert() {
|
||||||
|
let p = tmp_paths();
|
||||||
|
create_project_in(&p, "proj").unwrap();
|
||||||
|
assert!(create_project_in(&p, "proj").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn projekt_loeschen() {
|
||||||
|
let p = tmp_paths();
|
||||||
|
create_project_in(&p, "proj").unwrap();
|
||||||
|
delete_project_in(&p, "proj").unwrap();
|
||||||
|
assert!(!p.projects_dir().join("proj").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn projekt_loeschen_unbekannt_scheitert() {
|
||||||
|
let p = tmp_paths();
|
||||||
|
assert!(delete_project_in(&p, "gibtsnicht").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,7 +16,8 @@
|
|||||||
"width": 800,
|
"width": 800,
|
||||||
"height": 600,
|
"height": 600,
|
||||||
"resizable": true,
|
"resizable": true,
|
||||||
"fullscreen": false
|
"fullscreen": false,
|
||||||
|
"visible": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
|
|||||||
+39
-2
@@ -1,7 +1,44 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import HelloWorld from './components/HelloWorld.vue'
|
import { onMounted, ref } from "vue";
|
||||||
|
import { enable, disable, isEnabled } from "@tauri-apps/plugin-autostart";
|
||||||
|
import ProjectList from "./components/ProjectList.vue";
|
||||||
|
import PoolList from "./components/PoolList.vue";
|
||||||
|
|
||||||
|
const tab = ref<"projects" | "pools">("projects");
|
||||||
|
const autostart = ref(false);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
autostart.value = await isEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function toggleAutostart() {
|
||||||
|
if (autostart.value) {
|
||||||
|
await disable();
|
||||||
|
} else {
|
||||||
|
await enable();
|
||||||
|
}
|
||||||
|
autostart.value = await isEnabled();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<HelloWorld />
|
<header>
|
||||||
|
<h1>AI Control</h1>
|
||||||
|
<nav>
|
||||||
|
<button :class="{ active: tab === 'projects' }" @click="tab = 'projects'">
|
||||||
|
Projekte
|
||||||
|
</button>
|
||||||
|
<button :class="{ active: tab === 'pools' }" @click="tab = 'pools'">
|
||||||
|
Pools
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
<label class="autostart">
|
||||||
|
<input type="checkbox" :checked="autostart" @change="toggleAutostart" />
|
||||||
|
Autostart
|
||||||
|
</label>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<ProjectList v-if="tab === 'projects'" />
|
||||||
|
<PoolList v-else />
|
||||||
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 8.5 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 496 B |
@@ -1,95 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import viteLogo from '../assets/vite.svg'
|
|
||||||
import heroImg from '../assets/hero.png'
|
|
||||||
import vueLogo from '../assets/vue.svg'
|
|
||||||
|
|
||||||
const count = ref(0)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<section id="center">
|
|
||||||
<div class="hero">
|
|
||||||
<img :src="heroImg" class="base" width="170" height="179" alt="" />
|
|
||||||
<img :src="vueLogo" class="framework" alt="Vue logo" />
|
|
||||||
<img :src="viteLogo" class="vite" alt="Vite logo" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1>Get started</h1>
|
|
||||||
<p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="counter" @click="count++">
|
|
||||||
Count is {{ count }}
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="ticks"></div>
|
|
||||||
|
|
||||||
<section id="next-steps">
|
|
||||||
<div id="docs">
|
|
||||||
<svg class="icon" role="presentation" aria-hidden="true">
|
|
||||||
<use href="/icons.svg#documentation-icon"></use>
|
|
||||||
</svg>
|
|
||||||
<h2>Documentation</h2>
|
|
||||||
<p>Your questions, answered</p>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<a href="https://vite.dev/" target="_blank">
|
|
||||||
<img class="logo" :src="viteLogo" alt="" />
|
|
||||||
Explore Vite
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="https://vuejs.org/" target="_blank">
|
|
||||||
<img class="button-icon" :src="vueLogo" alt="" />
|
|
||||||
Learn more
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div id="social">
|
|
||||||
<svg class="icon" role="presentation" aria-hidden="true">
|
|
||||||
<use href="/icons.svg#social-icon"></use>
|
|
||||||
</svg>
|
|
||||||
<h2>Connect with us</h2>
|
|
||||||
<p>Join the Vite community</p>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<a href="https://github.com/vitejs/vite" target="_blank">
|
|
||||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
|
||||||
<use href="/icons.svg#github-icon"></use>
|
|
||||||
</svg>
|
|
||||||
GitHub
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="https://chat.vite.dev/" target="_blank">
|
|
||||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
|
||||||
<use href="/icons.svg#discord-icon"></use>
|
|
||||||
</svg>
|
|
||||||
Discord
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="https://x.com/vite_js" target="_blank">
|
|
||||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
|
||||||
<use href="/icons.svg#x-icon"></use>
|
|
||||||
</svg>
|
|
||||||
X.com
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="https://bsky.app/profile/vite.dev" target="_blank">
|
|
||||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
|
||||||
<use href="/icons.svg#bluesky-icon"></use>
|
|
||||||
</svg>
|
|
||||||
Bluesky
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="ticks"></div>
|
|
||||||
<section id="spacer"></section>
|
|
||||||
</template>
|
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from "vue";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
interface Pool {
|
||||||
|
name: string;
|
||||||
|
credentialType: string;
|
||||||
|
projects: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const pools = ref<Pool[]>([]);
|
||||||
|
const error = ref("");
|
||||||
|
const busy = ref(false);
|
||||||
|
|
||||||
|
type Mode = "oauth" | "apikey";
|
||||||
|
const dialog = ref<{ mode: Mode; editing: boolean } | null>(null);
|
||||||
|
const dName = ref("");
|
||||||
|
const dKey = ref("");
|
||||||
|
|
||||||
|
const dialogTitle = computed(() => {
|
||||||
|
if (!dialog.value) return "";
|
||||||
|
if (dialog.value.mode === "oauth") return "Neuen oAuth-Pool anlegen";
|
||||||
|
if (dialog.value.editing) return `API-Key ändern – ${dName.value}`;
|
||||||
|
return "Neuen API-Key-Pool anlegen";
|
||||||
|
});
|
||||||
|
|
||||||
|
function openNew(mode: Mode) {
|
||||||
|
dName.value = "";
|
||||||
|
dKey.value = "";
|
||||||
|
error.value = "";
|
||||||
|
dialog.value = { mode, editing: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditKey(pool: string) {
|
||||||
|
dName.value = pool;
|
||||||
|
dKey.value = "";
|
||||||
|
error.value = "";
|
||||||
|
dialog.value = { mode: "apikey", editing: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDialog() {
|
||||||
|
if (busy.value) return;
|
||||||
|
dialog.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
try {
|
||||||
|
pools.value = await invoke<Pool[]>("list_pools");
|
||||||
|
error.value = "";
|
||||||
|
} catch (e) {
|
||||||
|
error.value = String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
const d = dialog.value;
|
||||||
|
if (!d) return;
|
||||||
|
busy.value = true;
|
||||||
|
error.value = "";
|
||||||
|
try {
|
||||||
|
if (d.mode === "oauth") {
|
||||||
|
await invoke("create_oauth_pool", { name: dName.value });
|
||||||
|
} else if (d.editing) {
|
||||||
|
await invoke("set_apikey", { pool: dName.value, key: dKey.value });
|
||||||
|
} else {
|
||||||
|
await invoke("create_apikey_pool", { name: dName.value, key: dKey.value });
|
||||||
|
}
|
||||||
|
dialog.value = null;
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
error.value = String(e);
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteName = ref<string | null>(null);
|
||||||
|
|
||||||
|
function askDelete(pool: string) {
|
||||||
|
error.value = "";
|
||||||
|
deleteName.value = pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
const name = deleteName.value;
|
||||||
|
if (!name) return;
|
||||||
|
busy.value = true;
|
||||||
|
error.value = "";
|
||||||
|
try {
|
||||||
|
await invoke("delete_pool", { name });
|
||||||
|
deleteName.value = null;
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
error.value = String(e);
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function relogin(pool: string) {
|
||||||
|
busy.value = true;
|
||||||
|
error.value = "";
|
||||||
|
try {
|
||||||
|
await invoke("oauth_login", { pool });
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
error.value = String(e);
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renew(pool: string) {
|
||||||
|
busy.value = true;
|
||||||
|
error.value = "";
|
||||||
|
try {
|
||||||
|
await invoke("oauth_refresh", { pool });
|
||||||
|
} catch (e) {
|
||||||
|
error.value = String(e);
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(refresh);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="toolbar">
|
||||||
|
<button class="primary" :disabled="busy" @click="openNew('oauth')">
|
||||||
|
+ oAuth
|
||||||
|
</button>
|
||||||
|
<button class="primary" :disabled="busy" @click="openNew('apikey')">
|
||||||
|
+ apiKey
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
|
|
||||||
|
<table v-if="pools.length">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Pool</th>
|
||||||
|
<th>Typ</th>
|
||||||
|
<th class="actions-col">Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="p in pools" :key="p.name">
|
||||||
|
<td><strong>{{ p.name }}</strong></td>
|
||||||
|
<td>
|
||||||
|
<span class="badge" :class="p.credentialType">{{ p.credentialType }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="actions-col">
|
||||||
|
<template v-if="p.credentialType === 'oauth'">
|
||||||
|
<button :disabled="busy" @click="relogin(p.name)">Neu anmelden</button>
|
||||||
|
<button :disabled="busy" @click="renew(p.name)">Token erneuern</button>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<button :disabled="busy" @click="openEditKey(p.name)">Key ändern</button>
|
||||||
|
</template>
|
||||||
|
<button
|
||||||
|
v-if="!p.projects.length"
|
||||||
|
class="danger"
|
||||||
|
:disabled="busy"
|
||||||
|
@click="askDelete(p.name)"
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
<span v-else class="assigned" :title="p.projects.join(', ')">
|
||||||
|
zugeordnet: {{ p.projects.join(", ") }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p v-else class="empty">Noch keine Pools angelegt.</p>
|
||||||
|
|
||||||
|
<div v-if="dialog" class="modal-backdrop" @click.self="closeDialog">
|
||||||
|
<form class="modal" @submit.prevent="submit">
|
||||||
|
<h2>{{ dialogTitle }}</h2>
|
||||||
|
|
||||||
|
<label v-if="!dialog.editing">
|
||||||
|
<span>Name</span>
|
||||||
|
<input v-model="dName" placeholder="z. B. Mx9Privat" autofocus required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label v-if="dialog.mode === 'apikey'">
|
||||||
|
<span>API-Key</span>
|
||||||
|
<input
|
||||||
|
v-model="dKey"
|
||||||
|
type="password"
|
||||||
|
placeholder="sk-ant-api03-…"
|
||||||
|
:autofocus="dialog.editing"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<p v-if="dialog.mode === 'oauth'" class="hint">
|
||||||
|
Nach „Login starten“ öffnet sich der Browser zur Anmeldung gegen dein Abo.
|
||||||
|
</p>
|
||||||
|
<p v-if="busy && dialog.mode === 'oauth'" class="hint busy">
|
||||||
|
Warte auf die Anmeldung im Browser…
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" :disabled="busy" @click="closeDialog">Abbrechen</button>
|
||||||
|
<button type="submit" class="primary" :disabled="busy">
|
||||||
|
{{ dialog.mode === "oauth" ? "Login starten" : "Speichern" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="deleteName" class="modal-backdrop" @click.self="deleteName = null">
|
||||||
|
<div class="modal">
|
||||||
|
<h2>Pool löschen</h2>
|
||||||
|
<p class="hint">
|
||||||
|
Pool <strong>{{ deleteName }}</strong> und seine Credential-Datei werden
|
||||||
|
gelöscht. Das lässt sich nicht rückgängig machen.
|
||||||
|
</p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" :disabled="busy" @click="deleteName = null">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button class="danger" :disabled="busy" @click="confirmDelete">
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary {
|
||||||
|
background: #4a4af0;
|
||||||
|
border-color: #4a4af0;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.primary:hover:not(:disabled) {
|
||||||
|
background: #5b5bf5;
|
||||||
|
}
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-col {
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.actions-col button {
|
||||||
|
margin-left: 0.4rem;
|
||||||
|
}
|
||||||
|
.danger {
|
||||||
|
color: #ff6b6b;
|
||||||
|
border-color: #ff6b6b55;
|
||||||
|
}
|
||||||
|
.danger:hover:not(:disabled) {
|
||||||
|
background: #ff6b6b22;
|
||||||
|
}
|
||||||
|
.assigned {
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
border: 1px solid #444;
|
||||||
|
color: #cfcfd6;
|
||||||
|
}
|
||||||
|
.badge.oauth {
|
||||||
|
border-color: #3ecf6a55;
|
||||||
|
color: #3ecf6a;
|
||||||
|
}
|
||||||
|
.badge.apikey {
|
||||||
|
border-color: #f0a84a55;
|
||||||
|
color: #f0a84a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.9rem;
|
||||||
|
background: #23232a;
|
||||||
|
border: 1px solid #3a3a44;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
min-width: 24rem;
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
.modal h2 {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.modal label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
.modal label span {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
.modal input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
.hint.busy {
|
||||||
|
color: #4a9af5;
|
||||||
|
}
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, onUnmounted, ref } from "vue";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
interface Project {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
pool: string | null;
|
||||||
|
running: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Pool {
|
||||||
|
name: string;
|
||||||
|
credentialFile: string;
|
||||||
|
credentialType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const projects = ref<Project[]>([]);
|
||||||
|
const pools = ref<Pool[]>([]);
|
||||||
|
const error = ref("");
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
try {
|
||||||
|
projects.value = await invoke<Project[]>("list_projects");
|
||||||
|
pools.value = await invoke<Pool[]>("list_pools");
|
||||||
|
error.value = "";
|
||||||
|
} catch (e) {
|
||||||
|
error.value = String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start(project: Project) {
|
||||||
|
try {
|
||||||
|
await invoke("start_project", { project: project.name });
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
error.value = String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingRestart {
|
||||||
|
name: string;
|
||||||
|
from: string | null;
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = ref<PendingRestart | null>(null);
|
||||||
|
const restarting = ref(false);
|
||||||
|
|
||||||
|
async function assign(project: Project, event: Event) {
|
||||||
|
const pool = (event.target as HTMLSelectElement).value;
|
||||||
|
const from = project.pool;
|
||||||
|
try {
|
||||||
|
await invoke("assign_pool", { project: project.name, pool });
|
||||||
|
if (project.running && pool !== from) {
|
||||||
|
pending.value = { name: project.name, from, to: pool };
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
error.value = String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restartNow() {
|
||||||
|
const p = pending.value!;
|
||||||
|
restarting.value = true;
|
||||||
|
try {
|
||||||
|
await invoke("restart_project", { project: p.name });
|
||||||
|
pending.value = null;
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
error.value = String(e);
|
||||||
|
pending.value = null;
|
||||||
|
}
|
||||||
|
restarting.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let timer: number;
|
||||||
|
onMounted(() => {
|
||||||
|
refresh();
|
||||||
|
timer = window.setInterval(refresh, 3000);
|
||||||
|
});
|
||||||
|
onUnmounted(() => window.clearInterval(timer));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th></th>
|
||||||
|
<th>Projekt</th>
|
||||||
|
<th>Pool</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="p in projects" :key="p.name">
|
||||||
|
<td>
|
||||||
|
<span class="dot" :class="{ on: p.running }"></span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<strong>{{ p.name }}</strong>
|
||||||
|
<small>{{ p.path }}</small>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select :value="p.pool ?? ''" @change="assign(p, $event)">
|
||||||
|
<option value="" disabled>– kein Pool –</option>
|
||||||
|
<option v-for="pool in pools" :key="pool.name" :value="pool.name">
|
||||||
|
{{ pool.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button @click="start(p)">
|
||||||
|
{{ p.running ? "Aktivieren" : "Starten" }}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div v-if="pending" class="overlay">
|
||||||
|
<div class="dialog">
|
||||||
|
<h3>{{ pending.name }} läuft noch</h3>
|
||||||
|
<p>
|
||||||
|
Der Pool-Wechsel ist gespeichert. Die laufende Session arbeitet aber
|
||||||
|
weiter im alten Pool — der neue gilt erst ab dem nächsten Start.
|
||||||
|
</p>
|
||||||
|
<div class="pools">
|
||||||
|
<span class="chip">{{ pending.from ?? "kein Pool" }}</span>
|
||||||
|
<span class="arrow">→</span>
|
||||||
|
<span class="chip new">{{ pending.to }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="hint">
|
||||||
|
Der Neustart beendet die laufende Session. Der automatische
|
||||||
|
commit+push von claude-sync nach Sessionende entfällt dabei —
|
||||||
|
Ghostty fragt bei laufendem Prozess vor dem Beenden noch einmal nach.
|
||||||
|
</p>
|
||||||
|
<div class="actions">
|
||||||
|
<button @click="pending = null">Weiterlaufen lassen</button>
|
||||||
|
<button class="primary" :disabled="restarting" @click="restartNow">
|
||||||
|
{{ restarting ? "Starte neu …" : "Jetzt neu starten" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
+184
-263
@@ -1,296 +1,217 @@
|
|||||||
:root {
|
:root {
|
||||||
--text: #6b6375;
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
--text-h: #08060d;
|
font-size: 14px;
|
||||||
--bg: #fff;
|
color: #e6e6e6;
|
||||||
--border: #e5e4e7;
|
background: #1b1b1f;
|
||||||
--code-bg: #f4f3ec;
|
color-scheme: dark;
|
||||||
--accent: #aa3bff;
|
|
||||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
|
||||||
--accent-border: rgba(170, 59, 255, 0.5);
|
|
||||||
--social-bg: rgba(244, 243, 236, 0.5);
|
|
||||||
--shadow:
|
|
||||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
|
||||||
|
|
||||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
--mono: ui-monospace, Consolas, monospace;
|
|
||||||
|
|
||||||
font: 18px/145% var(--sans);
|
|
||||||
letter-spacing: 0.18px;
|
|
||||||
color-scheme: light dark;
|
|
||||||
color: var(--text);
|
|
||||||
background: var(--bg);
|
|
||||||
font-synthesis: none;
|
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
* {
|
||||||
:root {
|
box-sizing: border-box;
|
||||||
--text: #9ca3af;
|
|
||||||
--text-h: #f3f4f6;
|
|
||||||
--bg: #16171d;
|
|
||||||
--border: #2e303a;
|
|
||||||
--code-bg: #1f2028;
|
|
||||||
--accent: #c084fc;
|
|
||||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
|
||||||
--accent-border: rgba(192, 132, 252, 0.5);
|
|
||||||
--social-bg: rgba(47, 48, 58, 0.5);
|
|
||||||
--shadow:
|
|
||||||
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#social .button-icon {
|
|
||||||
filter: invert(1) brightness(2);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1,
|
header {
|
||||||
h2 {
|
display: flex;
|
||||||
font-family: var(--heading);
|
align-items: center;
|
||||||
font-weight: 500;
|
gap: 1.5rem;
|
||||||
color: var(--text-h);
|
padding: 0.75rem 1.25rem;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 56px;
|
font-size: 1rem;
|
||||||
letter-spacing: -1.68px;
|
|
||||||
margin: 32px 0;
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
font-size: 36px;
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
h2 {
|
|
||||||
font-size: 24px;
|
|
||||||
line-height: 118%;
|
|
||||||
letter-spacing: -0.24px;
|
|
||||||
margin: 0 0 8px;
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
p {
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
code,
|
h2 {
|
||||||
.counter {
|
font-size: 0.9rem;
|
||||||
font-family: var(--mono);
|
margin: 0 0 0.5rem;
|
||||||
display: inline-flex;
|
|
||||||
border-radius: 4px;
|
|
||||||
color: var(--text-h);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
code {
|
nav {
|
||||||
font-size: 15px;
|
|
||||||
line-height: 135%;
|
|
||||||
padding: 4px 8px;
|
|
||||||
background: var(--code-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.counter {
|
|
||||||
font-size: 16px;
|
|
||||||
padding: 5px 10px;
|
|
||||||
border-radius: 5px;
|
|
||||||
color: var(--accent);
|
|
||||||
background: var(--accent-bg);
|
|
||||||
border: 2px solid transparent;
|
|
||||||
transition: border-color 0.3s;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: var(--accent-border);
|
|
||||||
}
|
|
||||||
&:focus-visible {
|
|
||||||
outline: 2px solid var(--accent);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero {
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
.base,
|
|
||||||
.framework,
|
|
||||||
.vite {
|
|
||||||
inset-inline: 0;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.base {
|
|
||||||
width: 170px;
|
|
||||||
position: relative;
|
|
||||||
z-index: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.framework,
|
|
||||||
.vite {
|
|
||||||
position: absolute;
|
|
||||||
}
|
|
||||||
|
|
||||||
.framework {
|
|
||||||
z-index: 1;
|
|
||||||
top: 34px;
|
|
||||||
height: 28px;
|
|
||||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
|
||||||
scale(1.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.vite {
|
|
||||||
z-index: 0;
|
|
||||||
top: 107px;
|
|
||||||
height: 26px;
|
|
||||||
width: auto;
|
|
||||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
|
||||||
scale(0.8);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#app {
|
|
||||||
width: 1126px;
|
|
||||||
max-width: 100%;
|
|
||||||
margin: 0 auto;
|
|
||||||
text-align: center;
|
|
||||||
border-inline: 1px solid var(--border);
|
|
||||||
min-height: 100svh;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
gap: 0.5rem;
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#center {
|
main {
|
||||||
display: flex;
|
padding: 1.25rem;
|
||||||
flex-direction: column;
|
|
||||||
gap: 25px;
|
|
||||||
place-content: center;
|
|
||||||
place-items: center;
|
|
||||||
flex-grow: 1;
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
padding: 32px 20px 24px;
|
|
||||||
gap: 18px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#next-steps {
|
button,
|
||||||
display: flex;
|
select,
|
||||||
border-top: 1px solid var(--border);
|
input {
|
||||||
text-align: left;
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
& > div {
|
background: #2a2a30;
|
||||||
flex: 1 1 0;
|
border: 1px solid #444;
|
||||||
padding: 32px;
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
padding: 24px 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
flex-direction: column;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#docs {
|
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
border-right: none;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#next-steps ul {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
margin: 32px 0 0;
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
height: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: var(--text-h);
|
|
||||||
font-size: 16px;
|
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: var(--social-bg);
|
padding: 0.35rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background: #35353d;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav button.active {
|
||||||
|
background: #4a4af0;
|
||||||
|
border-color: #4a4af0;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #999;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
border-bottom: 1px solid #26262b;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
td small {
|
||||||
|
display: block;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.on {
|
||||||
|
background: #3ecf6a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #ff6b6b;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: 6px 12px;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: box-shadow 0.3s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
}
|
|
||||||
.button-icon {
|
|
||||||
height: 18px;
|
|
||||||
width: 18px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
margin-top: 20px;
|
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
justify-content: center;
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
li {
|
form h2 {
|
||||||
flex: 1 1 calc(50% - 8px);
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
form input {
|
||||||
|
min-width: 16rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
backdrop-filter: blur(3px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
box-sizing: border-box;
|
z-index: 10;
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#spacer {
|
.dialog {
|
||||||
height: 88px;
|
background: #222228;
|
||||||
border-top: 1px solid var(--border);
|
border: 1px solid #3a3a42;
|
||||||
@media (max-width: 1024px) {
|
border-radius: 12px;
|
||||||
height: 48px;
|
padding: 1.75rem 2rem;
|
||||||
}
|
max-width: 34rem;
|
||||||
|
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ticks {
|
.dialog h3 {
|
||||||
position: relative;
|
margin: 0 0 0.75rem;
|
||||||
width: 100%;
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
&::before,
|
|
||||||
&::after {
|
.dialog p {
|
||||||
content: '';
|
margin: 0 0 0.75rem;
|
||||||
position: absolute;
|
line-height: 1.5;
|
||||||
top: -4.5px;
|
color: #cfcfd6;
|
||||||
border: 5px solid transparent;
|
}
|
||||||
}
|
|
||||||
|
.dialog .hint {
|
||||||
&::before {
|
color: #9a9aa4;
|
||||||
left: 0;
|
font-size: 0.85rem;
|
||||||
border-left-color: var(--border);
|
}
|
||||||
}
|
|
||||||
&::after {
|
.dialog .pools {
|
||||||
right: 0;
|
display: flex;
|
||||||
border-right-color: var(--border);
|
align-items: center;
|
||||||
}
|
gap: 0.6rem;
|
||||||
|
margin: 1rem 0 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
background: #2a2a30;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.2rem 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip.new {
|
||||||
|
border-color: #4a4af0;
|
||||||
|
color: #b9b9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog .actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background: #4a4af0;
|
||||||
|
border-color: #4a4af0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary:hover {
|
||||||
|
background: #5c5cf2;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
header .autostart {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
color: #999;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
padding: 0;
|
||||||
|
accent-color: #4a4af0;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user