From a60b1a997bbbdf0db85f091afc8f0972e675d2be Mon Sep 17 00:00:00 2001 From: Tristan Michael Date: Fri, 3 Apr 2026 03:58:01 -0700 Subject: [PATCH 01/10] feat: add WorkspaceMode (local/webdav) and per-workspace theme to config Introduces WorkspaceMode enum with local and webdav variants, plus a theme field on WorkspaceConfig. Adds set_workspace_theme and add_webdav_workspace Tauri commands. WebDAV workspaces auto-manage local files in app data dir. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/tauri/src-tauri/src/lib.rs | 69 +++++++++++++++++++++++++++------ apps/tauri/src/lib/types.ts | 4 ++ crates/onyx-core/src/config.rs | 23 ++++++++++- 3 files changed, 82 insertions(+), 14 deletions(-) diff --git a/apps/tauri/src-tauri/src/lib.rs b/apps/tauri/src-tauri/src/lib.rs index 1242a33..3c4efe8 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, } @@ -417,6 +418,53 @@ fn set_webdav_config( .map_err(|e| e.to_string()) } +#[tauri::command] +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] fn store_credentials( domain: String, @@ -545,23 +593,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 +634,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/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-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()); } } From a1e97bc0fe4d863ecc5f355deea01ab73d35cb03 Mon Sep 17 00:00:00 2001 From: Tristan Michael Date: Fri, 3 Apr 2026 03:58:06 -0700 Subject: [PATCH 02/10] feat: per-workspace theme system with 5 theme options Replaces in-memory darkMode toggle with persisted per-workspace theme selection. Adds dark, nord, dracula, and solarized CSS theme definitions. Theme is applied via data-theme attribute and derived isDark class. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/tauri/src/App.svelte | 4 +- apps/tauri/src/app.css | 65 +++++++++++++++++++++++++ apps/tauri/src/lib/stores/app.svelte.ts | 51 ++++++++++++++++--- 3 files changed, 110 insertions(+), 10 deletions(-) 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..2e94fc4 100644 --- a/apps/tauri/src/app.css +++ b/apps/tauri/src/app.css @@ -68,3 +68,68 @@ body { background-color: #242424; color: #e5e7eb; } + +/* ── Theme overrides ─────────────────────────────────────────────── */ + +[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/stores/app.svelte.ts b/apps/tauri/src/lib/stores/app.svelte.ts index fd42e50..b23e8b7 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() { @@ -311,8 +319,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 +381,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 +422,8 @@ export const app = { setGroupByDueDate, triggerSync, setSyncMode, - toggleDarkMode, + setTheme, + addWebdavWorkspace, setScreen, clearError, }; From 12afc91110144d9da30e7d2a43975d5cd08d9866 Mon Sep 17 00:00:00 2001 From: Tristan Michael Date: Fri, 3 Apr 2026 03:58:12 -0700 Subject: [PATCH 03/10] feat: setup mode selection, per-workspace settings in kebab menu Setup screen now offers Local vs WebDAV mode choice with cancel button when workspaces exist. Settings moved from drawer bottom into workspace kebab menu, scoped per-workspace. WebDAV section hidden for local workspaces, theme dropdown replaces dark mode toggle. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/lib/screens/SettingsScreen.svelte | 157 +++++------ apps/tauri/src/lib/screens/SetupScreen.svelte | 245 ++++++++++++++---- apps/tauri/src/lib/screens/TasksScreen.svelte | 35 +-- 3 files changed, 293 insertions(+), 144 deletions(-) diff --git a/apps/tauri/src/lib/screens/SettingsScreen.svelte b/apps/tauri/src/lib/screens/SettingsScreen.svelte index c932bc6..1576ff1 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)" > - +
From 2a2e362a8fa713f2b4f9abcea5b307377142956c Mon Sep 17 00:00:00 2001 From: Tristan Michael Date: Fri, 3 Apr 2026 10:11:40 -0700 Subject: [PATCH 04/10] =?UTF-8?q?fix:=20WebDAV=20sync=20=E2=80=94=20Onyx?= =?UTF-8?q?=20subfolder,=20timeouts,=20error=20display,=20light=20theme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 37 +++++++++++++++++++ Cargo.lock | 18 ++++----- apps/tauri/src/app.css | 15 ++++++++ .../src/lib/screens/SettingsScreen.svelte | 3 ++ apps/tauri/src/lib/stores/app.svelte.ts | 15 +------- 5 files changed, 65 insertions(+), 23 deletions(-) create mode 100644 .claude/settings.local.json 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..355c147 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1144,7 +1144,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.52.0", ] [[package]] @@ -1304,9 +1304,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 +1347,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 +2335,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/app.css b/apps/tauri/src/app.css index 2e94fc4..99c17b1 100644 --- a/apps/tauri/src/app.css +++ b/apps/tauri/src/app.css @@ -71,6 +71,21 @@ body { /* ── 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; diff --git a/apps/tauri/src/lib/screens/SettingsScreen.svelte b/apps/tauri/src/lib/screens/SettingsScreen.svelte index 1576ff1..0257973 100644 --- a/apps/tauri/src/lib/screens/SettingsScreen.svelte +++ b/apps/tauri/src/lib/screens/SettingsScreen.svelte @@ -138,6 +138,9 @@ {app.syncing ? "Syncing..." : "Sync Now"} + {#if app.error} +

