Merge pull request #25 from SteelDynamite/fix/webdav-sync-and-themes

fix/webdav-sync-and-themes
This commit is contained in:
SteelDynamite 2026-04-03 10:32:58 -07:00 committed by GitHub
commit c4df1413dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 711 additions and 242 deletions

View file

@ -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"
}
]
}
]
}
}

19
Cargo.lock generated
View file

@ -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",

View file

@ -2386,6 +2386,7 @@ dependencies = [
"chrono",
"directories",
"keyring",
"log",
"quick-xml 0.36.2",
"reqwest 0.12.28",
"serde",

View file

@ -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<Option<Instant>> = Mutex::new(None);
struct AppState {
config: AppConfig,
config_path: PathBuf,
app_data_dir: PathBuf,
repo: Option<TaskRepository>,
}
@ -418,17 +419,74 @@ fn set_webdav_config(
}
#[tauri::command]
fn store_credentials(
fn set_workspace_theme(
workspace_name: String,
theme: Option<String>,
state: State<'_, Mutex<AppState>>,
) -> 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<AppState>>,
) -> 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<AppState>>,
) -> Result<SyncResult, String> {
// 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,

View file

@ -12,7 +12,7 @@
});
</script>
<div class={app.darkMode ? "dark" : ""}>
<div class={app.isDark ? "dark" : ""} data-theme={app.currentTheme ?? ""}>
<div class="h-screen w-screen" class:p-2={isLinux}>
<div
class="relative h-full w-full overflow-hidden bg-surface-light text-text-light dark:bg-surface-dark dark:text-text-dark"
@ -30,7 +30,7 @@
{/if}
{#if app.screen === "setup"}
<SetupScreen />
<SetupScreen cancellable={app.hasWorkspace} />
{:else}
<TasksScreen />
{/if}

View file

@ -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;
}

View file

