diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..b4b7cb5 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,37 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Edit|MultiEdit|Write", + "hooks": [ + { + "type": "command", + "command": "but claude pre-tool" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Edit|MultiEdit|Write", + "hooks": [ + { + "type": "command", + "command": "but claude post-tool" + } + ] + } + ], + "Stop": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "but claude stop" + } + ] + } + ] + } +} diff --git a/Cargo.lock b/Cargo.lock index b55af2b..f72af96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -990,6 +990,7 @@ dependencies = [ "chrono", "directories", "keyring", + "log", "quick-xml", "reqwest", "serde", @@ -1144,7 +1145,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.52.0", ] [[package]] @@ -1304,9 +1305,9 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" [[package]] name = "rustix" @@ -1347,9 +1348,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.9" +version = "0.103.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" dependencies = [ "ring", "rustls-pki-types", @@ -2335,18 +2336,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.42" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.42" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", diff --git a/apps/tauri/src-tauri/Cargo.lock b/apps/tauri/src-tauri/Cargo.lock index 07e9efb..01abbb0 100644 --- a/apps/tauri/src-tauri/Cargo.lock +++ b/apps/tauri/src-tauri/Cargo.lock @@ -2386,6 +2386,7 @@ dependencies = [ "chrono", "directories", "keyring", + "log", "quick-xml 0.36.2", "reqwest 0.12.28", "serde", diff --git a/apps/tauri/src-tauri/src/lib.rs b/apps/tauri/src-tauri/src/lib.rs index 1242a33..6a43333 100644 --- a/apps/tauri/src-tauri/src/lib.rs +++ b/apps/tauri/src-tauri/src/lib.rs @@ -11,7 +11,7 @@ use tauri::{Emitter, Manager, State}; use uuid::Uuid; use onyx_core::{ - config::{AppConfig, WorkspaceConfig}, + config::{AppConfig, WorkspaceConfig, WorkspaceMode}, models::{Task, TaskList, TaskStatus}, repository::TaskRepository, sync::{self, SyncMode, SyncResult as CoreSyncResult}, @@ -31,6 +31,7 @@ static LAST_WRITE: Mutex> = Mutex::new(None); struct AppState { config: AppConfig, config_path: PathBuf, + app_data_dir: PathBuf, repo: Option, } @@ -418,17 +419,74 @@ fn set_webdav_config( } #[tauri::command] -fn store_credentials( +fn set_workspace_theme( + workspace_name: String, + theme: Option, + state: State<'_, Mutex>, +) -> Result<(), String> { + let mut s = lock_state(&state)?; + if let Some(ws) = s.config.workspaces.get_mut(&workspace_name) { + ws.theme = theme; + } + s.config.save_to_file(&s.config_path.clone()).map_err(|e| e.to_string()) +} + +#[tauri::command] +fn add_webdav_workspace( + name: String, + webdav_url: String, + username: String, + password: String, + state: State<'_, Mutex>, +) -> Result<(), String> { + let mut s = lock_state(&state)?; + let managed_dir = s.app_data_dir.join("workspaces").join(&name); + std::fs::create_dir_all(&managed_dir).map_err(|e| e.to_string())?; + TaskRepository::init(managed_dir.clone()).map(|_| ()).map_err(|e| e.to_string())?; + + let mut ws = WorkspaceConfig::new(managed_dir); + ws.mode = WorkspaceMode::Webdav; + ws.webdav_url = Some(webdav_url.clone()); + + s.config.add_workspace(name.clone(), ws); + s.config.set_current_workspace(name).map_err(|e| e.to_string())?; + s.repo = None; + + // Store credentials keyed by hostname + let domain = webdav_url + .split("://") + .nth(1) + .and_then(|rest| rest.split('/').next()) + .unwrap_or("") + .to_string(); + s.config.save_to_file(&s.config_path.clone()).map_err(|e| e.to_string())?; + drop(s); + webdav::store_credentials(&domain, &username, &password).map_err(|e| e.to_string())?; + Ok(()) +} + +#[tauri::command] +async fn store_credentials( domain: String, username: String, password: String, ) -> Result<(), String> { - webdav::store_credentials(&domain, &username, &password).map_err(|e| e.to_string()) + tokio::task::spawn_blocking(move || { + webdav::store_credentials(&domain, &username, &password).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())? } #[tauri::command] -fn load_credentials(domain: String) -> Result<(String, String), String> { - webdav::load_credentials(&domain).map_err(|e| e.to_string()) +async fn load_credentials(domain: String) -> Result<(String, String), String> { + tokio::task::spawn_blocking(move || { + webdav::load_credentials(&domain) + .map(|(u, p)| ((*u).clone(), (*p).clone())) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())? } #[tauri::command] @@ -437,7 +495,8 @@ async fn test_webdav_connection( username: String, password: String, ) -> Result<(), String> { - let client = onyx_core::webdav::WebDavClient::new(&url, &username, &password); + let client = onyx_core::webdav::WebDavClient::new(&url, &username, &password) + .map_err(|e| e.to_string())?; client .test_connection() .await @@ -447,20 +506,39 @@ async fn test_webdav_connection( #[tauri::command] async fn sync_workspace( workspace_name: String, - workspace_path: String, - webdav_url: String, - username: String, - password: String, mode: String, state: State<'_, Mutex>, ) -> Result { + // Step 1: read config + let (workspace_path, webdav_url) = { + let s = lock_state(&state)?; + let ws = s.config.workspaces.get(&workspace_name) + .ok_or("Workspace not found")?; + (ws.path.clone(), ws.webdav_url.clone().ok_or("No WebDAV URL configured")?) + }; + + // Step 2: load credentials + let domain = webdav_url + .split("://") + .nth(1) + .and_then(|rest| rest.split('/').next()) + .unwrap_or("") + .to_string(); + let (username, password) = tokio::task::spawn_blocking(move || { + webdav::load_credentials(&domain) + .map(|(u, p)| ((*u).clone(), (*p).clone())) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())??; + let sync_mode = match mode.as_str() { "push" => SyncMode::Push, "pull" => SyncMode::Pull, _ => SyncMode::Full, }; let result = sync::sync_workspace( - &PathBuf::from(&workspace_path), + &workspace_path, &webdav_url, &username, &password, @@ -470,7 +548,6 @@ async fn sync_workspace( .await .map_err(|e| e.to_string())?; - // Persist last_sync timestamp to config { let mut s = lock_state(&state)?; if let Some(ws) = s.config.workspaces.get_mut(&workspace_name) { @@ -545,23 +622,18 @@ pub fn run() { .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_os::init()) .setup(|app| { - // Resolve config path: Tauri's app_data_dir on Android, directories crate on desktop + // Resolve app data dir and config path + let app_data_dir = app.path().app_data_dir() + .map_err(|e| format!("Failed to get app data dir: {}", e))?; let config_path = { #[cfg(target_os = "android")] - { - use tauri::Manager; - app.path().app_data_dir() - .map_err(|e| format!("Failed to get app data dir: {}", e))? - .join("config.json") - } + { app_data_dir.join("config.json") } #[cfg(not(target_os = "android"))] - { - AppConfig::get_config_path() - } + { AppConfig::get_config_path() } }; let config = AppConfig::load_from_file(&config_path).unwrap_or_default(); let workspace_path = config.get_current_workspace().ok().map(|(_, ws)| ws.path.clone()); - app.manage(Mutex::new(AppState { config, config_path, repo: None })); + app.manage(Mutex::new(AppState { config, config_path, app_data_dir, repo: None })); #[cfg(not(target_os = "android"))] if let Some(path) = workspace_path { @@ -591,6 +663,8 @@ pub fn run() { set_group_by_due_date, get_group_by_due_date, set_webdav_config, + set_workspace_theme, + add_webdav_workspace, store_credentials, load_credentials, test_webdav_connection, diff --git a/apps/tauri/src/App.svelte b/apps/tauri/src/App.svelte index 304b6a2..80d38ec 100644 --- a/apps/tauri/src/App.svelte +++ b/apps/tauri/src/App.svelte @@ -12,7 +12,7 @@ }); -
+
+ {:else} {/if} diff --git a/apps/tauri/src/app.css b/apps/tauri/src/app.css index ec6380a..99c17b1 100644 --- a/apps/tauri/src/app.css +++ b/apps/tauri/src/app.css @@ -68,3 +68,83 @@ body { background-color: #242424; color: #e5e7eb; } + +/* ── Theme overrides ─────────────────────────────────────────────── */ + +[data-theme="light"] { + --color-primary: #2d87b8; + --color-primary-hover: #2474a0; + --color-surface-light: #ffffff; + --color-surface-dark: #ffffff; + --color-card-light: #f9fafb; + --color-card-dark: #f9fafb; + --color-text-light: #1f2937; + --color-text-dark: #1f2937; + --color-text-secondary-light: #6b7280; + --color-text-secondary-dark: #6b7280; + --color-border-light: #e5e7eb; + --color-border-dark: #e5e7eb; +} + +[data-theme="dark"] { + --color-primary: #2d87b8; + --color-primary-hover: #2474a0; + --color-surface-light: #242424; + --color-surface-dark: #242424; + --color-card-light: #303030; + --color-card-dark: #303030; + --color-text-light: #e5e7eb; + --color-text-dark: #e5e7eb; + --color-text-secondary-light: #9ca3af; + --color-text-secondary-dark: #9ca3af; + --color-border-light: #3d3d3d; + --color-border-dark: #3d3d3d; +} + +[data-theme="nord"] { + --color-primary: #88c0d0; + --color-primary-hover: #7ab3c3; + --color-surface-light: #2e3440; + --color-surface-dark: #2e3440; + --color-card-light: #3b4252; + --color-card-dark: #3b4252; + --color-text-light: #eceff4; + --color-text-dark: #eceff4; + --color-text-secondary-light: #d8dee9; + --color-text-secondary-dark: #d8dee9; + --color-border-light: #434c5e; + --color-border-dark: #434c5e; + --color-danger: #bf616a; +} + +[data-theme="dracula"] { + --color-primary: #bd93f9; + --color-primary-hover: #a87ef0; + --color-surface-light: #282a36; + --color-surface-dark: #282a36; + --color-card-light: #343746; + --color-card-dark: #343746; + --color-text-light: #f8f8f2; + --color-text-dark: #f8f8f2; + --color-text-secondary-light: #bfbfbf; + --color-text-secondary-dark: #bfbfbf; + --color-border-light: #44475a; + --color-border-dark: #44475a; + --color-danger: #ff5555; +} + +[data-theme="solarized"] { + --color-primary: #268bd2; + --color-primary-hover: #1e7ac0; + --color-surface-light: #002b36; + --color-surface-dark: #002b36; + --color-card-light: #073642; + --color-card-dark: #073642; + --color-text-light: #93a1a1; + --color-text-dark: #93a1a1; + --color-text-secondary-light: #657b83; + --color-text-secondary-dark: #657b83; + --color-border-light: #094959; + --color-border-dark: #094959; + --color-danger: #dc322f; +} diff --git a/apps/tauri/src/lib/screens/SettingsScreen.svelte b/apps/tauri/src/lib/screens/SettingsScreen.svelte index c932bc6..0257973 100644 --- a/apps/tauri/src/lib/screens/SettingsScreen.svelte +++ b/apps/tauri/src/lib/screens/SettingsScreen.svelte @@ -2,7 +2,10 @@ import { invoke } from "@tauri-apps/api/core"; import { app } from "../stores/app.svelte"; - let { onclose }: { onclose?: () => void } = $props(); + let { onclose, workspaceName }: { onclose?: () => void; workspaceName: string } = $props(); + + let ws = $derived(app.config?.workspaces[workspaceName]); + let isWebdav = $derived(ws?.mode === "webdav"); let webdavUrl = $state(""); let webdavUser = $state(""); @@ -10,19 +13,15 @@ let testStatus = $state<"idle" | "testing" | "ok" | "fail">("idle"); $effect(() => { - const ws = app.config?.current_workspace; - if (!ws) return; - const cfg = app.config?.workspaces[ws]; - if (cfg?.webdav_url) { - webdavUrl = cfg.webdav_url; - try { - const domain = new URL(cfg.webdav_url).hostname; - invoke<[string, string]>("load_credentials", { domain }).then(([u, p]) => { - webdavUser = u; - webdavPass = p; - }).catch(() => {}); - } catch {} - } + if (!ws?.webdav_url) return; + webdavUrl = ws.webdav_url; + try { + const domain = new URL(ws.webdav_url).hostname; + invoke<[string, string]>("load_credentials", { domain }).then(([u, p]) => { + webdavUser = u; + webdavPass = p; + }).catch(() => {}); + } catch {} }); async function testConnection() { @@ -40,9 +39,9 @@ } async function saveWebdav() { - if (!app.config?.current_workspace || !webdavUrl.trim()) return; + if (!webdavUrl.trim()) return; await invoke("set_webdav_config", { - workspaceName: app.config.current_workspace, + workspaceName, webdavUrl: webdavUrl.trim(), }); if (webdavUser && webdavPass) { @@ -55,13 +54,12 @@ } await app.loadConfig(); } -
-

Settings

+

{workspaceName} Settings

- -
-

- WebDAV Sync -

-
- - + + {#if isWebdav} +
+

+ WebDAV Sync +

+
+ + - - + + - - + + -
- - +
+ + +
-
- {#if app.config?.current_workspace}
{ + const val = (e.target as HTMLSelectElement).value; + app.setTheme(val || null); + }} + class="w-full appearance-none rounded-lg border border-border-light bg-surface-light px-3 py-2 text-sm text-text-light outline-none focus:border-primary dark:border-border-dark dark:bg-surface-dark dark:text-text-dark" > -
-
- + + + + + + + +

Tauri v2 + Svelte

diff --git a/apps/tauri/src/lib/screens/SetupScreen.svelte b/apps/tauri/src/lib/screens/SetupScreen.svelte index f164798..7741941 100644 --- a/apps/tauri/src/lib/screens/SetupScreen.svelte +++ b/apps/tauri/src/lib/screens/SetupScreen.svelte @@ -1,16 +1,25 @@
-
+
+
+ {#if cancellable} + + {/if} +
{#if isDesktop}
{#if isWindows} @@ -72,58 +121,162 @@ class="w-full max-w-sm rounded-2xl bg-card-light p-8 shadow-lg dark:bg-card-dark" >

Onyx

-

- Create a new workspace or open an existing one. -

- + {#if mode === null} + +

+ How would you like to store your tasks? +

- - -
- -
- + -
-
- or -
-
+ {:else if mode === "local"} + +

+ Create a new workspace or open an existing one. +

- + + + + +
+ + +
+ + + +
+
+ or +
+
+ + + + {#if !isMobile} + + {/if} + + {:else} + +

+ Connect to a WebDAV server for cloud-synced tasks. +

+ + + + + + + + + + + + +
+ +
+ + + + {#if !isMobile} + + {/if} + {/if}
diff --git a/apps/tauri/src/lib/screens/TasksScreen.svelte b/apps/tauri/src/lib/screens/TasksScreen.svelte index 6ed0665..f22d91f 100644 --- a/apps/tauri/src/lib/screens/TasksScreen.svelte +++ b/apps/tauri/src/lib/screens/TasksScreen.svelte @@ -35,6 +35,7 @@ let showDrawer = $state(false); let showSettings = $state(false); + let settingsWorkspace = $state(null); let showNewList = $state(false); let showWorkspacePicker = $state(false); let workspacePickerEl = $state(null); @@ -152,7 +153,7 @@ clone.style.position = "absolute"; clone.style.top = "-9999px"; clone.style.left = "-9999px"; - if (app.darkMode) { + if (app.isDark) { clone.classList.add("dark"); clone.style.backgroundColor = "var(--color-surface-dark)"; clone.style.color = "var(--color-text-dark)"; @@ -192,12 +193,9 @@ showNewList = false; } - function openSettings() { - showSettings = true; - } - function closeSettings() { showSettings = false; + settingsWorkspace = null; } function handleHeaderMouseDown(e: MouseEvent) { @@ -256,7 +254,7 @@ {/if}

{name}

-

{ws?.path ?? ""}

+

{ws?.mode === "webdav" ? ws.webdav_url ?? "WebDAV" : ws?.path ?? ""}

@@ -270,6 +268,15 @@ {#if wsMenuName === name}
+
- -
@@ -632,7 +625,7 @@ class="relative flex h-full w-full flex-col overflow-hidden rounded-2xl bg-surface-light transition-transform duration-200 dark:bg-surface-dark {showSettings ? 'scale-100' : 'scale-95'}" style="border: 1px solid rgba(255,255,255,0.1); box-shadow: 0 25px 60px rgba(0,0,0,0.7), 0 10px 20px rgba(0,0,0,0.5)" > - +
diff --git a/apps/tauri/src/lib/stores/app.svelte.ts b/apps/tauri/src/lib/stores/app.svelte.ts index fd42e50..a76aeb5 100644 --- a/apps/tauri/src/lib/stores/app.svelte.ts +++ b/apps/tauri/src/lib/stores/app.svelte.ts @@ -20,9 +20,7 @@ let config = $state(null); let lists = $state([]); let activeListId = $state(null); let tasks = $state([]); -let darkMode = $state( - globalThis.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false, -); +let osDark = globalThis.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false; let syncing = $state(false); let syncMode = $state<"full" | "push" | "pull">("full"); let lastSyncResult = $state(null); @@ -56,6 +54,16 @@ let hasWorkspace = $derived( Object.keys(config.workspaces).length > 0, ); +const DARK_THEMES = new Set(["dark", "nord", "dracula", "solarized"]); +let currentTheme = $derived( + config?.current_workspace + ? config.workspaces[config.current_workspace]?.theme ?? null + : null, +); +let isDark = $derived( + currentTheme ? DARK_THEMES.has(currentTheme) : osDark, +); + // ── Actions ────────────────────────────────────────────────────────── async function loadConfig() { @@ -274,30 +282,17 @@ async function setGroupByDueDate(listId: string, enabled: boolean) { async function triggerSync() { if (!config?.current_workspace) return; - const workspaceName = config.current_workspace; - const ws = config.workspaces[workspaceName]; - if (!ws?.webdav_url) { - error = "No WebDAV URL configured"; - return; - } syncing = true; error = null; try { - const domain = new URL(ws.webdav_url).hostname; - const [username, password] = await invoke<[string, string]>("load_credentials", { domain }); const result = await invoke("sync_workspace", { - workspaceName, - workspacePath: ws.path, - webdavUrl: ws.webdav_url, - username, - password, + workspaceName: config.current_workspace, mode: syncMode, }); lastSyncResult = result; if (result.errors.length > 0) { error = result.errors.join("; "); } - // Reload config to pick up updated last_sync timestamp config = await invoke("get_config"); await loadLists(); } catch (e) { @@ -311,8 +306,31 @@ function setSyncMode(mode: "full" | "push" | "pull") { syncMode = mode; } -function toggleDarkMode() { - darkMode = !darkMode; +async function setTheme(theme: string | null) { + if (!config?.current_workspace) return; + try { + await invoke("set_workspace_theme", { + workspaceName: config.current_workspace, + theme, + }); + config = await invoke("get_config"); + } catch (e) { + error = String(e); + } +} + +async function addWebdavWorkspace(name: string, webdavUrl: string, username: string, password: string) { + try { + await invoke("add_webdav_workspace", { name, webdavUrl, username, password }); + config = await invoke("get_config"); + await loadLists(); + const ws = config?.workspaces[name]; + if (ws) invoke("watch_workspace", { path: ws.path }).catch((e) => console.warn("File watcher failed:", e)); + screen = "tasks"; + error = null; + } catch (e) { + error = String(e); + } } function setScreen(s: Screen) { @@ -350,8 +368,11 @@ export const app = { get completedTasks() { return completedTasks; }, - get darkMode() { - return darkMode; + get currentTheme() { + return currentTheme; + }, + get isDark() { + return isDark; }, get syncing() { return syncing; @@ -388,7 +409,8 @@ export const app = { setGroupByDueDate, triggerSync, setSyncMode, - toggleDarkMode, + setTheme, + addWebdavWorkspace, setScreen, clearError, }; diff --git a/apps/tauri/src/lib/types.ts b/apps/tauri/src/lib/types.ts index 40187e7..a24855c 100644 --- a/apps/tauri/src/lib/types.ts +++ b/apps/tauri/src/lib/types.ts @@ -19,10 +19,14 @@ export interface TaskList { group_by_due_date: boolean; } +export type WorkspaceMode = "local" | "webdav"; + export interface WorkspaceConfig { path: string; + mode: WorkspaceMode; webdav_url: string | null; last_sync: string | null; + theme: string | null; } export interface AppConfig { diff --git a/crates/onyx-cli/src/commands/sync.rs b/crates/onyx-cli/src/commands/sync.rs index a20d0c2..2794266 100644 --- a/crates/onyx-cli/src/commands/sync.rs +++ b/crates/onyx-cli/src/commands/sync.rs @@ -39,7 +39,8 @@ pub fn setup(workspace_name: Option) -> Result<()> { output::info("Testing connection..."); let rt = tokio::runtime::Runtime::new().context("Failed to create async runtime")?; - let client = WebDavClient::new(&url, &username, &password); + let client = WebDavClient::new(&url, &username, &password) + .context("Invalid WebDAV URL")?; match rt.block_on(client.test_connection()) { Ok(()) => { diff --git a/crates/onyx-core/Cargo.toml b/crates/onyx-core/Cargo.toml index 3b5f2a8..79dc14f 100644 --- a/crates/onyx-core/Cargo.toml +++ b/crates/onyx-core/Cargo.toml @@ -23,6 +23,7 @@ quick-xml = { workspace = true } tokio = { workspace = true } keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"], optional = true } zeroize = "1" +log = "0.4" [dev-dependencies] tempfile = "3.0" diff --git a/crates/onyx-core/src/config.rs b/crates/onyx-core/src/config.rs index b13a2bd..8d22016 100644 --- a/crates/onyx-core/src/config.rs +++ b/crates/onyx-core/src/config.rs @@ -3,18 +3,35 @@ use std::path::PathBuf; use serde::{Deserialize, Serialize}; use crate::error::{Error, Result}; +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum WorkspaceMode { + Local, + Webdav, +} + +impl Default for WorkspaceMode { + fn default() -> Self { + Self::Local + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WorkspaceConfig { pub path: PathBuf, + #[serde(default)] + pub mode: WorkspaceMode, #[serde(skip_serializing_if = "Option::is_none", default)] pub webdav_url: Option, #[serde(skip_serializing_if = "Option::is_none", default)] pub last_sync: Option>, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub theme: Option, } impl WorkspaceConfig { pub fn new(path: PathBuf) -> Self { - Self { path, webdav_url: None, last_sync: None } + Self { path, mode: WorkspaceMode::Local, webdav_url: None, last_sync: None, theme: None } } } @@ -229,7 +246,7 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("config.json"); - // Write old-format JSON without webdav_url or last_sync fields + // Write old-format JSON without webdav_url, last_sync, mode, or theme fields let old_json = r#"{ "workspaces": { "personal": { "path": "/home/user/tasks" } @@ -243,5 +260,7 @@ mod tests { assert_eq!(ws.path, PathBuf::from("/home/user/tasks")); assert!(ws.webdav_url.is_none()); assert!(ws.last_sync.is_none()); + assert_eq!(ws.mode, WorkspaceMode::Local); + assert!(ws.theme.is_none()); } } diff --git a/crates/onyx-core/src/sync.rs b/crates/onyx-core/src/sync.rs index debb46a..1f627d2 100644 --- a/crates/onyx-core/src/sync.rs +++ b/crates/onyx-core/src/sync.rs @@ -510,7 +510,28 @@ pub async fn sync_workspace( mode: SyncMode, on_progress: Option, ) -> Result { - let client = WebDavClient::new(webdav_url, username, password); + // Wrap entire sync in a hard timeout — reqwest's built-in timeout + // doesn't reliably fire on Windows native TLS when the server is unreachable. + match tokio::time::timeout( + crate::webdav::REQUEST_TIMEOUT * 2, + sync_workspace_inner(workspace_path, webdav_url, username, password, mode, on_progress), + ).await { + Ok(result) => result, + Err(_) => Err(Error::WebDav("Sync timed out — server may be unreachable".into())), + } +} + +async fn sync_workspace_inner( + workspace_path: &Path, + webdav_url: &str, + username: &str, + password: &str, + mode: SyncMode, + on_progress: Option, +) -> Result { + // Sync into an "Onyx" subfolder so we don't scan the user's entire cloud storage + let sync_url = format!("{}/Onyx", webdav_url.trim_end_matches('/')); + let client = WebDavClient::new(&sync_url, username, password)?; let mut sync_state = SyncState::load(workspace_path); let queue = OfflineQueue::load(workspace_path); let mut result = SyncResult::default(); @@ -521,7 +542,8 @@ pub async fn sync_workspace( } }; - // Ensure remote root exists + // Ensure remote Onyx folder exists (creates it on first sync) + client.create_dir("").await.ok(); client.test_connection().await?; // Scan local files diff --git a/crates/onyx-core/src/webdav.rs b/crates/onyx-core/src/webdav.rs index 00df9d0..86e0d3c 100644 --- a/crates/onyx-core/src/webdav.rs +++ b/crates/onyx-core/src/webdav.rs @@ -1,7 +1,11 @@ use reqwest::Client; -use zeroize::Zeroize; +use zeroize::Zeroizing; +use std::time::Duration; use crate::error::{Error, Result}; +/// Hard timeout for any WebDAV network operation. +pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); + /// Information about a file on the remote WebDAV server. #[derive(Debug, Clone)] pub struct RemoteFileInfo { @@ -11,29 +15,34 @@ pub struct RemoteFileInfo { pub last_modified: Option, } -/// WebDAV client wrapping reqwest with basic auth. +/// WebDAV client wrapping reqwest with basic auth. Credentials are zeroized on drop. pub struct WebDavClient { _client: Client, _base_url: String, - _username: String, - _password: String, -} - -impl Drop for WebDavClient { - fn drop(&mut self) { - self._password.zeroize(); - self._username.zeroize(); - } + _username: Zeroizing, + _password: Zeroizing, } impl WebDavClient { - pub fn new(base_url: &str, username: &str, password: &str) -> Self { + /// Create a new WebDAV client. Rejects non-HTTPS URLs to prevent sending credentials in plaintext. + pub fn new(base_url: &str, username: &str, password: &str) -> Result { + if !base_url.starts_with("https://") { + return Err(Error::WebDav("Refusing non-HTTPS URL: credentials would be sent in plaintext".into())); + } + Ok(Self::new_unchecked(base_url, username, password)) + } + + fn new_unchecked(base_url: &str, username: &str, password: &str) -> Self { let base_url = base_url.trim_end_matches('/').to_string(); Self { - _client: Client::new(), + _client: Client::builder() + .timeout(Duration::from_secs(30)) + .connect_timeout(Duration::from_secs(10)) + .build() + .unwrap_or_else(|_| Client::new()), _base_url: base_url, - _username: username.to_string(), - _password: password.to_string(), + _username: Zeroizing::new(username.to_string()), + _password: Zeroizing::new(password.to_string()), } } @@ -56,7 +65,7 @@ impl WebDavClient { pub async fn test_connection(&self) -> Result<()> { let resp = self._client .request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &self._base_url) - .basic_auth(&self._username, Some(&self._password)) + .basic_auth(self._username.as_str(), Some(self._password.as_str())) .header("Depth", "0") .header("Content-Type", "application/xml") .body(PROPFIND_BODY) @@ -78,7 +87,7 @@ impl WebDavClient { let url = self.full_url(path); let resp = self._client .request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &url) - .basic_auth(&self._username, Some(&self._password)) + .basic_auth(self._username.as_str(), Some(self._password.as_str())) .header("Depth", "1") .header("Content-Type", "application/xml") .body(PROPFIND_BODY) @@ -99,7 +108,7 @@ impl WebDavClient { let url = self.full_url(path); let resp = self._client .get(&url) - .basic_auth(&self._username, Some(&self._password)) + .basic_auth(self._username.as_str(), Some(self._password.as_str())) .send() .await?; @@ -119,7 +128,7 @@ impl WebDavClient { let url = self.full_url(path); let resp = self._client .put(&url) - .basic_auth(&self._username, Some(&self._password)) + .basic_auth(self._username.as_str(), Some(self._password.as_str())) .body(content) .send() .await?; @@ -136,7 +145,7 @@ impl WebDavClient { let url = self.full_url(path); let resp = self._client .delete(&url) - .basic_auth(&self._username, Some(&self._password)) + .basic_auth(self._username.as_str(), Some(self._password.as_str())) .send() .await?; @@ -155,7 +164,7 @@ impl WebDavClient { let url = self.full_url(path); let resp = self._client .request(reqwest::Method::from_bytes(b"MKCOL").unwrap(), &url) - .basic_auth(&self._username, Some(&self._password)) + .basic_auth(self._username.as_str(), Some(self._password.as_str())) .send() .await?; @@ -388,20 +397,27 @@ fn extract_relative_path(href: &str, base_url: &str, request_path: &str) -> Stri // --- Credential Storage --- #[cfg(feature = "keyring-storage")] -/// Store WebDAV credentials in the platform keychain. +/// Store WebDAV credentials in the platform keychain. Password is scoped by domain+username +/// to prevent collisions when multiple accounts exist on the same server. pub fn store_credentials(domain: &str, username: &str, password: &str) -> Result<()> { let service = format!("com.onyx.webdav.{}", domain); + let scoped_service = format!("com.onyx.webdav.{}.{}", domain, username); let user_entry = keyring::Entry::new(&service, "username") .map_err(|e| Error::Credential(format!("Failed to create keyring entry: {}", e)))?; user_entry.set_password(username) .map_err(|e| Error::Credential(format!("Failed to store username: {}", e)))?; - let pass_entry = keyring::Entry::new(&service, "password") + let pass_entry = keyring::Entry::new(&scoped_service, "password") .map_err(|e| Error::Credential(format!("Failed to create keyring entry: {}", e)))?; pass_entry.set_password(password) .map_err(|e| Error::Credential(format!("Failed to store password: {}", e)))?; + // Clean up legacy unscoped password entry if present + if let Ok(legacy) = keyring::Entry::new(&service, "password") { + let _ = legacy.delete_credential(); + } + Ok(()) } @@ -413,16 +429,28 @@ pub fn store_credentials(_domain: &str, _username: &str, _password: &str) -> Res #[cfg(feature = "keyring-storage")] /// Load WebDAV credentials from the platform keychain, falling back to env vars. -pub fn load_credentials(domain: &str) -> Result<(String, String)> { +pub fn load_credentials(domain: &str) -> Result<(Zeroizing, Zeroizing)> { let service = format!("com.onyx.webdav.{}", domain); let user_entry = keyring::Entry::new(&service, "username") .map_err(|e| Error::Credential(format!("Failed to create keyring entry: {}", e)))?; - let pass_entry = keyring::Entry::new(&service, "password") - .map_err(|e| Error::Credential(format!("Failed to create keyring entry: {}", e)))?; - if let (Ok(user), Ok(pass)) = (user_entry.get_password(), pass_entry.get_password()) { - return Ok((user, pass)); + if let Ok(user) = user_entry.get_password() { + // Try scoped password key first (domain+username), fall back to legacy unscoped key + let scoped_service = format!("com.onyx.webdav.{}.{}", domain, user); + let pass = keyring::Entry::new(&scoped_service, "password") + .ok() + .and_then(|e| e.get_password().ok()) + .or_else(|| { + // Migration fallback: try legacy unscoped password entry + keyring::Entry::new(&service, "password") + .ok() + .and_then(|e| e.get_password().ok()) + }); + + if let Some(pass) = pass { + return Ok((Zeroizing::new(user), Zeroizing::new(pass))); + } } // Fallback to env vars for headless/CI environments @@ -430,27 +458,29 @@ pub fn load_credentials(domain: &str) -> Result<(String, String)> { std::env::var("ONYX_WEBDAV_USER"), std::env::var("ONYX_WEBDAV_PASS"), ) { - return Ok((user, pass)); + log::warn!("Using environment variables for WebDAV credentials — prefer keyring for better security"); + return Ok((Zeroizing::new(user), Zeroizing::new(pass))); } Err(Error::Credential(format!( - "No credentials found for '{}'. Run 'onyx sync --setup' or set ONYX_WEBDAV_USER and ONYX_WEBDAV_PASS.", + "No credentials found for '{}'. Run setup or configure environment variables.", domain ))) } #[cfg(not(feature = "keyring-storage"))] /// Load WebDAV credentials from env vars only (keyring not available). -pub fn load_credentials(domain: &str) -> Result<(String, String)> { +pub fn load_credentials(domain: &str) -> Result<(Zeroizing, Zeroizing)> { if let (Ok(user), Ok(pass)) = ( std::env::var("ONYX_WEBDAV_USER"), std::env::var("ONYX_WEBDAV_PASS"), ) { - return Ok((user, pass)); + log::warn!("Using environment variables for WebDAV credentials — these are visible to other processes on this system"); + return Ok((Zeroizing::new(user), Zeroizing::new(pass))); } Err(Error::Credential(format!( - "No credentials found for '{}'. Set ONYX_WEBDAV_USER and ONYX_WEBDAV_PASS.", + "No credentials found for '{}'. Configure environment variables.", domain ))) } @@ -460,10 +490,23 @@ pub fn load_credentials(domain: &str) -> Result<(String, String)> { pub fn delete_credentials(domain: &str) -> Result<()> { let service = format!("com.onyx.webdav.{}", domain); - if let Ok(entry) = keyring::Entry::new(&service, "username") { + // Load username first so we can delete the scoped password entry + let username = keyring::Entry::new(&service, "username") + .ok() + .and_then(|e| e.get_password().ok()); + + if let Some(user) = &username { + let scoped_service = format!("com.onyx.webdav.{}.{}", domain, user); + if let Ok(entry) = keyring::Entry::new(&scoped_service, "password") { + let _ = entry.delete_credential(); + } + } + + // Clean up legacy unscoped password and username entries + if let Ok(entry) = keyring::Entry::new(&service, "password") { let _ = entry.delete_credential(); } - if let Ok(entry) = keyring::Entry::new(&service, "password") { + if let Ok(entry) = keyring::Entry::new(&service, "username") { let _ = entry.delete_credential(); } @@ -640,9 +683,21 @@ mod tests { // --- WebDavClient URL building --- + #[test] + fn test_new_rejects_http() { + let result = WebDavClient::new("http://example.com/dav", "user", "pass"); + assert!(result.is_err()); + } + + #[test] + fn test_new_accepts_https() { + let result = WebDavClient::new("https://example.com/dav", "user", "pass"); + assert!(result.is_ok()); + } + #[test] fn test_full_url_building() { - let client = WebDavClient::new("http://example.com/dav/", "user", "pass"); + let client = WebDavClient::new_unchecked("http://example.com/dav/", "user", "pass"); assert_eq!(client.full_url(""), "http://example.com/dav"); assert_eq!(client.full_url("file.md"), "http://example.com/dav/file.md"); assert_eq!(client.full_url("My Tasks/Buy groceries.md"), "http://example.com/dav/My%20Tasks/Buy%20groceries.md"); @@ -650,7 +705,7 @@ mod tests { #[test] fn test_full_url_strips_leading_slash() { - let client = WebDavClient::new("http://example.com/dav", "user", "pass"); + let client = WebDavClient::new_unchecked("http://example.com/dav", "user", "pass"); assert_eq!(client.full_url("/file.md"), "http://example.com/dav/file.md"); }