{app.error}

+ {/if} {#if ws?.last_sync} {@const lastSync = new Date(ws.last_sync)} {@const secsAgo = Math.floor((Date.now() - lastSync.getTime()) / 1000)} diff --git a/apps/tauri/src/lib/stores/app.svelte.ts b/apps/tauri/src/lib/stores/app.svelte.ts index b23e8b7..a76aeb5 100644 --- a/apps/tauri/src/lib/stores/app.svelte.ts +++ b/apps/tauri/src/lib/stores/app.svelte.ts @@ -282,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) { From ef266f9b0d84d1987463891e4cc83133ea23013f Mon Sep 17 00:00:00 2001 From: Tristan Michael Date: Fri, 3 Apr 2026 09:48:53 -0700 Subject: [PATCH 05/10] Relax reqwest features in Cargo.toml Remove the custom default-features and rustls-tls feature flags for reqwest in Cargo.toml. This simplifies the dependency specification and avoids potential TLS configuration or feature mismatch issues that could make HTTP requests behave unexpectedly (e.g., reachability or URL formatting issues). The change restores reqwest to its simpler default feature set to resolve connectivity confusion. --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index ff4f4c3..47e21de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,6 @@ uuid = { version = "1.0", features = ["serde", "v4"] } chrono = { version = "0.4", features = ["serde"] } anyhow = "1.0" tokio = { version = "1.40", features = ["full"] } -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } +reqwest = { version = "0.12" } sha2 = "0.10" quick-xml = "0.36" From d03cc92a539ddb43afcb31a391d90a25a3cc1af2 Mon Sep 17 00:00:00 2001 From: Tristan Michael Date: Fri, 3 Apr 2026 10:03:12 -0700 Subject: [PATCH 06/10] Use reqwest with rustls-tls and disable default features Enable rustls TLS support and disable reqwest's default features to avoid pulling in unwanted native TLS dependencies. This ensures the application uses rustls for TLS connections and keeps the build more portable and consistent across platforms. --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 47e21de..ff4f4c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,6 @@ uuid = { version = "1.0", features = ["serde", "v4"] } chrono = { version = "0.4", features = ["serde"] } anyhow = "1.0" tokio = { version = "1.40", features = ["full"] } -reqwest = { version = "0.12" } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } sha2 = "0.10" quick-xml = "0.36" From 0c4073c99847aac7ca03ca734a6c2eccbd243d7f Mon Sep 17 00:00:00 2001 From: Tristan Michael Date: Fri, 3 Apr 2026 08:56:24 -0700 Subject: [PATCH 07/10] security: harden credential management in onyx-core - Enforce HTTPS for WebDAV URLs (reject http:// to prevent plaintext credentials) - Replace String with Zeroizing for credential fields and load_credentials return - Remove manual Drop impl (Zeroizing handles zeroize-on-drop automatically) - Scope keyring password entries by domain+username to prevent collisions - Add migration fallback for legacy unscoped keyring entries - Sanitize error messages to not leak keyring service patterns or env var names - Add log warnings when falling back to env var credentials - Add log dependency to onyx-core Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/onyx-core/Cargo.toml | 1 + crates/onyx-core/src/webdav.rs | 125 +++++++++++++++++++++++---------- 2 files changed, 89 insertions(+), 37 deletions(-) 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/webdav.rs b/crates/onyx-core/src/webdav.rs index 00df9d0..a38b80d 100644 --- a/crates/onyx-core/src/webdav.rs +++ b/crates/onyx-core/src/webdav.rs @@ -1,5 +1,5 @@ use reqwest::Client; -use zeroize::Zeroize; +use zeroize::Zeroizing; use crate::error::{Error, Result}; /// Information about a file on the remote WebDAV server. @@ -11,29 +11,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(std::time::Duration::from_secs(30)) + .connect_timeout(std::time::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 +61,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 +83,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 +104,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 +124,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 +141,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 +160,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 +393,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 +425,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 +454,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 +486,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 +679,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 +701,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"); } From be6b8d0d9031016247e75a69da0f0e136fc51f32 Mon Sep 17 00:00:00 2001 From: Tristan Michael Date: Fri, 3 Apr 2026 08:56:32 -0700 Subject: [PATCH 08/10] security: update callers for hardened credential API - Handle Result from WebDavClient::new in CLI sync, core sync, and Tauri - Unwrap Zeroizing at Tauri serialization boundary - Use .as_str() for basic_auth calls with Zeroizing fields Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/tauri/src-tauri/src/lib.rs | 25 ++++++++++++++++++++++--- crates/onyx-cli/src/commands/sync.rs | 3 ++- crates/onyx-core/src/sync.rs | 2 +- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/apps/tauri/src-tauri/src/lib.rs b/apps/tauri/src-tauri/src/lib.rs index 3c4efe8..05bf21e 100644 --- a/apps/tauri/src-tauri/src/lib.rs +++ b/apps/tauri/src-tauri/src/lib.rs @@ -99,6 +99,13 @@ fn repo_mut(state: &mut AppState) -> Result<&mut TaskRepository, String> { state.repo.as_mut().ok_or_else(|| "Repository not initialized".to_string()) } +// ── Debug ─────────────────────────────────────────────────────────── + +#[tauri::command] +fn log_debug(msg: String) { + eprintln!("[frontend] {msg}"); +} + // ── Config commands ────────────────────────────────────────────────── #[tauri::command] @@ -476,7 +483,9 @@ fn store_credentials( #[tauri::command] fn load_credentials(domain: String) -> Result<(String, String), String> { - webdav::load_credentials(&domain).map_err(|e| e.to_string()) + webdav::load_credentials(&domain) + .map(|(u, p)| ((*u).clone(), (*p).clone())) + .map_err(|e| e.to_string()) } #[tauri::command] @@ -485,7 +494,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 @@ -507,6 +517,7 @@ async fn sync_workspace( "pull" => SyncMode::Pull, _ => SyncMode::Full, }; + eprintln!("[sync] starting sync: workspace={workspace_name} path={workspace_path} url={webdav_url} mode={mode}"); let result = sync::sync_workspace( &PathBuf::from(&workspace_path), &webdav_url, @@ -516,15 +527,22 @@ async fn sync_workspace( None, ) .await - .map_err(|e| e.to_string())?; + .map_err(|e| { + eprintln!("[sync] sync_workspace error: {e}"); + e.to_string() + })?; + eprintln!("[sync] sync complete: uploaded={} downloaded={} errors={}", result.uploaded, result.downloaded, result.errors.len()); // Persist last_sync timestamp to config { + eprintln!("[sync] acquiring state lock..."); let mut s = lock_state(&state)?; + eprintln!("[sync] lock acquired, saving config..."); if let Some(ws) = s.config.workspaces.get_mut(&workspace_name) { ws.last_sync = Some(Utc::now()); } s.config.save_to_file(&s.config_path.clone()).map_err(|e| e.to_string())?; + eprintln!("[sync] config saved"); } Ok(result.into()) @@ -614,6 +632,7 @@ pub fn run() { Ok(()) }) .invoke_handler(tauri::generate_handler![ + log_debug, get_config, save_config, add_workspace, 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/src/sync.rs b/crates/onyx-core/src/sync.rs index debb46a..9dfbd4e 100644 --- a/crates/onyx-core/src/sync.rs +++ b/crates/onyx-core/src/sync.rs @@ -510,7 +510,7 @@ pub async fn sync_workspace( mode: SyncMode, on_progress: Option, ) -> Result { - let client = WebDavClient::new(webdav_url, username, password); + let client = WebDavClient::new(webdav_url, username, password)?; let mut sync_state = SyncState::load(workspace_path); let queue = OfflineQueue::load(workspace_path); let mut result = SyncResult::default(); From 9e57f1df3c566557a27fe20d7c232362f38b18c6 Mon Sep 17 00:00:00 2001 From: Tristan Michael Date: Fri, 3 Apr 2026 08:56:38 -0700 Subject: [PATCH 09/10] chore: update lock files for log dependency Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 1 + apps/tauri/src-tauri/Cargo.lock | 1 + 2 files changed, 2 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 355c147..f72af96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -990,6 +990,7 @@ dependencies = [ "chrono", "directories", "keyring", + "log", "quick-xml", "reqwest", "serde", 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", From 58f37b08d6ef85bca3504e1cf7bcea52ebc95205 Mon Sep 17 00:00:00 2001 From: Tristan Michael Date: Fri, 3 Apr 2026 10:11:46 -0700 Subject: [PATCH 10/10] =?UTF-8?q?fix:=20harden=20WebDAV=20sync=20=E2=80=94?= =?UTF-8?q?=20async=20credentials,=20consolidated=20command,=20Onyx=20subf?= =?UTF-8?q?older?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/tauri/src-tauri/src/lib.rs | 68 +++++++++++++++++++-------------- crates/onyx-core/src/sync.rs | 26 ++++++++++++- crates/onyx-core/src/webdav.rs | 8 +++- 3 files changed, 69 insertions(+), 33 deletions(-) diff --git a/apps/tauri/src-tauri/src/lib.rs b/apps/tauri/src-tauri/src/lib.rs index 05bf21e..6a43333 100644 --- a/apps/tauri/src-tauri/src/lib.rs +++ b/apps/tauri/src-tauri/src/lib.rs @@ -99,13 +99,6 @@ fn repo_mut(state: &mut AppState) -> Result<&mut TaskRepository, String> { state.repo.as_mut().ok_or_else(|| "Repository not initialized".to_string()) } -// ── Debug ─────────────────────────────────────────────────────────── - -#[tauri::command] -fn log_debug(msg: String) { - eprintln!("[frontend] {msg}"); -} - // ── Config commands ────────────────────────────────────────────────── #[tauri::command] @@ -473,19 +466,27 @@ fn add_webdav_workspace( } #[tauri::command] -fn store_credentials( +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(|(u, p)| ((*u).clone(), (*p).clone())) - .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] @@ -505,21 +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, }; - eprintln!("[sync] starting sync: workspace={workspace_name} path={workspace_path} url={webdav_url} mode={mode}"); let result = sync::sync_workspace( - &PathBuf::from(&workspace_path), + &workspace_path, &webdav_url, &username, &password, @@ -527,22 +546,14 @@ async fn sync_workspace( None, ) .await - .map_err(|e| { - eprintln!("[sync] sync_workspace error: {e}"); - e.to_string() - })?; - eprintln!("[sync] sync complete: uploaded={} downloaded={} errors={}", result.uploaded, result.downloaded, result.errors.len()); + .map_err(|e| e.to_string())?; - // Persist last_sync timestamp to config { - eprintln!("[sync] acquiring state lock..."); let mut s = lock_state(&state)?; - eprintln!("[sync] lock acquired, saving config..."); if let Some(ws) = s.config.workspaces.get_mut(&workspace_name) { ws.last_sync = Some(Utc::now()); } s.config.save_to_file(&s.config_path.clone()).map_err(|e| e.to_string())?; - eprintln!("[sync] config saved"); } Ok(result.into()) @@ -632,7 +643,6 @@ pub fn run() { Ok(()) }) .invoke_handler(tauri::generate_handler![ - log_debug, get_config, save_config, add_workspace, diff --git a/crates/onyx-core/src/sync.rs b/crates/onyx-core/src/sync.rs index 9dfbd4e..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 a38b80d..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::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 { @@ -32,8 +36,8 @@ impl WebDavClient { let base_url = base_url.trim_end_matches('/').to_string(); Self { _client: Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .connect_timeout(std::time::Duration::from_secs(10)) + .timeout(Duration::from_secs(30)) + .connect_timeout(Duration::from_secs(10)) .build() .unwrap_or_else(|_| Client::new()), _base_url: base_url,