@ -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();
}
</script>
<header
class="flex items-center justify-between border-b border-border-light px-4 py-3 dark:border-border-dark"
>
<h1 class="text-lg font-bold">Settings</h1>
<h1 class="text-lg font-bold">{workspaceName} Settings</h1>
<button
onclick={() => onclose?.()}
class="rounded-lg p-1.5 hover:bg-black/5 dark:hover:bg-white/10"
@ -75,53 +73,53 @@
</header>
<main class="flex-1 overflow-y-auto p-4">
<!-- WebDAV Sync -->
<section class="mb-6">
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide opacity-50">
WebDAV Sync
</h2>
<div class="rounded-xl border border-border-light p-4 dark:border-border-dark">
<label class="mb-1 block text-xs font-medium opacity-60">Server URL</label>
<input
type="url"
bind:value={webdavUrl}
placeholder="https://dav.example.com/tasks/"
class="mb-3 w-full rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm outline-none focus:border-primary dark:border-border-dark"
/>
<!-- WebDAV Sync (only for webdav workspaces) -->
{#if isWebdav}
<section class="mb-6">
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide opacity-50">
WebDAV Sync
</h2>
<div class="rounded-xl border border-border-light p-4 dark:border-border-dark">
<label class="mb-1 block text-xs font-medium opacity-60">Server URL</label>
<input
type="url"
bind:value={webdavUrl}
placeholder="https://dav.example.com/tasks/"
class="mb-3 w-full rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm outline-none focus:border-primary dark:border-border-dark"
/>
<label class="mb-1 block text-xs font-medium opacity-60">Username</label>
<input
type="text"
bind:value={webdavUser}
class="mb-3 w-full rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm outline-none focus:border-primary dark:border-border-dark"
/>
<label class="mb-1 block text-xs font-medium opacity-60">Username</label>
<input
type="text"
bind:value={webdavUser}
class="mb-3 w-full rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm outline-none focus:border-primary dark:border-border-dark"
/>
<label class="mb-1 block text-xs font-medium opacity-60">Password</label>
<input
type="password"
bind:value={webdavPass}
class="mb-4 w-full rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm outline-none focus:border-primary dark:border-border-dark"
/>
<label class="mb-1 block text-xs font-medium opacity-60">Password</label>
<input
type="password"
bind:value={webdavPass}
class="mb-4 w-full rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm outline-none focus:border-primary dark:border-border-dark"
/>
<div class="flex gap-2">
<button
onclick={testConnection}
disabled={!webdavUrl.trim()}
class="rounded-lg border border-border-light px-4 py-2 text-sm font-medium hover:bg-black/5 disabled:opacity-40 dark:border-border-dark dark:hover:bg-white/10"
>
{testStatus === "testing" ? "Testing…" : testStatus === "ok" ? "Connected" : testStatus === "fail" ? "Failed — Retry" : "Test Connection"}
</button>
<button
onclick={saveWebdav}
disabled={!webdavUrl.trim()}
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary-hover disabled:opacity-40"
>
Save
</button>
<div class="flex gap-2">
<button
onclick={testConnection}
disabled={!webdavUrl.trim()}
class="rounded-lg border border-border-light px-4 py-2 text-sm font-medium hover:bg-black/5 disabled:opacity-40 dark:border-border-dark dark:hover:bg-white/10"
>
{testStatus === "testing" ? "Testing..." : testStatus === "ok" ? "Connected" : testStatus === "fail" ? "Failed -- Retry" : "Test Connection"}
</button>
<button
onclick={saveWebdav}
disabled={!webdavUrl.trim()}
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary-hover disabled:opacity-40"
>
Save
</button>
</div>
</div>
</div>
{#if app.config?.current_workspace}
<div class="mt-3 flex items-center gap-2">
<select
value={app.syncMode}
@ -137,11 +135,14 @@
disabled={app.syncing}
class="flex-1 rounded-lg bg-primary py-2 text-sm font-medium text-white hover:bg-primary-hover disabled:opacity-40"
>
{app.syncing ? "Syncing" : "Sync Now"}
{app.syncing ? "Syncing..." : "Sync Now"}
</button>
</div>
{#if app.config.workspaces[app.config.current_workspace]?.last_sync}
{@const lastSync = new Date(app.config.workspaces[app.config.current_workspace].last_sync!)}
{#if app.error}
<p class="mt-1.5 text-xs text-danger">{app.error}</p>
{/if}
{#if ws?.last_sync}
{@const lastSync = new Date(ws.last_sync)}
{@const secsAgo = Math.floor((Date.now() - lastSync.getTime()) / 1000)}
{@const relTime = secsAgo < 60 ? "just now" : secsAgo < 3600 ? `${Math.floor(secsAgo / 60)}m ago` : `${Math.floor(secsAgo / 3600)}h ago`}
<p class="mt-1.5 text-xs opacity-40">
@ -151,27 +152,32 @@
{/if}
</p>
{/if}
{/if}
</section>
</section>
{/if}
<!-- Theme -->
<section>
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide opacity-50">
Appearance
</h2>
<button
onclick={() => app.toggleDarkMode()}
class="flex w-full items-center justify-between rounded-xl border border-border-light p-4 dark:border-border-dark"
>
<span class="text-sm font-medium">Dark mode</span>
<div
class="h-6 w-11 rounded-full transition-colors {app.darkMode ? 'bg-primary' : 'bg-gray-300 dark:bg-gray-600'}"
<div class="rounded-xl border border-border-light p-4 dark:border-border-dark">
<label class="mb-1 block text-xs font-medium opacity-60">Theme</label>
<select
value={ws?.theme ?? ""}
onchange={(e) => {
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"
>
<div
class="h-5 w-5 translate-y-0.5 rounded-full bg-white shadow transition-transform {app.darkMode ? 'translate-x-5.5' : 'translate-x-0.5'}"
></div>
</div>
</button>
<option value="">System default</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="nord">Nord</option>
<option value="dracula">Dracula</option>
<option value="solarized">Solarized Dark</option>
</select>
</div>
</section>
<p class="mt-8 text-center text-xs opacity-30">Tauri v2 + Svelte</p>

View file

@ -1,16 +1,25 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog";
import { app } from "../stores/app.svelte";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { platform } from "@tauri-apps/plugin-os";
let { cancellable = false }: { cancellable?: boolean } = $props();
const appWindow = getCurrentWindow();
const currentPlatform = platform();
const isDesktop = currentPlatform === "linux" || currentPlatform === "windows";
const isWindows = currentPlatform === "windows";
const isMobile = currentPlatform === "android" || currentPlatform === "ios";
let mode = $state<"local" | "webdav" | null>(isMobile ? "webdav" : null);
let name = $state("");
let path = $state("");
let webdavUrl = $state("");
let webdavUser = $state("");
let webdavPass = $state("");
let testStatus = $state<"idle" | "testing" | "ok" | "fail">("idle");
async function pickFolder() {
const selected = await open({ directory: true, multiple: false });
@ -26,23 +35,63 @@
const selected = await open({ directory: true, multiple: false });
if (!selected) return;
const folder = selected as string;
// Derive workspace name from folder name
const parts = folder.replace(/\\/g, "/").split("/");
const wsName = parts[parts.length - 1] || "workspace";
await app.addWorkspace(wsName, folder);
}
async function testConnection() {
testStatus = "testing";
try {
await invoke("test_webdav_connection", {
url: webdavUrl,
username: webdavUser,
password: webdavPass,
});
testStatus = "ok";
} catch {
testStatus = "fail";
}
}
async function handleCreateWebdav() {
if (!name.trim() || !webdavUrl.trim()) return;
await app.addWebdavWorkspace(name.trim(), webdavUrl.trim(), webdavUser, webdavPass);
}
function handleDrag(e: MouseEvent) {
if (e.button !== 0) return;
if ((e.target as HTMLElement).closest("button, input")) return;
if (isDesktop) appWindow.startDragging();
}
function goBack() {
mode = null;
name = "";
path = "";
webdavUrl = "";
webdavUser = "";
webdavPass = "";
testStatus = "idle";
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="flex h-full flex-col" onmousedown={handleDrag}>
<!-- Title bar area with window controls -->
<header class="flex h-11 shrink-0 items-center justify-end px-2">
<header class="flex h-11 shrink-0 items-center justify-between px-2">
<div>
{#if cancellable}
<button
onclick={() => app.setScreen("tasks")}
class="rounded-lg p-1.5 opacity-50 hover:bg-black/10 hover:opacity-80 dark:hover:bg-white/10"
>
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M17 10a.75.75 0 01-.75.75H5.612l4.158 3.96a.75.75 0 11-1.04 1.08l-5.5-5.25a.75.75 0 010-1.08l5.5-5.25a.75.75 0 111.04 1.08L5.612 9.25H16.25A.75.75 0 0117 10z" />
</svg>
</button>
{/if}
</div>
{#if isDesktop}
<div class="flex items-center gap-0.5">
{#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"
>
<h1 class="mb-1 text-2xl font-bold">Onyx</h1>
<p class="mb-6 text-sm text-text-secondary-light dark:text-text-secondary-dark">
Create a new workspace or open an existing one.
</p>
<label class="mb-1 block text-sm font-medium">
Workspace name
<input
type="text"
bind:value={name}
placeholder="My Tasks"
class="mt-1 mb-4 w-full rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm font-normal outline-none focus:border-primary dark:border-border-dark"
/>
</label>
{#if mode === null}
<!-- Step 1: Choose mode -->
<p class="mb-6 text-sm text-text-secondary-light dark:text-text-secondary-dark">
How would you like to store your tasks?
</p>
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class="mb-1 block text-sm font-medium">Folder</label>
<div class="mb-6 flex gap-2">
<input
type="text"
bind:value={path}
readonly
placeholder="Select a folder…"
class="min-w-0 flex-1 rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm dark:border-border-dark"
/>
<button
onclick={pickFolder}
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary-hover"
onclick={() => (mode = "local")}
class="mb-3 w-full rounded-xl border border-border-light p-4 text-left hover:bg-black/5 dark:border-border-dark dark:hover:bg-white/10"
>
Browse
<p class="text-sm font-semibold">Local Folder</p>
<p class="mt-0.5 text-xs text-text-secondary-light dark:text-text-secondary-dark">
Pick a folder on your computer. Files stay local.
</p>
</button>
</div>
<button
onclick={handleCreate}
disabled={!name.trim() || !path.trim()}
class="w-full rounded-lg bg-primary py-2.5 text-sm font-medium text-white hover:bg-primary-hover disabled:opacity-40"
>
Create Workspace
</button>
<button
onclick={() => (mode = "webdav")}
class="w-full rounded-xl border border-border-light p-4 text-left hover:bg-black/5 dark:border-border-dark dark:hover:bg-white/10"
>
<p class="text-sm font-semibold">WebDAV Server</p>
<p class="mt-0.5 text-xs text-text-secondary-light dark:text-text-secondary-dark">
Connect to a WebDAV server. The app manages local files automatically.
</p>
</button>
<div class="my-4 flex items-center gap-3">
<div class="h-px flex-1 bg-border-light dark:bg-border-dark"></div>
<span class="text-xs opacity-40">or</span>
<div class="h-px flex-1 bg-border-light dark:bg-border-dark"></div>
</div>
{:else if mode === "local"}
<!-- Step 2a: Local workspace -->
<p class="mb-6 text-sm text-text-secondary-light dark:text-text-secondary-dark">
Create a new workspace or open an existing one.
</p>
<button
onclick={handleOpen}
class="w-full rounded-lg border border-border-light py-2.5 text-sm font-medium hover:bg-black/5 dark:border-border-dark dark:hover:bg-white/10"
>
Open Existing Folder
</button>
<label class="mb-1 block text-sm font-medium">
Workspace name
<input
type="text"
bind:value={name}
placeholder="My Tasks"
class="mt-1 mb-4 w-full rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm font-normal outline-none focus:border-primary dark:border-border-dark"
/>
</label>
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class="mb-1 block text-sm font-medium">Folder</label>
<div class="mb-6 flex gap-2">
<input
type="text"
bind:value={path}
readonly
placeholder="Select a folder..."
class="min-w-0 flex-1 rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm dark:border-border-dark"
/>
<button
onclick={pickFolder}
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary-hover"
>
Browse
</button>
</div>
<button
onclick={handleCreate}
disabled={!name.trim() || !path.trim()}
class="w-full rounded-lg bg-primary py-2.5 text-sm font-medium text-white hover:bg-primary-hover disabled:opacity-40"
>
Create Workspace
</button>
<div class="my-4 flex items-center gap-3">
<div class="h-px flex-1 bg-border-light dark:bg-border-dark"></div>
<span class="text-xs opacity-40">or</span>
<div class="h-px flex-1 bg-border-light dark:bg-border-dark"></div>
</div>
<button
onclick={handleOpen}
class="mb-3 w-full rounded-lg border border-border-light py-2.5 text-sm font-medium hover:bg-black/5 dark:border-border-dark dark:hover:bg-white/10"
>
Open Existing Folder
</button>
{#if !isMobile}
<button
onclick={goBack}
class="w-full rounded-lg py-2 text-sm opacity-50 hover:opacity-80"
>
Back
</button>
{/if}
{:else}
<!-- Step 2b: WebDAV workspace -->
<p class="mb-6 text-sm text-text-secondary-light dark:text-text-secondary-dark">
Connect to a WebDAV server for cloud-synced tasks.
</p>
<label class="mb-1 block text-sm font-medium">
Workspace name
<input
type="text"
bind:value={name}
placeholder="My Tasks"
class="mt-1 mb-4 w-full rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm font-normal outline-none focus:border-primary dark:border-border-dark"
/>
</label>
<label class="mb-1 block text-xs font-medium opacity-60">Server URL</label>
<input
type="url"
bind:value={webdavUrl}
placeholder="https://dav.example.com/tasks/"
class="mb-3 w-full rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm outline-none focus:border-primary dark:border-border-dark"
/>
<label class="mb-1 block text-xs font-medium opacity-60">Username</label>
<input
type="text"
bind:value={webdavUser}
class="mb-3 w-full rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm outline-none focus:border-primary dark:border-border-dark"
/>
<label class="mb-1 block text-xs font-medium opacity-60">Password</label>
<input
type="password"
bind:value={webdavPass}
class="mb-4 w-full rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm outline-none focus:border-primary dark:border-border-dark"
/>
<div class="mb-4 flex gap-2">
<button
onclick={testConnection}
disabled={!webdavUrl.trim()}
class="rounded-lg border border-border-light px-4 py-2 text-sm font-medium hover:bg-black/5 disabled:opacity-40 dark:border-border-dark dark:hover:bg-white/10"
>
{testStatus === "testing" ? "Testing..." : testStatus === "ok" ? "Connected" : testStatus === "fail" ? "Failed -- Retry" : "Test Connection"}
</button>
</div>
<button
onclick={handleCreateWebdav}
disabled={!name.trim() || !webdavUrl.trim()}
class="w-full rounded-lg bg-primary py-2.5 text-sm font-medium text-white hover:bg-primary-hover disabled:opacity-40"
>
Create Workspace
</button>
{#if !isMobile}
<button
onclick={goBack}
class="mt-3 w-full rounded-lg py-2 text-sm opacity-50 hover:opacity-80"
>
Back
</button>
{/if}
{/if}
</div>
</div>
</div>

View file

@ -35,6 +35,7 @@
let showDrawer = $state(false);
let showSettings = $state(false);
let settingsWorkspace = $state<string | null>(null);
let showNewList = $state(false);
let showWorkspacePicker = $state(false);
let workspacePickerEl = $state<HTMLDivElement | null>(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}
<div class="min-w-0 flex-1">
<p class="truncate text-sm">{name}</p>
<p class="truncate text-xs opacity-40">{ws?.path ?? ""}</p>
<p class="truncate text-xs opacity-40">{ws?.mode === "webdav" ? ws.webdav_url ?? "WebDAV" : ws?.path ?? ""}</p>
</div>
</button>
<div class="relative shrink-0" data-ws-menu>
@ -270,6 +268,15 @@
</button>
{#if wsMenuName === name}
<div class="absolute right-0 top-full z-40 mt-1 min-w-[140px] rounded-lg border border-border-light bg-surface-light py-1 shadow-lg dark:border-border-dark dark:bg-surface-dark">
<button
onclick={() => { wsMenuName = null; settingsWorkspace = name; showSettings = true; showWorkspacePicker = false; }}
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-black/5 dark:hover:bg-white/10"
>
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd" />
</svg>
Settings
</button>
<button
onclick={() => { wsMenuName = null; confirmRemoveWorkspace = name; }}
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-danger hover:bg-black/5 dark:hover:bg-white/10"
@ -347,20 +354,6 @@
</div>
</div>
<!-- Settings -->
<button
onclick={openSettings}
class="flex shrink-0 items-center gap-2 border-t border-border-light px-5 py-3 text-sm opacity-50 hover:bg-black/5 hover:opacity-80 dark:border-border-dark dark:hover:bg-white/10"
>
<svg class="h-4.5 w-4.5" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z"
clip-rule="evenodd"
/>
</svg>
Settings
</button>
</div>
<!-- Main content panel -->
@ -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)"
>
<SettingsScreen onclose={closeSettings} />
<SettingsScreen onclose={closeSettings} workspaceName={settingsWorkspace ?? app.config?.current_workspace ?? ""} />
</div>
</div>

View file

@ -20,9 +20,7 @@ let config = $state<AppConfig | null>(null);
let lists = $state<TaskList[]>([]);
let activeListId = $state<string | null>(null);
let tasks = $state<Task[]>([]);
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<SyncResult | null>(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<SyncResult>("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<AppConfig>("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<AppConfig>("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<AppConfig>("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,
};

View file

@ -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 {

View file

@ -39,7 +39,8 @@ pub fn setup(workspace_name: Option<String>) -> 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(()) => {

View file

@ -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"

View file

@ -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<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub last_sync: Option<chrono::DateTime<chrono::Utc>>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub theme: Option<String>,
}
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());
}
}

View file

@ -510,7 +510,28 @@ pub async fn sync_workspace(
mode: SyncMode,
on_progress: Option<ProgressCallback>,
) -> Result<SyncResult> {
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<ProgressCallback>,
) -> Result<SyncResult> {
// 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

View file

@ -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<String>,
}
/// 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<String>,
_password: Zeroizing<String>,
}
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<Self> {
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<String>, Zeroizing<String>)> {
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<String>, Zeroizing<String>)> {
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");
}