diff --git a/package-lock.json b/package-lock.json index 2e985f6..c7a9f00 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "@tauri-apps/api": "^2.11.1", + "@tauri-apps/plugin-autostart": "^2.5.1", "@tauri-apps/plugin-opener": "^2.5.4", "vue": "^3.5.39" }, @@ -661,6 +662,15 @@ "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": { "version": "2.5.4", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz", diff --git a/package.json b/package.json index 8a4ac51..6775304 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@tauri-apps/api": "^2.11.1", + "@tauri-apps/plugin-autostart": "^2.5.1", "@tauri-apps/plugin-opener": "^2.5.4", "vue": "^3.5.39" }, diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 088fcf6..2a06b87 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -79,12 +79,17 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" name = "app" version = "0.1.0" dependencies = [ + "base64 0.22.1", + "getrandom 0.2.17", "log", "serde", "serde_json", + "sha2", "tauri", "tauri-build", + "tauri-plugin-autostart", "tauri-plugin-log", + "ureq", ] [[package]] @@ -122,6 +127,17 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "autocfg" version = "1.5.1" @@ -670,13 +686,33 @@ dependencies = [ "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]] name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" 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]] @@ -687,7 +723,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] @@ -814,7 +850,7 @@ dependencies = [ "rustc_version", "toml 1.1.2+spec-1.1.0", "vswhom", - "winreg", + "winreg 0.55.0", ] [[package]] @@ -2529,6 +2565,17 @@ dependencies = [ "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]] name = "redox_users" version = "0.5.2" @@ -2632,6 +2679,20 @@ dependencies = [ "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]] name = "rkyv" version = "0.7.46" @@ -2693,6 +2754,41 @@ dependencies = [ "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]] name = "rustversion" version = "1.0.22" @@ -3099,6 +3195,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.7" @@ -3237,7 +3339,7 @@ dependencies = [ "anyhow", "bytes", "cookie", - "dirs", + "dirs 6.0.0", "dunce", "embed_plist", "getrandom 0.3.4", @@ -3287,7 +3389,7 @@ checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" dependencies = [ "anyhow", "cargo_toml", - "dirs", + "dirs 6.0.0", "glob", "heck 0.5.0", "json-patch", @@ -3357,6 +3459,20 @@ dependencies = [ "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]] name = "tauri-plugin-log" version = "2.8.0" @@ -3804,7 +3920,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc" dependencies = [ "crossbeam-channel", - "dirs", + "dirs 6.0.0", "libappindicator", "muda", "objc2", @@ -3890,6 +4006,30 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "url" version = "2.5.8" @@ -4151,6 +4291,24 @@ dependencies = [ "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]] name = "webview2-com" version = "0.38.2" @@ -4381,6 +4539,15 @@ dependencies = [ "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]] name = "windows-sys" version = "0.59.0" @@ -4562,6 +4729,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "winreg" version = "0.55.0" @@ -4594,7 +4770,7 @@ dependencies = [ "block2", "cookie", "crossbeam-channel", - "dirs", + "dirs 6.0.0", "dom_query", "dpi", "dunce", @@ -4722,6 +4898,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.4" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index fb4d9fc..3e19170 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -21,5 +21,10 @@ tauri-build = { version = "2.6.3", features = [] } serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } log = "0.4" -tauri = { version = "2.11.3", features = [] } +tauri = { version = "2.11.3", features = ["tray-icon"] } tauri-plugin-log = "2" +tauri-plugin-autostart = "2" +ureq = { version = "2", features = ["json"] } +base64 = "0.22" +sha2 = "0.10" +getrandom = "0.2" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index c135d7f..29234a7 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -6,6 +6,7 @@ "main" ], "permissions": [ - "core:default" + "core:default", + "autostart:default" ] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9c3118c..edc892c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, + 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 { + 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, 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, 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, +} + +fn list_pools_in(paths: &Paths) -> Result, 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 { + 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 { + 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, 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, 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 = 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)] pub fn run() { + use tauri::Manager; + tauri::Builder::default() + .plugin(tauri_plugin_autostart::init( + tauri_plugin_autostart::MacosLauncher::LaunchAgent, + None, + )) .setup(|app| { if cfg!(debug_assertions) { app.handle().plugin( @@ -9,8 +639,328 @@ pub fn run() { .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(()) }) + // 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!()) .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()); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index efe013f..e59f351 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -16,7 +16,8 @@ "width": 800, "height": 600, "resizable": true, - "fullscreen": false + "fullscreen": false, + "visible": false } ], "security": { diff --git a/src/App.vue b/src/App.vue index 388b05e..a03772a 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,7 +1,44 @@ diff --git a/src/assets/hero.png b/src/assets/hero.png deleted file mode 100644 index 02251f4..0000000 Binary files a/src/assets/hero.png and /dev/null differ diff --git a/src/assets/vite.svg b/src/assets/vite.svg deleted file mode 100644 index 5101b67..0000000 --- a/src/assets/vite.svg +++ /dev/null @@ -1 +0,0 @@ -Vite diff --git a/src/assets/vue.svg b/src/assets/vue.svg deleted file mode 100644 index 770e9d3..0000000 --- a/src/assets/vue.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/components/HelloWorld.vue b/src/components/HelloWorld.vue deleted file mode 100644 index c232865..0000000 --- a/src/components/HelloWorld.vue +++ /dev/null @@ -1,95 +0,0 @@ - - - diff --git a/src/components/PoolList.vue b/src/components/PoolList.vue new file mode 100644 index 0000000..e1be695 --- /dev/null +++ b/src/components/PoolList.vue @@ -0,0 +1,344 @@ + + + + + diff --git a/src/components/ProjectList.vue b/src/components/ProjectList.vue new file mode 100644 index 0000000..02531dc --- /dev/null +++ b/src/components/ProjectList.vue @@ -0,0 +1,148 @@ + + + diff --git a/src/style.css b/src/style.css index 527d4fb..4c8cece 100644 --- a/src/style.css +++ b/src/style.css @@ -1,296 +1,217 @@ :root { - --text: #6b6375; - --text-h: #08060d; - --bg: #fff; - --border: #e5e4e7; - --code-bg: #f4f3ec; - --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; - } + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-size: 14px; + color: #e6e6e6; + background: #1b1b1f; + color-scheme: dark; } -@media (prefers-color-scheme: dark) { - :root { - --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); - } +* { + box-sizing: border-box; } body { margin: 0; } -h1, -h2 { - font-family: var(--heading); - font-weight: 500; - color: var(--text-h); +header { + display: flex; + align-items: center; + gap: 1.5rem; + padding: 0.75rem 1.25rem; + border-bottom: 1px solid #333; } h1 { - font-size: 56px; - 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 { + font-size: 1rem; margin: 0; } -code, -.counter { - font-family: var(--mono); - display: inline-flex; - border-radius: 4px; - color: var(--text-h); +h2 { + font-size: 0.9rem; + margin: 0 0 0.5rem; } -code { - 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; +nav { display: flex; - flex-direction: column; - box-sizing: border-box; + gap: 0.5rem; } -#center { - display: flex; - flex-direction: column; - gap: 25px; - place-content: center; - place-items: center; - flex-grow: 1; - - @media (max-width: 1024px) { - padding: 32px 20px 24px; - gap: 18px; - } +main { + padding: 1.25rem; } -#next-steps { - display: flex; - border-top: 1px solid var(--border); - text-align: left; - - & > div { - flex: 1 1 0; - 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; - } +button, +select, +input { + font: inherit; + color: inherit; + background: #2a2a30; + border: 1px solid #444; + border-radius: 6px; + padding: 0.35rem 0.75rem; } -#docs { - border-right: 1px solid var(--border); - - @media (max-width: 1024px) { - border-right: none; - border-bottom: 1px solid var(--border); - } +button { + cursor: pointer; } -#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; - background: var(--social-bg); - 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; - justify-content: center; - - li { - flex: 1 1 calc(50% - 8px); - } - - a { - width: 100%; - justify-content: center; - box-sizing: border-box; - } - } +button:hover { + background: #35353d; } -#spacer { - height: 88px; - border-top: 1px solid var(--border); - @media (max-width: 1024px) { - height: 48px; - } +nav button.active { + background: #4a4af0; + border-color: #4a4af0; } -.ticks { - position: relative; +table { width: 100%; - - &::before, - &::after { - content: ''; - position: absolute; - top: -4.5px; - border: 5px solid transparent; - } - - &::before { - left: 0; - border-left-color: var(--border); - } - &::after { - right: 0; - border-right-color: var(--border); - } + 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; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; +} + +form h2 { + 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; + z-index: 10; +} + +.dialog { + background: #222228; + border: 1px solid #3a3a42; + border-radius: 12px; + padding: 1.75rem 2rem; + max-width: 34rem; + box-shadow: 0 18px 50px rgba(0, 0, 0, 0.5); +} + +.dialog h3 { + margin: 0 0 0.75rem; + font-size: 1.05rem; +} + +.dialog p { + margin: 0 0 0.75rem; + line-height: 1.5; + color: #cfcfd6; +} + +.dialog .hint { + color: #9a9aa4; + font-size: 0.85rem; +} + +.dialog .pools { + display: flex; + 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; }