Merge pull request #30 from SteelDynamite/workspace-settings-overhaul

workspace-settings-overhaul
This commit is contained in:
SteelDynamite 2026-04-05 19:24:27 -07:00 committed by GitHub
commit ee8721e71a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
70 changed files with 4485 additions and 694 deletions

View file

@ -1,37 +0,0 @@
{
"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"
}
]
}
]
}
}

View file

@ -1,37 +0,0 @@
{
"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"
}
]
}
]
}
}

View file

@ -31,19 +31,19 @@ Two-crate workspace (`resolver = "2"`, edition 2021) plus a Tauri app:
- **onyx-core** — Pure Rust library. Storage trait with `FileSystemStorage` implementation, `TaskRepository` (main API), data models, config, error types. No CLI/UI dependencies. `keyring` feature-gated behind `keyring-storage` (default on) for Android compatibility. - **onyx-core** — Pure Rust library. Storage trait with `FileSystemStorage` implementation, `TaskRepository` (main API), data models, config, error types. No CLI/UI dependencies. `keyring` feature-gated behind `keyring-storage` (default on) for Android compatibility.
- **onyx-cli** — CLI frontend using clap. Commands are in `src/commands/` (init, workspace, list, task, group). Output formatting in `src/output.rs`. - **onyx-cli** — CLI frontend using clap. Commands are in `src/commands/` (init, workspace, list, task, group). Output formatting in `src/output.rs`.
- **apps/tauri/** — Tauri v2 GUI. Svelte 5 frontend in `src/`, Rust backend in `src-tauri/` with Tauri commands that call into `onyx-core`. `notify` crate feature-gated for Android. - **apps/tauri/** — Tauri v2 GUI. Svelte 5 frontend in `src/`, Rust backend in `src-tauri/` with Tauri commands that call into `onyx-core`. `notify` crate feature-gated for Android. `tauri-plugin-credentials/` provides cross-platform credential storage (Android Keystore via EncryptedSharedPreferences, desktop via keyring crate).
### Key patterns ### Key patterns
- **Storage trait** (`storage.rs`): Strategy pattern for task persistence. `FileSystemStorage` reads/writes markdown files with YAML frontmatter and JSON metadata files. - **Storage trait** (`storage.rs`): Strategy pattern for task persistence. `FileSystemStorage` reads/writes markdown files with YAML frontmatter and JSON metadata files.
- **Repository** (`repository.rs`): `TaskRepository` wraps a `Storage` impl and provides the public API for task/list CRUD, ordering, and grouping. Tests live here. - **Repository** (`repository.rs`): `TaskRepository` wraps a `Storage` impl and provides the public API for task/list CRUD, ordering, and grouping. Tests live here.
- **Config** (`config.rs`): `AppConfig` manages named workspaces with paths, mode (local/webdav), theme, and WebDAV URL. Stored in platform-specific config dirs via the `directories` crate. - **Config** (`config.rs`): `AppConfig` manages workspaces keyed by UUID string. `WorkspaceConfig` stores `name`, `path`, `mode` (Local/Webdav), `webdav_url`, `webdav_path` (user-selected remote folder), `theme`, and `sync_interval_secs`. `add_workspace` returns a generated UUID. Stored in platform-specific config dirs via the `directories` crate.
- **Sync** (`sync.rs`): Three-way diff sync with offline queue. Auto-appends `Onyx/` to WebDAV URL. Wrapped in `tokio::time::timeout` (60s) to handle unreachable servers on Windows. - **Sync** (`sync.rs`): Three-way diff sync with offline queue. Checksum-based conflict resolution: downloads remote, compares SHA-256 — identical content is a false conflict (skipped); when different, remote wins and local is recovered as a duplicate with a new UUID and `[RECOVERED FROM CONFLICT]` prefix. Auto-sync lifecycle: periodic polling (configurable interval, default 60s), debounced file-change (5s), window-focus (30s stale threshold). Wrapped in `tokio::time::timeout` (60s) to handle unreachable servers on Windows.
- **WebDAV** (`webdav.rs`): reqwest client with rustls-tls, 30s request timeout, 10s connect timeout. Credentials stored via `keyring` crate (feature-gated). `Zeroizing<String>` for credential fields. Scoped keyring keys (`com.onyx.webdav.<domain>::<username>`); auto-migrates legacy dot-separated format on load. 10MB PROPFIND response cap. - **WebDAV** (`webdav.rs`): reqwest client with rustls-tls, 30s request timeout, 10s connect timeout. Rejects non-HTTPS URLs. `Zeroizing<String>` for credential fields. `move_resource` method for WebDAV MOVE (workspace rename). 10MB PROPFIND response cap. Desktop credentials via `keyring` crate (feature-gated); Tauri GUI uses `tauri-plugin-credentials` for cross-platform support (Android Keystore + desktop keychain).
### On-disk format ### On-disk format
Workspaces are plain folders. Each task list is a subfolder containing `.listdata.json` (metadata/ordering) and one `.md` file per task. The workspace root has `.metadata.json` for list ordering. Workspaces are plain folders. Each task list is a subfolder containing `.listdata.json` (metadata/ordering) and one `.md` file per task. The workspace root has `.onyx-workspace.json` for list ordering and workspace detection. Sync only processes files at expected depths: `.onyx-workspace.json` at root, `.listdata.json` and `*.md` inside list directories. Task frontmatter contains `id`, `status`, `version` (u64, increments on every write, defaults to 1 for legacy files), and optionally `due`, `has_time`, `parent` (omitted when false/null). `list_tasks` auto-deduplicates by UUID, keeping the highest-version file and deleting stale copies.
### Tauri GUI structure ### Tauri GUI structure
@ -59,12 +59,16 @@ The GUI uses Svelte 5 runes mode (`$state`, `$derived`, `$effect`, `$props()`).
- **Main panel header**: Hamburger + window controls in top bar; list name (large, bold) + kebab below divider (matching task detail layout). Kebab has Rename, Group by due date, Delete completed, Delete list. - **Main panel header**: Hamburger + window controls in top bar; list name (large, bold) + kebab below divider (matching task detail layout). Kebab has Rename, Group by due date, Delete completed, Delete list.
- **New task**: FAB button opens bottom toast sheet (outside sliding container for fixed positioning). - **New task**: FAB button opens bottom toast sheet (outside sliding container for fixed positioning).
### Current state (2026-04-03) ### Development phase
Pre-alpha. No users, no released builds, no data to migrate. Breaking changes to on-disk formats, config structure, or sync conventions are free — do not add migration logic.
### Current state (2026-04-05)
- **Phase 1** (Core + CLI): Complete - **Phase 1** (Core + CLI): Complete
- **Phase 2** (WebDAV sync): Complete — CLI + GUI sync working, auto-creates `Onyx/` subfolder on remote - **Phase 2** (WebDAV sync): Complete — remote folder browsing, checksum-based conflict resolution, auto-sync lifecycle, per-workspace sync interval
- **Phase 3** (GUI MVP): Complete - **Phase 3** (GUI MVP): Complete
- **Phase 4** (Mobile): Tauri Android cfg-gated, needs `tauri android init` + build - **Phase 4** (Mobile): Tauri Android cfg-gated with tauri-plugin-credentials and safe area insets; needs `tauri android init` + build
### GUI features done ### GUI features done
@ -83,24 +87,33 @@ The GUI uses Svelte 5 runes mode (`$state`, `$derived`, `$effect`, `$props()`).
- Group-by-due-date toggle per list (main panel kebab) - Group-by-due-date toggle per list (main panel kebab)
- Delete completed tasks (main panel kebab + subtask kebab, with confirmation dialogs) - Delete completed tasks (main panel kebab + subtask kebab, with confirmation dialogs)
- Keyboard shortcuts (Escape priority chain: settings → detail → list menu → drawer → menus) - Keyboard shortcuts (Escape priority chain: settings → detail → list menu → drawer → menus)
- Setup screen with 2-step mode selection (Local Folder vs WebDAV Server), window dragging, "Open Existing Folder" option - Setup screen with 2-step mode selection (Local Folder vs WebDAV Server), window dragging, "Open Existing Folder" option, remote folder browsing
- WebDAV setup flow with connection test, credential storage in system keychain - WebDAV setup flow with connection test, credential storage via tauri-plugin-credentials (Android Keystore + desktop keychain)
- WebDAV sync: auto-creates `Onyx/` subfolder on remote, 60s hard timeout, sync error display in settings - WebDAV sync: user-selected remote folder, 60s hard timeout, checksum-based conflict resolution (remote wins, local recovered as duplicate)
- Auto-sync lifecycle: periodic polling (configurable interval), debounced file-change (5s), window-focus (30s stale threshold); sync status dot (idle/synced/error/offline) and manual sync button in drawer
- Initial sync loading screen for new WebDAV workspaces
- Per-workspace sync interval (configurable in settings)
- Upload/download counts in drawer footer (hidden when zero)
- Transient sync error suppression (connectivity issues update status dot only, no error banner)
- File watcher (notify crate, 500ms debounce, auto-reloads on external changes) - File watcher (notify crate, 500ms debounce, auto-reloads on external changes)
- Sync status indicators (last-sync time + upload/download counts chip) - WorkspaceMode enum (local/webdav) with per-workspace config, UUID-keyed workspaces
- Push/pull/full sync mode selection (session-only, in settings) - Workspace rename (local folder rename + WebDAV MOVE for remote folders, with confirmation dialog)
- WorkspaceMode enum (local/webdav) with per-workspace config
- Desktop packaging (Linux: AppImage + .deb; Windows: MSI) - Desktop packaging (Linux: AppImage + .deb; Windows: MSI)
- Tauri desktop-only deps (notify, keyring) feature-gated for Android compilation - Tauri desktop-only deps (notify, keyring) feature-gated for Android compilation
- Safe area insets for mobile (CSS variables --safe-top/--safe-bottom, viewport-fit=cover)
- Task deduplication on load (handles sync conflict duplicates)
- Subtask hierarchy: subtask count shown on parent tasks in list, subtask detail via three-panel slide navigation, inline add at top of subtask list (new subtasks prepend), collapsible completed subtasks section, cascade delete (parent deletion removes all subtasks with confirmation warning) - Subtask hierarchy: subtask count shown on parent tasks in list, subtask detail via three-panel slide navigation, inline add at top of subtask list (new subtasks prepend), collapsible completed subtasks section, cascade delete (parent deletion removes all subtasks with confirmation warning)
- Custom confirmation dialogs (ConfirmDialog component replaces native confirm()) - Custom confirmation dialogs (ConfirmDialog component replaces native confirm())
### GUI features NOT yet done ### GUI features NOT yet done
- Workspace retarget/migrate
- Search/filter tasks - Search/filter tasks
- Desktop packaging for macOS - Desktop packaging for macOS
## Roadmap ## Roadmap
See `PLAN.md` for the 7-phase roadmap. Detailed API docs in `docs/API.md`, development practices in `docs/DEVELOPMENT.md`. See `PLAN.md` for the 7-phase roadmap. Detailed API docs in `docs/API.md`, development practices in `docs/DEVELOPMENT.md`.
## GitButler
If you generate code or modify files, run the gitbutler update branches MCP tool.

70
PLAN.md
View file

@ -61,9 +61,8 @@ Tasks are stored as individual `.md` files with YAML frontmatter:
--- ---
id: 550e8400-e29b-41d4-a716-446655440000 id: 550e8400-e29b-41d4-a716-446655440000
status: backlog status: backlog
version: 3
due: 2026-11-15T14:00:00Z due: 2026-11-15T14:00:00Z
created: 2026-10-26T10:00:00Z
updated: 2026-10-26T12:30:00Z
parent: 550e8400-e29b-41d4-a716-446655440001 parent: 550e8400-e29b-41d4-a716-446655440001
--- ---
@ -86,8 +85,8 @@ Task {
description: String, // Markdown content description: String, // Markdown content
status: TaskStatus, // Backlog or Completed status: TaskStatus, // Backlog or Completed
due_date: Option<DateTime>, due_date: Option<DateTime>,
created_at: DateTime, has_time: bool, // Whether due_date includes a specific time
updated_at: DateTime, version: u64, // Increments on every write; used for sync dedup
parent_id: Option<Uuid>, // For subtasks parent_id: Option<Uuid>, // For subtasks
} }
@ -106,12 +105,18 @@ TaskList {
} }
AppConfig { AppConfig {
workspaces: HashMap<String, WorkspaceConfig>, workspaces: HashMap<String, WorkspaceConfig>, // UUID keys
current_workspace: Option<String>, current_workspace: Option<String>, // UUID
} }
WorkspaceConfig { WorkspaceConfig {
name: String, // Display name
path: PathBuf, path: PathBuf,
mode: WorkspaceMode, // Local or Webdav
webdav_url: Option<String>,
webdav_path: Option<String>, // User-selected remote folder
theme: Option<String>,
sync_interval_secs: Option<u64>,
} }
``` ```
@ -119,7 +124,7 @@ WorkspaceConfig {
``` ```
~/Documents/Tasks/ # User-selected folder ~/Documents/Tasks/ # User-selected folder
├── .metadata.json # Global: list ordering, last opened list ├── .onyx-workspace.json # Global: list ordering, last opened list
├── My Tasks/ # Task list folder ├── My Tasks/ # Task list folder
│ ├── .listdata.json # List metadata: task order, id, timestamps │ ├── .listdata.json # List metadata: task order, id, timestamps
│ ├── Buy groceries.md # Title: "Buy groceries" (without .md) │ ├── Buy groceries.md # Title: "Buy groceries" (without .md)
@ -132,7 +137,7 @@ WorkspaceConfig {
**Note**: Task titles are derived from filenames by removing the `.md` extension. **Note**: Task titles are derived from filenames by removing the `.md` extension.
**`.metadata.json` (root level)**: **`.onyx-workspace.json` (root level)**:
```json ```json
{ {
"version": 1, "version": 1,
@ -169,14 +174,20 @@ WorkspaceConfig {
```json ```json
{ {
"workspaces": { "workspaces": {
"personal": { "a1b2c3d4-...": {
"path": "/home/user/Documents/Tasks" "name": "personal",
"path": "/home/user/Documents/Tasks",
"mode": "local"
}, },
"shared": { "e5f6g7h8-...": {
"path": "/home/user/Dropbox/TeamTasks" "name": "shared",
"path": "/home/user/Dropbox/TeamTasks",
"mode": "webdav",
"webdav_url": "https://nextcloud.example.com/remote.php/dav/files/user/",
"webdav_path": "TeamTasks"
} }
}, },
"current_workspace": "personal" "current_workspace": "a1b2c3d4-..."
} }
``` ```
@ -394,7 +405,7 @@ $ onyx workspace migrate personal ~/Dropbox/Tasks
⚠ This will move all files from ~/Documents/Tasks to ~/Dropbox/Tasks ⚠ This will move all files from ~/Documents/Tasks to ~/Dropbox/Tasks
Continue? (y/n): y Continue? (y/n): y
Moving files... Moving files...
Moved .metadata.json Moved .onyx-workspace.json
Moved My Tasks/ (15 files) Moved My Tasks/ (15 files)
Moved Work/ (8 files) Moved Work/ (8 files)
✓ Migrated 23 files to ~/Dropbox/Tasks ✓ Migrated 23 files to ~/Dropbox/Tasks
@ -453,11 +464,16 @@ cargo run -p onyx-cli -- workspace list
Add WebDAV support to `onyx-core`: Add WebDAV support to `onyx-core`:
```rust ```rust
// Update WorkspaceConfig to include WebDAV // WorkspaceConfig with WebDAV support (UUID-keyed in AppConfig)
WorkspaceConfig { WorkspaceConfig {
name: String,
path: PathBuf, path: PathBuf,
mode: WorkspaceMode, // Local or Webdav
webdav_url: Option<String>, webdav_url: Option<String>,
webdav_path: Option<String>, // User-selected remote folder
last_sync: Option<DateTime>, last_sync: Option<DateTime>,
theme: Option<String>,
sync_interval_secs: Option<u64>,
} }
// AppConfig remains the same (workspaces + current_workspace) // AppConfig remains the same (workspaces + current_workspace)
@ -484,8 +500,8 @@ pub fn delete_credentials(domain: &str) -> Result<()>;
``` ```
#### Sync Strategy #### Sync Strategy
- **Trigger**: On app start (if connected), background timer (every 5 min), on modification (debounced) - **Trigger**: Auto-sync lifecycle — periodic polling (configurable interval, default 60s), debounced file-change (5s), window-focus (30s stale threshold)
- **Conflict Resolution**: Last-write-wins with timestamp - **Conflict Resolution**: Checksum-based — downloads remote, compares SHA-256. Identical = false conflict (skipped). Different = remote wins, local recovered as duplicate with new UUID and `[RECOVERED FROM CONFLICT]` prefix
- **Offline Support**: Queue operations when offline, sync when online - **Offline Support**: Queue operations when offline, sync when online
#### Authentication #### Authentication
@ -664,20 +680,22 @@ apps/tauri/
#### App Configuration (Phase 3+) #### App Configuration (Phase 3+)
**Update AppConfig** to include UI preferences: **AppConfig** with UI preferences (theme is per-workspace):
```rust ```rust
AppConfig { AppConfig {
workspaces: HashMap<String, WorkspaceConfig>, // From Phase 1 workspaces: HashMap<String, WorkspaceConfig>, // UUID keys
current_workspace: Option<String>, current_workspace: Option<String>, // UUID
theme: Theme, // NEW: light/dark mode
window_size: Option<(u32, u32)>, // NEW: remember window size
last_opened_list_per_workspace: HashMap<String, Uuid>, // NEW: per-workspace last view
} }
WorkspaceConfig { WorkspaceConfig {
name: String, // Display name
path: PathBuf, path: PathBuf,
webdav_url: Option<String>, // From Phase 2 mode: WorkspaceMode, // Local or Webdav
webdav_url: Option<String>,
webdav_path: Option<String>, // User-selected remote folder
last_sync: Option<DateTime>, last_sync: Option<DateTime>,
theme: Option<String>, // Per-workspace theme
sync_interval_secs: Option<u64>, // Auto-sync interval
} }
``` ```
@ -1011,6 +1029,6 @@ This project is free and open-source software licensed under GPL v3.
--- ---
**Last Updated**: 2026-04-03 **Last Updated**: 2026-04-05
**Document Version**: 4.2 **Document Version**: 4.3
**Status**: Ready to Implement - Milestone-Driven Plan **Status**: Ready to Implement - Milestone-Driven Plan

View file

@ -40,6 +40,7 @@ onyx/
- Subtask hierarchy (parent_id) - Subtask hierarchy (parent_id)
- WebDAV sync with three-way diff and offline queue - WebDAV sync with three-way diff and offline queue
- Platform keychain credential storage (feature-gated for Android) - Platform keychain credential storage (feature-gated for Android)
- Checksum-based conflict resolution (remote wins, local recovered as duplicate)
### CLI (`onyx-cli`) ### CLI (`onyx-cli`)
- Workspace management (init, add, list, switch, remove, retarget, migrate) - Workspace management (init, add, list, switch, remove, retarget, migrate)
@ -62,7 +63,7 @@ onyx/
- Keyboard shortcuts (Escape priority chain) - Keyboard shortcuts (Escape priority chain)
- WebDAV setup flow with credential auto-population - WebDAV setup flow with credential auto-population
- File watcher (auto-reloads on external changes) - File watcher (auto-reloads on external changes)
- Sync status indicators and push/pull/full mode selection - Auto-sync with configurable interval, status indicators
- Custom confirmation dialogs - Custom confirmation dialogs
- Desktop packaging (Linux: AppImage + .deb; Windows: MSI) - Desktop packaging (Linux: AppImage + .deb; Windows: MSI)
@ -166,9 +167,8 @@ Tasks are stored as markdown files with YAML frontmatter (Obsidian-compatible):
--- ---
id: 550e8400-e29b-41d4-a716-446655440000 id: 550e8400-e29b-41d4-a716-446655440000
status: backlog status: backlog
version: 3
due: 2026-11-15T14:00:00Z due: 2026-11-15T14:00:00Z
created: 2026-10-26T10:00:00Z
updated: 2026-10-26T12:30:00Z
--- ---
Task description and notes go here in **markdown** format. Task description and notes go here in **markdown** format.
@ -182,7 +182,7 @@ Task description and notes go here in **markdown** format.
``` ```
~/Documents/Tasks/ # User-selected folder ~/Documents/Tasks/ # User-selected folder
├── .metadata.json # Global: list ordering, last opened list ├── .onyx-workspace.json # Workspace metadata: list ordering, detection
├── My Tasks/ # Task list folder ├── My Tasks/ # Task list folder
│ ├── .listdata.json # List metadata: task order, id, timestamps │ ├── .listdata.json # List metadata: task order, id, timestamps
│ ├── Buy groceries.md # Individual task files │ ├── Buy groceries.md # Individual task files

View file

@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Onyx</title> <title>Onyx</title>
</head> </head>
<body> <body>

View file

@ -909,6 +909,21 @@ dependencies = [
"new_debug_unreachable", "new_debug_unreachable",
] ]
[[package]]
name = "futures"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]] [[package]]
name = "futures-channel" name = "futures-channel"
version = "0.3.32" version = "0.3.32"
@ -916,6 +931,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-sink",
] ]
[[package]] [[package]]
@ -970,6 +986,7 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [ dependencies = [
"futures-channel",
"futures-core", "futures-core",
"futures-io", "futures-io",
"futures-macro", "futures-macro",
@ -2385,7 +2402,6 @@ version = "0.1.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"directories", "directories",
"keyring",
"log", "log",
"quick-xml 0.36.2", "quick-xml 0.36.2",
"reqwest 0.12.28", "reqwest 0.12.28",
@ -2403,6 +2419,7 @@ name = "onyx-tasks"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"futures",
"notify", "notify",
"notify-debouncer-mini", "notify-debouncer-mini",
"onyx-core", "onyx-core",
@ -2410,6 +2427,7 @@ dependencies = [
"serde_json", "serde_json",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-credentials",
"tauri-plugin-dialog", "tauri-plugin-dialog",
"tauri-plugin-os", "tauri-plugin-os",
"tokio", "tokio",
@ -4105,6 +4123,18 @@ dependencies = [
"walkdir", "walkdir",
] ]
[[package]]
name = "tauri-plugin-credentials"
version = "0.1.0"
dependencies = [
"keyring",
"log",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
]
[[package]] [[package]]
name = "tauri-plugin-dialog" name = "tauri-plugin-dialog"
version = "2.6.0" version = "2.6.0"

View file

@ -24,7 +24,9 @@ tauri-plugin-os = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
onyx-core = { path = "../../../crates/onyx-core", default-features = false } onyx-core = { path = "../../../crates/onyx-core", default-features = false }
tauri-plugin-credentials = { path = "../tauri-plugin-credentials", default-features = false }
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
futures = "0.3"
uuid = { version = "1", features = ["serde", "v4"] } uuid = { version = "1", features = ["serde", "v4"] }
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
notify = { version = "7", optional = true } notify = { version = "7", optional = true }
@ -34,5 +36,5 @@ notify-debouncer-mini = { version = "0.5", optional = true }
[features] [features]
default = ["desktop"] default = ["desktop"]
desktop = ["notify", "notify-debouncer-mini", "onyx-core/keyring-storage"] desktop = ["notify", "notify-debouncer-mini", "tauri-plugin-credentials/desktop"]
custom-protocol = ["tauri/custom-protocol"] custom-protocol = ["tauri/custom-protocol"]

View file

@ -17,6 +17,7 @@ use onyx_core::{
sync::{self, SyncMode, SyncResult as CoreSyncResult}, sync::{self, SyncMode, SyncResult as CoreSyncResult},
webdav, webdav,
}; };
use tauri_plugin_credentials::Credentials;
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
/// Active file watcher stored globally so it lives for the app lifetime. /// Active file watcher stored globally so it lives for the app lifetime.
@ -120,12 +121,11 @@ fn add_workspace(
state: State<'_, Mutex<AppState>>, state: State<'_, Mutex<AppState>>,
) -> Result<(), String> { ) -> Result<(), String> {
let mut s = lock_state(&state)?; let mut s = lock_state(&state)?;
let ws = WorkspaceConfig::new(PathBuf::from(&path)); let ws = WorkspaceConfig::new(name, PathBuf::from(&path));
s.config.add_workspace(name.clone(), ws); let id = s.config.add_workspace(ws);
s.config s.config
.set_current_workspace(name) .set_current_workspace(id)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
// Reset repo so it reopens on next access
s.repo = None; s.repo = None;
s.config s.config
.save_to_file(&s.config_path.clone()) .save_to_file(&s.config_path.clone())
@ -134,12 +134,12 @@ fn add_workspace(
#[tauri::command] #[tauri::command]
fn set_current_workspace( fn set_current_workspace(
name: String, id: String,
state: State<'_, Mutex<AppState>>, state: State<'_, Mutex<AppState>>,
) -> Result<(), String> { ) -> Result<(), String> {
let mut s = lock_state(&state)?; let mut s = lock_state(&state)?;
s.config s.config
.set_current_workspace(name) .set_current_workspace(id)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
s.repo = None; s.repo = None;
s.config s.config
@ -149,17 +149,96 @@ fn set_current_workspace(
#[tauri::command] #[tauri::command]
fn remove_workspace( fn remove_workspace(
name: String, id: String,
state: State<'_, Mutex<AppState>>, state: State<'_, Mutex<AppState>>,
) -> Result<(), String> { ) -> Result<(), String> {
let mut s = lock_state(&state)?; let mut s = lock_state(&state)?;
s.config.remove_workspace(&name); s.config.remove_workspace(&id);
s.repo = None; s.repo = None;
s.config s.config
.save_to_file(&s.config_path.clone()) .save_to_file(&s.config_path.clone())
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
#[tauri::command]
async fn rename_workspace(
id: String,
new_name: String,
app_handle: tauri::AppHandle,
state: State<'_, Mutex<AppState>>,
) -> Result<(), String> {
// Extract workspace info while holding the lock briefly
let (mode, old_path, webdav_url, webdav_path) = {
let s = lock_state(&state)?;
let ws = s.config.workspaces.get(&id).ok_or("Workspace not found")?;
(
ws.mode.clone(),
ws.path.clone(),
ws.webdav_url.clone(),
ws.webdav_path.clone(),
)
};
match mode {
WorkspaceMode::Local => {
// Rename the local folder
let parent = old_path.parent().ok_or("Workspace has no parent directory")?;
let new_path = parent.join(&new_name);
if new_path != old_path {
if new_path.exists() {
return Err(format!("A folder named '{}' already exists at that location", new_name));
}
std::fs::rename(&old_path, &new_path).map_err(|e| format!("Failed to rename folder: {}", e))?;
}
let mut s = lock_state(&state)?;
s.config.rename_workspace(&id, new_name).map_err(|e| e.to_string())?;
if let Some(ws) = s.config.workspaces.get_mut(&id) {
ws.path = new_path;
}
s.repo = None;
s.config.save_to_file(&s.config_path.clone()).map_err(|e| e.to_string())?;
}
WorkspaceMode::Webdav => {
// Rename the remote folder via WebDAV MOVE
let base_url = webdav_url.as_deref().ok_or("No WebDAV URL configured")?;
let remote_path = webdav_path.as_deref().unwrap_or("");
let domain = base_url
.split("://").nth(1)
.and_then(|rest| rest.split('/').next())
.unwrap_or("").to_string();
let creds = app_handle.state::<Credentials<tauri::Wry>>();
let (username, password) = creds.load(&domain)?;
let client = webdav::WebDavClient::new(base_url, &username, &password)
.map_err(|e| e.to_string())?;
// Compute new remote path by replacing the last segment
let new_remote_path = if remote_path.is_empty() || remote_path == "/" {
new_name.clone()
} else if let Some(parent) = remote_path.trim_end_matches('/').rsplit_once('/') {
format!("{}/{}", parent.0, new_name)
} else {
new_name.clone()
};
if new_remote_path != remote_path {
client.move_resource(remote_path, &new_remote_path).await.map_err(|e| e.to_string())?;
}
let mut s = lock_state(&state)?;
s.config.rename_workspace(&id, new_name).map_err(|e| e.to_string())?;
if let Some(ws) = s.config.workspaces.get_mut(&id) {
ws.webdav_path = Some(new_remote_path);
}
s.repo = None;
s.config.save_to_file(&s.config_path.clone()).map_err(|e| e.to_string())?;
}
}
Ok(())
}
// ── Workspace init ─────────────────────────────────────────────────── // ── Workspace init ───────────────────────────────────────────────────
#[tauri::command] #[tauri::command]
@ -405,12 +484,12 @@ fn get_group_by_due_date(
#[tauri::command] #[tauri::command]
fn set_webdav_config( fn set_webdav_config(
workspace_name: String, workspace_id: String,
webdav_url: String, webdav_url: String,
state: State<'_, Mutex<AppState>>, state: State<'_, Mutex<AppState>>,
) -> Result<(), String> { ) -> Result<(), String> {
let mut s = lock_state(&state)?; let mut s = lock_state(&state)?;
if let Some(ws) = s.config.workspaces.get_mut(&workspace_name) { if let Some(ws) = s.config.workspaces.get_mut(&workspace_id) {
ws.webdav_url = Some(webdav_url); ws.webdav_url = Some(webdav_url);
} }
s.config s.config
@ -420,36 +499,162 @@ fn set_webdav_config(
#[tauri::command] #[tauri::command]
fn set_workspace_theme( fn set_workspace_theme(
workspace_name: String, workspace_id: String,
theme: Option<String>, theme: Option<String>,
state: State<'_, Mutex<AppState>>, state: State<'_, Mutex<AppState>>,
) -> Result<(), String> { ) -> Result<(), String> {
let mut s = lock_state(&state)?; let mut s = lock_state(&state)?;
if let Some(ws) = s.config.workspaces.get_mut(&workspace_name) { if let Some(ws) = s.config.workspaces.get_mut(&workspace_id) {
ws.theme = theme; ws.theme = theme;
} }
s.config.save_to_file(&s.config_path.clone()).map_err(|e| e.to_string()) s.config.save_to_file(&s.config_path.clone()).map_err(|e| e.to_string())
} }
#[tauri::command] #[tauri::command]
fn add_webdav_workspace( fn set_sync_interval(
name: String, workspace_id: String,
webdav_url: String, interval_secs: Option<u64>,
username: String,
password: String,
state: State<'_, Mutex<AppState>>, state: State<'_, Mutex<AppState>>,
) -> Result<(), String> { ) -> Result<(), String> {
let mut s = lock_state(&state)?; let mut s = lock_state(&state)?;
let managed_dir = s.app_data_dir.join("workspaces").join(&name); if let Some(ws) = s.config.workspaces.get_mut(&workspace_id) {
ws.sync_interval_secs = interval_secs;
}
s.config.save_to_file(&s.config_path.clone()).map_err(|e| e.to_string())
}
/// A remote folder entry returned to the frontend.
#[derive(Debug, Serialize, Deserialize)]
struct RemoteFolderEntry {
name: String,
is_workspace: bool,
}
/// Summary of a list inside a remote workspace.
#[derive(Debug, Serialize, Deserialize)]
struct RemoteListInfo {
name: String,
task_count: usize,
}
#[tauri::command]
async fn list_remote_folder(
url: String,
username: String,
password: String,
path: String,
) -> Result<Vec<RemoteFolderEntry>, String> {
let client = onyx_core::webdav::WebDavClient::new(&url, &username, &password)
.map_err(|e| e.to_string())?;
let entries = client.list_files(&path).await.map_err(|e| e.to_string())?;
let dir_entries: Vec<_> = entries.into_iter().filter(|e| e.is_dir).collect();
// Check all subfolders for .onyx-workspace.json in parallel
let sub_paths: Vec<_> = dir_entries.iter().map(|entry| {
if path.is_empty() { entry.path.clone() }
else { format!("{}/{}", path.trim_end_matches('/'), entry.path) }
}).collect();
let checks: Vec<_> = sub_paths.iter().map(|sp| {
client.list_files(sp)
}).collect();
let results: Vec<_> = futures::future::join_all(checks).await
.into_iter().map(|r| r.unwrap_or_default()).collect();
let folders = dir_entries.into_iter().zip(results).map(|(entry, sub_files)| {
let is_workspace = sub_files.iter().any(|f| !f.is_dir && f.path == ".onyx-workspace.json");
RemoteFolderEntry { name: entry.path, is_workspace }
}).collect();
Ok(folders)
}
#[tauri::command]
async fn inspect_remote_workspace(
url: String,
username: String,
password: String,
path: String,
) -> Result<Vec<RemoteListInfo>, String> {
let client = onyx_core::webdav::WebDavClient::new(&url, &username, &password)
.map_err(|e| e.to_string())?;
let entries = client.list_files(&path).await.map_err(|e| e.to_string())?;
let mut lists = Vec::new();
for entry in entries {
if !entry.is_dir { continue; }
let list_path = if path.is_empty() {
entry.path.clone()
} else {
format!("{}/{}", path.trim_end_matches('/'), entry.path)
};
let files = client.list_files(&list_path).await.unwrap_or_default();
let has_listdata = files.iter().any(|f| !f.is_dir && f.path == ".listdata.json");
if has_listdata {
let task_count = files.iter().filter(|f| !f.is_dir && f.path.ends_with(".md")).count();
lists.push(RemoteListInfo {
name: entry.path,
task_count,
});
}
}
Ok(lists)
}
#[tauri::command]
async fn create_remote_workspace(
url: String,
username: String,
password: String,
path: String,
) -> Result<(), String> {
let client = onyx_core::webdav::WebDavClient::new(&url, &username, &password)
.map_err(|e| e.to_string())?;
if !path.is_empty() {
client.ensure_dir(&path).await.map_err(|e| e.to_string())?;
}
// Upload an empty .onyx-workspace.json
let metadata = serde_json::json!({
"version": 1,
"list_order": [],
"last_opened_list": null,
});
let file_path = if path.is_empty() {
".onyx-workspace.json".to_string()
} else {
format!("{}/{}", path.trim_end_matches('/'), ".onyx-workspace.json")
};
client.put_file(&file_path, serde_json::to_string_pretty(&metadata).unwrap().into_bytes())
.await
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
fn add_webdav_workspace(
name: String,
webdav_url: String,
webdav_path: String,
username: String,
password: String,
app_handle: tauri::AppHandle,
state: State<'_, Mutex<AppState>>,
) -> Result<(), String> {
let mut s = lock_state(&state)?;
// Use a UUID-based directory name to avoid filesystem conflicts with duplicate workspace names
let dir_id = uuid::Uuid::new_v4().to_string();
let managed_dir = s.app_data_dir.join("workspaces").join(&dir_id);
std::fs::create_dir_all(&managed_dir).map_err(|e| e.to_string())?; 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())?; TaskRepository::init(managed_dir.clone()).map(|_| ()).map_err(|e| e.to_string())?;
let mut ws = WorkspaceConfig::new(managed_dir); let mut ws = WorkspaceConfig::new(name, managed_dir);
ws.mode = WorkspaceMode::Webdav; ws.mode = WorkspaceMode::Webdav;
ws.webdav_url = Some(webdav_url.clone()); ws.webdav_url = Some(webdav_url.clone());
ws.webdav_path = Some(webdav_path);
s.config.add_workspace(name.clone(), ws); let id = s.config.add_workspace(ws);
s.config.set_current_workspace(name).map_err(|e| e.to_string())?; s.config.set_current_workspace(id).map_err(|e| e.to_string())?;
s.repo = None; s.repo = None;
// Store credentials keyed by hostname // Store credentials keyed by hostname
@ -461,7 +666,8 @@ fn add_webdav_workspace(
.to_string(); .to_string();
s.config.save_to_file(&s.config_path.clone()).map_err(|e| e.to_string())?; s.config.save_to_file(&s.config_path.clone()).map_err(|e| e.to_string())?;
drop(s); drop(s);
webdav::store_credentials(&domain, &username, &password).map_err(|e| e.to_string())?; let creds = app_handle.state::<Credentials<tauri::Wry>>();
creds.store(&domain, &username, &password)?;
Ok(()) Ok(())
} }
@ -470,23 +676,19 @@ async fn store_credentials(
domain: String, domain: String,
username: String, username: String,
password: String, password: String,
app_handle: tauri::AppHandle,
) -> Result<(), String> { ) -> Result<(), String> {
tokio::task::spawn_blocking(move || { let creds = app_handle.state::<Credentials<tauri::Wry>>();
webdav::store_credentials(&domain, &username, &password).map_err(|e| e.to_string()) creds.store(&domain, &username, &password)
})
.await
.map_err(|e| e.to_string())?
} }
#[tauri::command] #[tauri::command]
async fn load_credentials(domain: String) -> Result<(String, String), String> { async fn load_credentials(
tokio::task::spawn_blocking(move || { domain: String,
webdav::load_credentials(&domain) app_handle: tauri::AppHandle,
.map(|(u, p)| ((*u).clone(), (*p).clone())) ) -> Result<(String, String), String> {
.map_err(|e| e.to_string()) let creds = app_handle.state::<Credentials<tauri::Wry>>();
}) creds.load(&domain)
.await
.map_err(|e| e.to_string())?
} }
#[tauri::command] #[tauri::command]
@ -505,16 +707,22 @@ async fn test_webdav_connection(
#[tauri::command] #[tauri::command]
async fn sync_workspace( async fn sync_workspace(
workspace_name: String, workspace_id: String,
mode: String, mode: String,
app_handle: tauri::AppHandle,
state: State<'_, Mutex<AppState>>, state: State<'_, Mutex<AppState>>,
) -> Result<SyncResult, String> { ) -> Result<SyncResult, String> {
// Step 1: read config // Step 1: read config — combine base URL with the user-chosen remote path
let (workspace_path, webdav_url) = { let (workspace_path, webdav_url) = {
let s = lock_state(&state)?; let s = lock_state(&state)?;
let ws = s.config.workspaces.get(&workspace_name) let ws = s.config.workspaces.get(&workspace_id)
.ok_or("Workspace not found")?; .ok_or("Workspace not found")?;
(ws.path.clone(), ws.webdav_url.clone().ok_or("No WebDAV URL configured")?) let base = ws.webdav_url.clone().ok_or("No WebDAV URL configured")?;
let full = match &ws.webdav_path {
Some(p) if !p.is_empty() => format!("{}/{}", base.trim_end_matches('/'), p.trim_matches('/')),
_ => base,
};
(ws.path.clone(), full)
}; };
// Step 2: load credentials // Step 2: load credentials
@ -524,13 +732,8 @@ async fn sync_workspace(
.and_then(|rest| rest.split('/').next()) .and_then(|rest| rest.split('/').next())
.unwrap_or("") .unwrap_or("")
.to_string(); .to_string();
let (username, password) = tokio::task::spawn_blocking(move || { let creds = app_handle.state::<Credentials<tauri::Wry>>();
webdav::load_credentials(&domain) let (username, password) = creds.load(&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() { let sync_mode = match mode.as_str() {
"push" => SyncMode::Push, "push" => SyncMode::Push,
@ -550,7 +753,7 @@ async fn sync_workspace(
{ {
let mut s = lock_state(&state)?; let mut s = lock_state(&state)?;
if let Some(ws) = s.config.workspaces.get_mut(&workspace_name) { if let Some(ws) = s.config.workspaces.get_mut(&workspace_id) {
ws.last_sync = Some(Utc::now()); ws.last_sync = Some(Utc::now());
} }
s.config.save_to_file(&s.config_path.clone()).map_err(|e| e.to_string())?; s.config.save_to_file(&s.config_path.clone()).map_err(|e| e.to_string())?;
@ -621,6 +824,7 @@ pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_credentials::init())
.setup(|app| { .setup(|app| {
// Resolve app data dir and config path // Resolve app data dir and config path
let app_data_dir = app.path().app_data_dir() let app_data_dir = app.path().app_data_dir()
@ -648,6 +852,7 @@ pub fn run() {
add_workspace, add_workspace,
set_current_workspace, set_current_workspace,
remove_workspace, remove_workspace,
rename_workspace,
init_workspace, init_workspace,
get_lists, get_lists,
create_list, create_list,
@ -664,7 +869,11 @@ pub fn run() {
get_group_by_due_date, get_group_by_due_date,
set_webdav_config, set_webdav_config,
set_workspace_theme, set_workspace_theme,
set_sync_interval,
add_webdav_workspace, add_webdav_workspace,
list_remote_folder,
inspect_remote_workspace,
create_remote_workspace,
store_credentials, store_credentials,
load_credentials, load_credentials,
test_webdav_connection, test_webdav_connection,

View file

@ -6,6 +6,7 @@
import TasksScreen from "./lib/screens/TasksScreen.svelte"; import TasksScreen from "./lib/screens/TasksScreen.svelte";
const isLinux = platform() === "linux"; const isLinux = platform() === "linux";
const isMobile = platform() === "android" || platform() === "ios";
onMount(() => { onMount(() => {
app.loadConfig(); app.loadConfig();
@ -23,13 +24,40 @@
{#if app.error} {#if app.error}
<div <div
class="absolute top-0 left-0 right-0 z-50 flex items-center justify-between bg-danger px-4 py-2 text-sm text-white" class="absolute top-0 left-0 right-0 z-50 flex items-center justify-between bg-danger px-4 py-2 text-sm text-white"
style="top: env(safe-area-inset-top)"
> >
<span>{app.error}</span> <span>{app.error}</span>
<button onclick={() => app.clearError()} class="ml-2 font-bold"></button> <button onclick={() => app.clearError()} class="ml-2 font-bold"></button>
</div> </div>
{/if} {/if}
{#if app.screen === "setup"} {#if app.initialSync}
<div class="flex h-full flex-col items-center justify-center gap-4">
<svg class="h-8 w-8 animate-spin text-primary" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" opacity="0.25" />
<path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" stroke-width="3" stroke-linecap="round" />
</svg>
<p class="text-sm text-text-secondary-light dark:text-text-secondary-dark">Syncing workspace&hellip;</p>
</div>
{:else if app.screen === "missing"}
<div class="flex h-full items-center justify-center p-6">
<div 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">Workspace Not Found</h1>
<p class="mb-2 text-sm text-text-secondary-light dark:text-text-secondary-dark">
The workspace <strong>{app.missingWorkspace && app.config?.workspaces[app.missingWorkspace]?.name || "Unknown"}</strong> could not be opened. Its folder may have been moved or deleted.
</p>
<p class="mb-6 text-sm text-text-secondary-light dark:text-text-secondary-dark">
It will be removed from your workspace list. You can re-add it if the folder becomes available again.
</p>
<button
onclick={() => app.forgetMissingWorkspace()}
class="w-full rounded-lg bg-primary py-2.5 text-sm font-medium text-white hover:bg-primary-hover"
>
Continue
</button>
</div>
</div>
{:else if app.screen === "setup"}
<SetupScreen cancellable={app.hasWorkspace} /> <SetupScreen cancellable={app.hasWorkspace} />
{:else} {:else}
<TasksScreen /> <TasksScreen />

View file

@ -36,6 +36,12 @@ body {
background: transparent; background: transparent;
} }
/* Safe area CSS variable — content elements opt into this, overlays don't */
:root {
--safe-top: env(safe-area-inset-top);
--safe-bottom: env(safe-area-inset-bottom);
}
.linux-window-border { .linux-window-border {
border: 1px solid rgba(0, 0, 0, 0.15); border: 1px solid rgba(0, 0, 0, 0.15);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25), 0 0 2px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25), 0 0 2px rgba(0, 0, 0, 0.1);

View file

@ -18,7 +18,7 @@
if (!title.trim()) return; if (!title.trim()) return;
const created = await app.createTask(title.trim(), description.trim() || undefined); const created = await app.createTask(title.trim(), description.trim() || undefined);
if (dueDate && created) { if (dueDate && created) {
await app.updateTask({ ...created, due_date: dueDate, has_time: dueDateHasTime, updated_at: new Date().toISOString() }); await app.updateTask({ ...created, due_date: dueDate, has_time: dueDateHasTime });
} }
title = ""; title = "";
description = ""; description = "";

View file

@ -35,7 +35,7 @@
function debouncedSave(fields: Partial<Task>) { function debouncedSave(fields: Partial<Task>) {
clearTimeout(saveTimer); clearTimeout(saveTimer);
saveTimer = setTimeout(() => { saveTimer = setTimeout(() => {
app.updateTask({ ...task, ...fields, updated_at: new Date().toISOString() }); app.updateTask({ ...task, ...fields });
}, 400); }, 400);
} }
@ -48,7 +48,7 @@
} }
function handleDateChange(iso: string | null, hasTime: boolean = false) { function handleDateChange(iso: string | null, hasTime: boolean = false) {
app.updateTask({ ...task, due_date: iso, has_time: hasTime, updated_at: new Date().toISOString() }); app.updateTask({ ...task, due_date: iso, has_time: hasTime });
} }
async function handleToggle() { async function handleToggle() {

View file

@ -1,10 +1,11 @@
<script lang="ts"> <script lang="ts">
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { app } from "../stores/app.svelte"; import { app } from "../stores/app.svelte";
import ConfirmDialog from "../components/ConfirmDialog.svelte";
let { onclose, workspaceName }: { onclose?: () => void; workspaceName: string } = $props(); let { onclose, workspaceId, ondelete }: { onclose?: () => void; workspaceId: string; ondelete?: (id: string) => void } = $props();
let ws = $derived(app.config?.workspaces[workspaceName]); let ws = $derived(app.config?.workspaces[workspaceId]);
let isWebdav = $derived(ws?.mode === "webdav"); let isWebdav = $derived(ws?.mode === "webdav");
let webdavUrl = $state(""); let webdavUrl = $state("");
@ -12,6 +13,11 @@
let webdavPass = $state(""); let webdavPass = $state("");
let testStatus = $state<"idle" | "testing" | "ok" | "fail">("idle"); let testStatus = $state<"idle" | "testing" | "ok" | "fail">("idle");
let renaming = $state(false);
let renameValue = $state("");
let showKebab = $state(false);
let confirmRename = $state(false);
$effect(() => { $effect(() => {
if (!ws?.webdav_url) return; if (!ws?.webdav_url) return;
webdavUrl = ws.webdav_url; webdavUrl = ws.webdav_url;
@ -41,7 +47,7 @@
async function saveWebdav() { async function saveWebdav() {
if (!webdavUrl.trim()) return; if (!webdavUrl.trim()) return;
await invoke("set_webdav_config", { await invoke("set_webdav_config", {
workspaceName, workspaceId,
webdavUrl: webdavUrl.trim(), webdavUrl: webdavUrl.trim(),
}); });
if (webdavUser && webdavPass) { if (webdavUser && webdavPass) {
@ -54,12 +60,38 @@
} }
await app.loadConfig(); await app.loadConfig();
} }
function startRename() {
showKebab = false;
renaming = true;
renameValue = ws?.name ?? "";
}
async function handleRename() {
if (!renaming) return;
renaming = false;
var trimmed = renameValue.trim();
if (!trimmed || trimmed === ws?.name) return;
confirmRename = true;
}
async function doRename() {
confirmRename = false;
var trimmed = renameValue.trim();
if (!trimmed) return;
await app.renameWorkspace(workspaceId, trimmed);
}
function handleWindowClick(e: MouseEvent) {
if (showKebab && !(e.target as HTMLElement).closest("[data-settings-kebab]")) showKebab = false;
}
</script> </script>
<svelte:window onclick={handleWindowClick} />
<header <header
class="flex items-center justify-between border-b border-border-light px-4 py-3 dark:border-border-dark" 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">{workspaceName} Settings</h1> <h1 class="text-lg font-bold">Workspace Settings</h1>
<button <button
onclick={() => onclose?.()} onclick={() => onclose?.()}
class="rounded-lg p-1.5 hover:bg-black/5 dark:hover:bg-white/10" class="rounded-lg p-1.5 hover:bg-black/5 dark:hover:bg-white/10"
@ -72,6 +104,56 @@
</button> </button>
</header> </header>
<!-- Workspace name + kebab -->
<div class="flex items-center gap-2 px-4 py-3">
<div class="min-w-0 flex-1">
{#if renaming}
<input
type="text"
bind:value={renameValue}
class="w-full bg-transparent text-xl font-bold outline-none"
onkeydown={(e) => { if (e.key === "Enter") handleRename(); if (e.key === "Escape") { renaming = false; } }}
onblur={handleRename}
autofocus
/>
{:else}
<p class="text-xl font-bold">{ws?.name}</p>
{/if}
</div>
<div class="relative shrink-0" data-settings-kebab>
<button
onclick={() => showKebab = !showKebab}
class="rounded-lg p-1.5 hover:bg-black/5 dark:hover:bg-white/10"
>
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M10 6a1.5 1.5 0 110-3 1.5 1.5 0 010 3zm0 5.5a1.5 1.5 0 110-3 1.5 1.5 0 010 3zm0 5.5a1.5 1.5 0 110-3 1.5 1.5 0 010 3z" />
</svg>
</button>
{#if showKebab}
<div class="absolute right-0 top-full z-10 mt-1 w-40 rounded-xl border border-border-light bg-surface-light py-1 shadow-lg dark:border-border-dark dark:bg-surface-dark">
<button
onclick={startRename}
class="flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-black/5 dark:hover:bg-white/10"
>
<svg class="h-4 w-4 opacity-60" viewBox="0 0 20 20" fill="currentColor">
<path d="M2.695 14.763l-1.262 3.154a.5.5 0 00.65.65l3.155-1.262a4 4 0 001.343-.885L17.5 5.5a2.121 2.121 0 00-3-3L3.58 13.42a4 4 0 00-.885 1.343z" />
</svg>
Rename
</button>
<button
onclick={() => { showKebab = false; ondelete?.(workspaceId); }}
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-danger 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="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
Delete
</button>
</div>
{/if}
</div>
</div>
<main class="flex-1 overflow-y-auto p-4"> <main class="flex-1 overflow-y-auto p-4">
<!-- WebDAV Sync (only for webdav workspaces) --> <!-- WebDAV Sync (only for webdav workspaces) -->
{#if isWebdav} {#if isWebdav}
@ -120,47 +202,28 @@
</div> </div>
</div> </div>
<div class="mt-3 flex items-center gap-2"> <div class="mt-3">
<label class="mb-1 block text-xs font-medium opacity-60">Sync interval</label>
<select <select
value={app.syncMode} value={String(app.syncIntervalSecs)}
onchange={(e) => app.setSyncMode((e.target as HTMLSelectElement).value as "full" | "push" | "pull")} onchange={(e) => {
class="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" const val = parseInt((e.target as HTMLSelectElement).value);
app.setSyncInterval(val === 60 ? null : val);
}}
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"
> >
<option value="full">Sync both ways</option> <option value="30">30 seconds</option>
<option value="push">Push only</option> <option value="60">1 minute</option>
<option value="pull">Pull only</option> <option value="120">2 minutes</option>
<option value="300">5 minutes</option>
<option value="600">10 minutes</option>
</select> </select>
<button
onclick={() => app.triggerSync()}
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"}
</button>
</div> </div>
{#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">
Last sync: {relTime}
{#if app.lastSyncResult}
&nbsp;·&nbsp;{app.lastSyncResult.uploaded}{app.lastSyncResult.downloaded}
{/if}
</p>
{/if}
</section> </section>
{/if} {/if}
<!-- Theme --> <!-- Theme -->
<section> <section>
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide opacity-50">
Appearance
</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">Theme</label> <label class="mb-1 block text-xs font-medium opacity-60">Theme</label>
<select <select
value={ws?.theme ?? ""} value={ws?.theme ?? ""}
@ -177,8 +240,17 @@
<option value="dracula">Dracula</option> <option value="dracula">Dracula</option>
<option value="solarized">Solarized Dark</option> <option value="solarized">Solarized Dark</option>
</select> </select>
</div>
</section> </section>
<p class="mt-8 text-center text-xs opacity-30">Tauri v2 + Svelte</p> <p class="mt-8 text-center text-xs opacity-30">Tauri v2 + Svelte</p>
</main> </main>
{#if confirmRename}
<ConfirmDialog
message="Rename workspace to '{renameValue.trim()}'?"
detail={isWebdav ? "This will rename the folder on the WebDAV server." : "This will rename the folder on disk."}
confirmText="Rename"
onconfirm={doRename}
oncancel={() => confirmRename = false}
/>
{/if}

View file

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { documentDir } from "@tauri-apps/api/path";
import { open } from "@tauri-apps/plugin-dialog"; import { open } from "@tauri-apps/plugin-dialog";
import { app } from "../stores/app.svelte"; import { app } from "../stores/app.svelte";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
@ -13,14 +14,40 @@
const isWindows = currentPlatform === "windows"; const isWindows = currentPlatform === "windows";
const isMobile = currentPlatform === "android" || currentPlatform === "ios"; const isMobile = currentPlatform === "android" || currentPlatform === "ios";
// ── Shared state ──────────────────────────────────────────────────
let mode = $state<"local" | "webdav" | null>(isMobile ? "webdav" : null); let mode = $state<"local" | "webdav" | null>(isMobile ? "webdav" : null);
let name = $state(""); let name = $state("Onyx");
let path = $state(""); let path = $state("");
documentDir().then((d) => { path = d; }).catch(() => {});
// ── WebDAV state ──────────────────────────────────────────────────
let webdavUrl = $state(""); let webdavUrl = $state("");
let webdavUser = $state(""); let webdavUser = $state("");
let webdavPass = $state(""); let webdavPass = $state("");
let testStatus = $state<"idle" | "testing" | "ok" | "fail">("idle"); let testStatus = $state<"idle" | "testing" | "ok" | "fail">("idle");
// WebDAV step: "connect" → "browse" → "preview" | "create"
let webdavStep = $state<"connect" | "browse" | "preview" | "create">("connect");
let browsePath = $state<string[]>([]); // stack of folder names for navigation
let browseLoading = $state(false);
let browseEntries = $state<{ name: string; is_workspace: boolean }[]>([]);
let browseError = $state<string | null>(null);
// Workspace preview state
let previewName = $state("");
let previewLists = $state<{ name: string; task_count: number }[]>([]);
let previewLoading = $state(false);
// Create workspace state
let createName = $state("Onyx");
let creating = $state(false);
// ── Derived ───────────────────────────────────────────────────────
let currentBrowsePath = $derived(browsePath.join("/"));
// ── Local workspace handlers ──────────────────────────────────────
async function pickFolder() { async function pickFolder() {
const selected = await open({ directory: true, multiple: false }); const selected = await open({ directory: true, multiple: false });
if (selected) path = selected as string; if (selected) path = selected as string;
@ -42,6 +69,8 @@
await app.addWorkspace(wsName, folder); await app.addWorkspace(wsName, folder);
} }
// ── WebDAV handlers ───────────────────────────────────────────────
async function testConnection() { async function testConnection() {
testStatus = "testing"; testStatus = "testing";
try { try {
@ -56,10 +85,108 @@
} }
} }
async function handleCreateWebdav() { async function connectAndBrowse() {
if (!name.trim() || !webdavUrl.trim()) return; testStatus = "testing";
await app.addWebdavWorkspace(name.trim(), webdavUrl.trim(), webdavUser, webdavPass); try {
await invoke("test_webdav_connection", {
url: webdavUrl,
username: webdavUser,
password: webdavPass,
});
testStatus = "ok";
webdavStep = "browse";
browsePath = [];
await loadFolder();
} catch {
testStatus = "fail";
} }
}
async function loadFolder() {
browseLoading = true;
browseError = null;
try {
const entries: typeof browseEntries = await invoke("list_remote_folder", {
url: webdavUrl,
username: webdavUser,
password: webdavPass,
path: currentBrowsePath,
});
entries.sort((a, b) => (a.is_workspace === b.is_workspace ? 0 : a.is_workspace ? -1 : 1));
browseEntries = entries;
} catch (e) {
browseError = String(e);
browseEntries = [];
} finally {
browseLoading = false;
}
}
async function navigateInto(folder: { name: string; is_workspace: boolean }) {
if (folder.is_workspace) {
previewName = folder.name;
previewLoading = true;
webdavStep = "preview";
try {
const wsPath = currentBrowsePath
? `${currentBrowsePath}/${folder.name}`
: folder.name;
previewLists = await invoke("inspect_remote_workspace", {
url: webdavUrl,
username: webdavUser,
password: webdavPass,
path: wsPath,
});
} catch (e) {
browseError = String(e);
webdavStep = "browse";
} finally {
previewLoading = false;
}
} else {
browsePath = [...browsePath, folder.name];
await loadFolder();
}
}
function navigateUp() {
browsePath = browsePath.slice(0, -1);
loadFolder();
}
async function openExistingWorkspace() {
const wsPath = currentBrowsePath
? `${currentBrowsePath}/${previewName}`
: previewName;
await app.addWebdavWorkspace(previewName, webdavUrl.trim(), wsPath, webdavUser, webdavPass);
}
function startCreate() {
createName = "Onyx";
webdavStep = "create";
}
async function handleCreateWebdav() {
if (!createName.trim()) return;
creating = true;
try {
const wsPath = currentBrowsePath
? `${currentBrowsePath}/${createName.trim()}`
: createName.trim();
await invoke("create_remote_workspace", {
url: webdavUrl,
username: webdavUser,
password: webdavPass,
path: wsPath,
});
await app.addWebdavWorkspace(createName.trim(), webdavUrl.trim(), wsPath, webdavUser, webdavPass);
} catch (e) {
browseError = String(e);
creating = false;
}
}
// ── Window dragging ───────────────────────────────────────────────
function handleDrag(e: MouseEvent) { function handleDrag(e: MouseEvent) {
if (e.button !== 0) return; if (e.button !== 0) return;
@ -69,17 +196,34 @@
function goBack() { function goBack() {
mode = null; mode = null;
name = ""; name = "Onyx";
path = ""; path = "";
webdavUrl = ""; webdavUrl = "";
webdavUser = ""; webdavUser = "";
webdavPass = ""; webdavPass = "";
testStatus = "idle"; testStatus = "idle";
webdavStep = "connect";
browsePath = [];
browseEntries = [];
browseError = null;
}
function webdavBack() {
if (webdavStep === "preview" || webdavStep === "create") {
webdavStep = "browse";
} else if (webdavStep === "browse") {
webdavStep = "connect";
browsePath = [];
browseEntries = [];
} else {
goBack();
}
} }
</script> </script>
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="flex h-full flex-col" onmousedown={handleDrag}> <div class="flex h-full flex-col" onmousedown={handleDrag}>
<div class="shrink-0" style="height: var(--safe-top)"></div>
<!-- Title bar area with window controls --> <!-- Title bar area with window controls -->
<header class="flex h-11 shrink-0 items-center justify-between px-2"> <header class="flex h-11 shrink-0 items-center justify-between px-2">
<div> <div>
@ -214,27 +358,17 @@
</button> </button>
{/if} {/if}
{:else} {:else if webdavStep === "connect"}
<!-- Step 2b: WebDAV workspace --> <!-- Step 2b: WebDAV connect -->
<p class="mb-6 text-sm text-text-secondary-light dark:text-text-secondary-dark"> <p class="mb-6 text-sm text-text-secondary-light dark:text-text-secondary-dark">
Connect to a WebDAV server for cloud-synced tasks. Connect to a WebDAV server.
</p> </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> <label class="mb-1 block text-xs font-medium opacity-60">Server URL</label>
<input <input
type="url" type="url"
bind:value={webdavUrl} bind:value={webdavUrl}
placeholder="https://dav.example.com/tasks/" placeholder="https://dav.example.com/remote.php/dav/files/user/"
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" 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"
/> />
@ -252,22 +386,16 @@
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" 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"> {#if testStatus === "fail"}
<button <p class="mb-3 text-xs text-danger">Connection failed. Check your URL and credentials.</p>
onclick={testConnection} {/if}
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 <button
onclick={handleCreateWebdav} onclick={connectAndBrowse}
disabled={!name.trim() || !webdavUrl.trim()} disabled={!webdavUrl.trim() || testStatus === "testing"}
class="w-full rounded-lg bg-primary py-2.5 text-sm font-medium text-white hover:bg-primary-hover disabled:opacity-40" 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 {testStatus === "testing" ? "Connecting..." : "Connect"}
</button> </button>
{#if !isMobile} {#if !isMobile}
@ -278,6 +406,148 @@
Back Back
</button> </button>
{/if} {/if}
{:else if webdavStep === "browse"}
<!-- Step 3: Folder explorer -->
<p class="mb-4 text-sm text-text-secondary-light dark:text-text-secondary-dark">
Pick a folder or create a new workspace.
</p>
<!-- Breadcrumb / back navigation -->
<div class="mb-3 flex items-center gap-1 text-xs text-text-secondary-light dark:text-text-secondary-dark">
{#if browsePath.length > 0}
<button onclick={navigateUp} class="flex items-center gap-0.5 hover:opacity-80">
<svg class="h-3.5 w-3.5" 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}
<span class="truncate font-mono">/{currentBrowsePath}</span>
</div>
<!-- Folder list -->
<div class="mb-4 max-h-48 overflow-y-auto rounded-lg border border-border-light dark:border-border-dark">
{#if browseLoading}
<div class="flex items-center justify-center py-6 text-xs opacity-50">Loading...</div>
{:else if browseError}
<div class="px-3 py-4 text-xs text-danger">{browseError}</div>
{:else if browseEntries.length === 0}
<div class="px-3 py-4 text-xs opacity-50">No folders found.</div>
{:else}
{#each browseEntries as entry}
<button
onclick={() => navigateInto(entry)}
class="flex w-full items-center gap-2 border-b border-border-light px-3 py-2.5 text-left text-sm last:border-b-0 hover:bg-black/5 dark:border-border-dark dark:hover:bg-white/10"
>
{#if entry.is_workspace}
<!-- Workspace icon -->
<svg class="h-4 w-4 shrink-0 text-primary" viewBox="0 0 20 20" fill="currentColor">
<path d="M10.362 1.093a.75.75 0 00-.724 0L2.523 5.018 10 9.143l7.477-4.125-7.115-3.925zM18 6.443l-7.25 4v8.25l6.862-3.786A.75.75 0 0018 14.25V6.443zM9.25 18.693v-8.25l-7.25-4v7.807a.75.75 0 00.388.657l6.862 3.786z" />
</svg>
{:else}
<!-- Folder icon -->
<svg class="h-4 w-4 shrink-0 opacity-40" viewBox="0 0 20 20" fill="currentColor">
<path d="M3.75 3A1.75 1.75 0 002 4.75v3.26a3.235 3.235 0 011.75-.51h12.5c.644 0 1.245.188 1.75.51V6.75A1.75 1.75 0 0016.25 5h-4.836a.25.25 0 01-.177-.073L9.823 3.513A1.75 1.75 0 008.586 3H3.75zM3.75 9A1.75 1.75 0 002 10.75v4.5c0 .966.784 1.75 1.75 1.75h12.5A1.75 1.75 0 0018 15.25v-4.5A1.75 1.75 0 0016.25 9H3.75z" />
</svg>
{/if}
<span class="truncate">{entry.name}</span>
{#if entry.is_workspace}
<span class="ml-auto shrink-0 rounded bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">workspace</span>
{:else}
<svg class="ml-auto h-3.5 w-3.5 shrink-0 opacity-30" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" />
</svg>
{/if}
</button>
{/each}
{/if}
</div>
<button
onclick={startCreate}
class="w-full rounded-lg bg-primary py-2.5 text-sm font-medium text-white hover:bg-primary-hover"
>
Create Workspace
</button>
<button
onclick={webdavBack}
class="mt-3 w-full rounded-lg py-2 text-sm opacity-50 hover:opacity-80"
>
Back
</button>
{:else if webdavStep === "preview"}
<!-- Step 4a: Workspace preview -->
<div class="mb-4 flex items-center gap-2">
<button onclick={() => (webdavStep = "browse")} class="rounded-lg p-1 opacity-50 hover:opacity-80">
<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>
<h2 class="text-lg font-semibold">{previewName}</h2>
</div>
{#if previewLoading}
<div class="flex items-center justify-center py-8 text-xs opacity-50">Loading workspace...</div>
{:else if previewLists.length === 0}
<p class="mb-6 py-4 text-center text-xs opacity-50">No lists in this workspace yet.</p>
{:else}
<div class="mb-6 max-h-48 overflow-y-auto rounded-lg border border-border-light dark:border-border-dark">
{#each previewLists as list}
<div class="flex items-center justify-between border-b border-border-light px-3 py-2.5 text-sm last:border-b-0 dark:border-border-dark">
<span class="truncate">{list.name}</span>
<span class="shrink-0 rounded-full bg-black/5 px-2 py-0.5 text-xs tabular-nums dark:bg-white/10">
{list.task_count} {list.task_count === 1 ? "task" : "tasks"}
</span>
</div>
{/each}
</div>
{/if}
<button
onclick={openExistingWorkspace}
class="w-full rounded-lg bg-primary py-2.5 text-sm font-medium text-white hover:bg-primary-hover"
>
Open Workspace
</button>
{:else if webdavStep === "create"}
<!-- Step 4b: Create workspace -->
<div class="mb-4 flex items-center gap-2">
<button onclick={() => (webdavStep = "browse")} class="rounded-lg p-1 opacity-50 hover:opacity-80">
<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>
<h2 class="text-lg font-semibold">New Workspace</h2>
</div>
<p class="mb-1 text-xs text-text-secondary-light dark:text-text-secondary-dark">
Creating in: <span class="font-mono">/{currentBrowsePath}</span>
</p>
<label class="mb-1 block text-sm font-medium">
Workspace name
<input
type="text"
bind:value={createName}
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 browseError}
<p class="mb-3 text-xs text-danger">{browseError}</p>
{/if}
<button
onclick={handleCreateWebdav}
disabled={!createName.trim() || creating}
class="w-full rounded-lg bg-primary py-2.5 text-sm font-medium text-white hover:bg-primary-hover disabled:opacity-40"
>
{creating ? "Creating..." : "Create Workspace"}
</button>
{/if} {/if}
</div> </div>
</div> </div>

View file

@ -45,14 +45,11 @@
showWorkspacePicker = false; showWorkspacePicker = false;
if (showListMenu && listMenuEl && !listMenuEl.contains(e.target as Node)) if (showListMenu && listMenuEl && !listMenuEl.contains(e.target as Node))
showListMenu = false; showListMenu = false;
const target = e.target as HTMLElement;
if (wsMenuName && !target.closest("[data-ws-menu]")) wsMenuName = null;
} }
let newListName = $state(""); let newListName = $state("");
let showCompleted = $state(false); let showCompleted = $state(false);
let completedVisible = $state(false); let completedVisible = $state(false);
let wsMenuName = $state<string | null>(null);
let renamingListId = $state<string | null>(null); let renamingListId = $state<string | null>(null);
let renameValue = $state(""); let renameValue = $state("");
let showListMenu = $state(false); let showListMenu = $state(false);
@ -137,7 +134,6 @@
if (taskStack.length > 0) { closeDetail(); return; } if (taskStack.length > 0) { closeDetail(); return; }
if (showListMenu) { showListMenu = false; return; } if (showListMenu) { showListMenu = false; return; }
if (showDrawer) { closeDrawer(); return; } if (showDrawer) { closeDrawer(); return; }
if (wsMenuName) { wsMenuName = null; return; }
if (showWorkspacePicker) { showWorkspacePicker = false; return; } if (showWorkspacePicker) { showWorkspacePicker = false; return; }
} }
@ -204,7 +200,7 @@
if (isDesktop) appWindow.startDragging(); if (isDesktop) appWindow.startDragging();
} }
let workspaceNames = $derived(app.config ? Object.keys(app.config.workspaces).sort((a, b) => a.localeCompare(b)) : []); let workspaceIds = $derived(app.config ? Object.keys(app.config.workspaces).sort((a, b) => (app.config!.workspaces[a].name).localeCompare(app.config!.workspaces[b].name)) : []);
let translateX = $derived(showDrawer ? '0' : '-80cqi'); let translateX = $derived(showDrawer ? '0' : '-80cqi');
</script> </script>
@ -219,6 +215,7 @@
> >
<!-- Drawer panel --> <!-- Drawer panel -->
<div class="flex h-full shrink-0 flex-col bg-surface-light dark:bg-surface-dark" style="width: 80cqi"> <div class="flex h-full shrink-0 flex-col bg-surface-light dark:bg-surface-dark" style="width: 80cqi">
<div class="shrink-0" style="height: var(--safe-top)"></div>
<!-- Drawer header: workspace switcher + settings --> <!-- Drawer header: workspace switcher + settings -->
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div <div
@ -230,7 +227,7 @@
onclick={() => (showWorkspacePicker = !showWorkspacePicker)} onclick={() => (showWorkspacePicker = !showWorkspacePicker)}
class="flex items-center gap-1.5 rounded-lg px-2 py-1 text-sm font-semibold hover:bg-black/5 dark:hover:bg-white/10" class="flex items-center gap-1.5 rounded-lg px-2 py-1 text-sm font-semibold hover:bg-black/5 dark:hover:bg-white/10"
> >
<span class="truncate">{app.config?.current_workspace ?? "Workspace"}</span> <span class="truncate">{app.config?.current_workspace ? app.config.workspaces[app.config.current_workspace]?.name ?? "Workspace" : "Workspace"}</span>
<svg class="h-3.5 w-3.5 shrink-0 transition-transform {showWorkspacePicker ? 'rotate-180' : ''}" viewBox="0 0 20 20" fill="currentColor"> <svg class="h-3.5 w-3.5 shrink-0 transition-transform {showWorkspacePicker ? 'rotate-180' : ''}" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" /> <path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" />
</svg> </svg>
@ -240,55 +237,31 @@
<div <div
class="absolute left-0 top-full z-40 mt-1 w-full rounded-lg border border-border-light bg-surface-light py-1 shadow-lg dark:border-border-dark dark:bg-surface-dark" class="absolute left-0 top-full z-40 mt-1 w-full rounded-lg border border-border-light bg-surface-light py-1 shadow-lg dark:border-border-dark dark:bg-surface-dark"
> >
{#each workspaceNames as name} {#each workspaceIds as wsId}
{@const ws = app.config?.workspaces[name]} {@const ws = app.config?.workspaces[wsId]}
<div class="group flex items-center px-1 hover:bg-black/5 dark:hover:bg-white/10"> <div class="group flex items-center px-1 hover:bg-black/5 dark:hover:bg-white/10">
<button <button
onclick={() => { if (name !== app.config?.current_workspace) app.switchWorkspace(name); showWorkspacePicker = false; }} onclick={() => { if (wsId !== app.config?.current_workspace) app.switchWorkspace(wsId); showWorkspacePicker = false; }}
class="flex min-w-0 flex-1 items-center gap-2 px-2 py-1.5 text-left {name === app.config?.current_workspace ? 'font-bold' : ''}" class="flex min-w-0 flex-1 items-center gap-2 px-2 py-1.5 text-left {wsId === app.config?.current_workspace ? 'font-bold' : ''}"
> >
{#if name === app.config?.current_workspace} {#if wsId === app.config?.current_workspace}
<svg class="h-4 w-4 shrink-0 opacity-50" viewBox="0 0 20 20" fill="currentColor"> <svg class="h-4 w-4 shrink-0 opacity-50" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" /> <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" />
</svg> </svg>
{/if} {/if}
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="truncate text-sm">{name}</p> <p class="truncate text-sm">{ws?.name}</p>
<p class="truncate text-xs opacity-40">{ws?.mode === "webdav" ? ws.webdav_url ?? "WebDAV" : ws?.path ?? ""}</p> <p class="truncate text-xs opacity-40">{ws?.mode === "webdav" ? ws.webdav_url ?? "WebDAV" : ws?.path?.replace(/\/[^/]+\/?$/, "") ?? ""}</p>
</div> </div>
</button> </button>
<div class="relative shrink-0" data-ws-menu>
<button <button
onclick={(e) => { e.stopPropagation(); wsMenuName = wsMenuName === name ? null : name; }} onclick={(e) => { e.stopPropagation(); settingsWorkspace = wsId; showSettings = true; showWorkspacePicker = false; }}
class="rounded p-1 opacity-0 transition-opacity group-hover:opacity-40 hover:!opacity-80 {wsMenuName === name ? '!opacity-80' : ''}" class="shrink-0 rounded p-1 opacity-0 transition-opacity group-hover:opacity-40 hover:!opacity-80"
> >
<svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor"> <svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
</svg>
</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" /> <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> </svg>
Settings
</button> </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"
>
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
Delete
</button>
</div>
{/if}
</div>
</div> </div>
{/each} {/each}
<div class="mt-1 border-t border-border-light px-1 pt-1 dark:border-border-dark"> <div class="mt-1 border-t border-border-light px-1 pt-1 dark:border-border-dark">
@ -354,6 +327,34 @@
</div> </div>
</div> </div>
<!-- Drawer footer: sync status -->
<div class="shrink-0 px-4 py-2.5" style="padding-bottom: max(0.625rem, var(--safe-bottom))">
{#if app.isWebdav}
<div class="flex items-center gap-2">
<!-- Status dot -->
<span
class="inline-block h-2 w-2 rounded-full {app.syncing ? 'animate-pulse bg-primary' : app.syncStatus === 'synced' || app.syncStatus === 'idle' ? 'bg-green-500' : app.syncStatus === 'error' ? 'bg-red-500' : 'bg-gray-400'}"
></span>
<span class="flex-1 text-xs opacity-60">
{app.syncing ? "Syncing..." : app.syncStatus === "synced" || app.syncStatus === "idle" ? "Synced" : app.syncStatus === "error" ? "Sync error" : "Offline"}{#if !app.syncing && app.lastSyncResult && (app.lastSyncResult.uploaded > 0 || app.lastSyncResult.downloaded > 0)}&nbsp;&nbsp;{#if app.lastSyncResult.uploaded > 0}{app.lastSyncResult.uploaded}{/if}{#if app.lastSyncResult.uploaded > 0 && app.lastSyncResult.downloaded > 0} {/if}{#if app.lastSyncResult.downloaded > 0}{app.lastSyncResult.downloaded}{/if}{/if}
</span>
<!-- Manual sync button -->
<button
onclick={() => app.triggerSync()}
disabled={app.syncing}
class="rounded-lg p-1.5 hover:bg-black/5 disabled:opacity-30 dark:hover:bg-white/10"
title="Sync now"
>
<svg class="h-4 w-4" style={app.syncing ? 'animation: spin 1s linear infinite reverse' : ''} viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd" />
</svg>
</button>
</div>
{:else}
<span class="text-xs opacity-40">Local workspace</span>
{/if}
</div>
</div> </div>
<!-- Main content panel --> <!-- Main content panel -->
@ -374,6 +375,7 @@
> >
<!-- Sub-panel: Task list --> <!-- Sub-panel: Task list -->
<div class="relative flex h-full w-1/3 flex-col"> <div class="relative flex h-full w-1/3 flex-col">
<div class="shrink-0" style="height: var(--safe-top)"></div>
<!-- Header / drag region --> <!-- Header / drag region -->
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<header <header
@ -495,6 +497,7 @@
<!-- Task list --> <!-- Task list -->
<main class="flex-1 overflow-y-auto"> <main class="flex-1 overflow-y-auto">
{#key app.activeListId}
{#if app.lists.length === 0} {#if app.lists.length === 0}
<div class="flex h-full flex-col items-center justify-center p-8 text-center"> <div class="flex h-full flex-col items-center justify-center p-8 text-center">
<p class="text-lg font-medium opacity-60">No lists yet</p> <p class="text-lg font-medium opacity-60">No lists yet</p>
@ -558,11 +561,13 @@
{/if} {/if}
{/if} {/if}
{/if} {/if}
{/key}
</main> </main>
<!-- FAB button --> <!-- FAB button -->
<div <div
class="pointer-events-none absolute bottom-6 left-0 right-0 z-20 flex justify-center transition-all duration-250 ease-out {newTaskState.open ? 'opacity-0 scale-75' : ''} {showDrawer || taskStack.length > 0 ? 'translate-y-24 opacity-0' : 'translate-y-0 opacity-100'}" class="pointer-events-none absolute left-0 right-0 z-20 flex justify-center transition-all duration-250 ease-out {newTaskState.open ? 'opacity-0 scale-75' : ''} {showDrawer || taskStack.length > 0 ? 'translate-y-24 opacity-0' : 'translate-y-0 opacity-100'}"
style="bottom: max(1.5rem, var(--safe-bottom))"
> >
<button <button
onclick={() => { if (app.activeListId) newTaskState.open = true; }} onclick={() => { if (app.activeListId) newTaskState.open = true; }}
@ -578,6 +583,7 @@
<!-- Sub-panel: Task detail --> <!-- Sub-panel: Task detail -->
<div class="relative flex h-full w-1/3 flex-col bg-surface-light dark:bg-surface-dark"> <div class="relative flex h-full w-1/3 flex-col bg-surface-light dark:bg-surface-dark">
<div class="shrink-0" style="height: var(--safe-top)"></div>
{#if parentTask} {#if parentTask}
{#key parentTask.id} {#key parentTask.id}
<TaskDetailView task={parentTask} onback={closeDetail} onopen={pushTask} /> <TaskDetailView task={parentTask} onback={closeDetail} onopen={pushTask} />
@ -587,6 +593,7 @@
<!-- Sub-panel: Subtask detail --> <!-- Sub-panel: Subtask detail -->
<div class="relative flex h-full w-1/3 flex-col bg-surface-light dark:bg-surface-dark"> <div class="relative flex h-full w-1/3 flex-col bg-surface-light dark:bg-surface-dark">
<div class="shrink-0" style="height: var(--safe-top)"></div>
{#if subtaskDetail} {#if subtaskDetail}
{#key subtaskDetail.id} {#key subtaskDetail.id}
<TaskDetailView task={subtaskDetail} onback={closeDetail} /> <TaskDetailView task={subtaskDetail} onback={closeDetail} />
@ -595,15 +602,6 @@
</div> </div>
</div> </div>
<!-- Sync status indicator -->
{#if app.syncing}
<div class="absolute bottom-4 right-4 z-20 h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
{:else if app.lastSyncResult}
<div class="absolute bottom-4 right-4 z-20 flex items-center gap-1 rounded-full bg-black/10 px-2.5 py-1 text-xs opacity-60 dark:bg-white/10">
<span>{app.lastSyncResult.uploaded}</span>
<span>{app.lastSyncResult.downloaded}</span>
</div>
{/if}
</div> </div>
</div> </div>
</div> </div>
@ -612,7 +610,7 @@
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div <div
class="absolute inset-0 z-50 flex transition-opacity duration-200 {showSettings ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'}" class="absolute inset-0 z-50 flex transition-opacity duration-200 {showSettings ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'}"
style="padding: 4%" style="padding: 4%; padding-top: max(4%, env(safe-area-inset-top)); padding-bottom: max(4%, env(safe-area-inset-bottom))"
> >
<!-- Backdrop --> <!-- Backdrop -->
<div <div
@ -625,7 +623,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'}" 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)" 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} workspaceName={settingsWorkspace ?? app.config?.current_workspace ?? ""} /> <SettingsScreen onclose={closeSettings} workspaceId={settingsWorkspace ?? app.config?.current_workspace ?? ""} ondelete={(id) => { closeSettings(); confirmRemoveWorkspace = id; }} />
</div> </div>
</div> </div>
@ -648,11 +646,11 @@
<!-- Remove workspace confirmation --> <!-- Remove workspace confirmation -->
{#if confirmRemoveWorkspace} {#if confirmRemoveWorkspace}
<ConfirmDialog <ConfirmDialog
message='Remove workspace "{confirmRemoveWorkspace}"?' message='Remove workspace "{app.config?.workspaces[confirmRemoveWorkspace]?.name ?? confirmRemoveWorkspace}"?'
detail="Files remain on disk." detail="Files remain on disk."
confirmText="Remove" confirmText="Remove"
danger danger
onconfirm={() => { const name = confirmRemoveWorkspace; confirmRemoveWorkspace = null; if (name) app.removeWorkspace(name); }} onconfirm={() => { const id = confirmRemoveWorkspace; confirmRemoveWorkspace = null; if (id) app.removeWorkspace(id); }}
oncancel={() => (confirmRemoveWorkspace = null)} oncancel={() => (confirmRemoveWorkspace = null)}
/> />
{/if} {/if}

View file

@ -1,5 +1,6 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import type { import type {
AppConfig, AppConfig,
Task, Task,
@ -11,6 +12,8 @@ import type {
// Listen for file system changes from the backend watcher. // Listen for file system changes from the backend watcher.
listen("fs-changed", () => { listen("fs-changed", () => {
loadLists(); loadLists();
// Debounced sync for WebDAV workspaces on local file changes
if (isWebdav) debouncedSync();
}); });
// ── Reactive state ─────────────────────────────────────────────────── // ── Reactive state ───────────────────────────────────────────────────
@ -22,9 +25,18 @@ let activeListId = $state<string | null>(null);
let tasks = $state<Task[]>([]); let tasks = $state<Task[]>([]);
let osDark = globalThis.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false; let osDark = globalThis.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false;
let syncing = $state(false); let syncing = $state(false);
let syncMode = $state<"full" | "push" | "pull">("full"); let initialSync = $state(false);
let syncStatus = $state<"idle" | "synced" | "error" | "offline">("idle");
let lastSyncResult = $state<SyncResult | null>(null); let lastSyncResult = $state<SyncResult | null>(null);
let error = $state<string | null>(null); let error = $state<string | null>(null);
let missingWorkspace = $state<string | null>(null);
let lastSyncTime = 0;
let _syncInterval: ReturnType<typeof setInterval> | null = null;
let _syncDebounce: ReturnType<typeof setTimeout> | null = null;
let _focusUnlisten: (() => void) | null = null;
const DEFAULT_SYNC_INTERVAL_SECS = 60;
const SYNC_DEBOUNCE_MS = 5_000;
const SYNC_FOCUS_THRESHOLD_MS = 30_000;
// ── Derived ────────────────────────────────────────────────────────── // ── Derived ──────────────────────────────────────────────────────────
@ -63,6 +75,16 @@ let currentTheme = $derived(
let isDark = $derived( let isDark = $derived(
currentTheme ? DARK_THEMES.has(currentTheme) : osDark, currentTheme ? DARK_THEMES.has(currentTheme) : osDark,
); );
let isWebdav = $derived(
config?.current_workspace
? config.workspaces[config.current_workspace]?.mode === "webdav"
: false,
);
let syncIntervalSecs = $derived(
config?.current_workspace
? config.workspaces[config.current_workspace]?.sync_interval_secs ?? DEFAULT_SYNC_INTERVAL_SECS
: DEFAULT_SYNC_INTERVAL_SECS,
);
// ── Actions ────────────────────────────────────────────────────────── // ── Actions ──────────────────────────────────────────────────────────
@ -70,8 +92,19 @@ async function loadConfig() {
try { try {
config = await invoke<AppConfig>("get_config"); config = await invoke<AppConfig>("get_config");
if (hasWorkspace) { if (hasWorkspace) {
// Try loading lists — if the workspace path is gone, get_lists will fail
lists = [];
try {
lists = await invoke<TaskList[]>("get_lists");
} catch {
missingWorkspace = config!.current_workspace;
screen = "missing";
return;
}
if (lists.length > 0 && !activeListId) activeListId = lists[0].id;
if (activeListId) await loadTasks();
screen = "tasks"; screen = "tasks";
await loadLists(); if (isWebdav) startAutoSync();
} else { } else {
screen = "setup"; screen = "setup";
} }
@ -95,23 +128,35 @@ async function addWorkspace(name: string, path: string) {
} }
} }
async function switchWorkspace(name: string) { async function switchWorkspace(id: string) {
try { try {
await invoke("set_current_workspace", { name }); await invoke("set_current_workspace", { id });
config = await invoke<AppConfig>("get_config"); config = await invoke<AppConfig>("get_config");
activeListId = null; activeListId = null;
await loadLists(); await loadLists();
const ws = config?.workspaces[name]; const ws = config?.workspaces[id];
if (ws) invoke("watch_workspace", { path: ws.path }).catch((e) => console.warn("File watcher failed:", e)); if (ws) invoke("watch_workspace", { path: ws.path }).catch((e) => console.warn("File watcher failed:", e));
if (isWebdav) startAutoSync(); else stopAutoSync();
error = null; error = null;
} catch (e) { } catch (e) {
error = String(e); error = String(e);
} }
} }
async function removeWorkspace(name: string) { async function renameWorkspace(id: string, newName: string) {
try { try {
await invoke("remove_workspace", { name }); await invoke("rename_workspace", { id, newName });
config = await invoke<AppConfig>("get_config");
error = null;
} catch (e) {
error = String(e);
}
}
async function removeWorkspace(id: string) {
stopAutoSync();
try {
await invoke("remove_workspace", { id });
config = await invoke<AppConfig>("get_config"); config = await invoke<AppConfig>("get_config");
if (!hasWorkspace) { if (!hasWorkspace) {
screen = "setup"; screen = "setup";
@ -139,7 +184,14 @@ async function loadLists() {
async function loadTasks() { async function loadTasks() {
if (!activeListId) return; if (!activeListId) return;
try { try {
tasks = await invoke<Task[]>("list_tasks", { listId: activeListId }); const loaded = await invoke<Task[]>("list_tasks", { listId: activeListId });
// Deduplicate by task ID — sync conflicts can produce files with the same UUID
const seen = new Set<string>();
tasks = loaded.filter((t) => {
if (seen.has(t.id)) return false;
seen.add(t.id);
return true;
});
} catch (e) { } catch (e) {
error = String(e); error = String(e);
} }
@ -147,6 +199,7 @@ async function loadTasks() {
async function selectList(id: string) { async function selectList(id: string) {
activeListId = id; activeListId = id;
tasks = [];
await loadTasks(); await loadTasks();
} }
@ -281,36 +334,69 @@ async function setGroupByDueDate(listId: string, enabled: boolean) {
} }
async function triggerSync() { async function triggerSync() {
if (!config?.current_workspace) return; if (!config?.current_workspace || syncing) return;
syncing = true; syncing = true;
error = null;
try { try {
const result = await invoke<SyncResult>("sync_workspace", { const result = await invoke<SyncResult>("sync_workspace", {
workspaceName: config.current_workspace, workspaceId: config.current_workspace,
mode: syncMode, mode: "full",
}); });
lastSyncResult = result; lastSyncResult = result;
if (result.errors.length > 0) { lastSyncTime = Date.now();
error = result.errors.join("; "); syncStatus = result.errors.length > 0 ? "error" : "synced";
} if (result.errors.length > 0) error = result.errors.join("; ");
config = await invoke<AppConfig>("get_config"); config = await invoke<AppConfig>("get_config");
await loadLists(); await loadLists();
} catch (e) { } catch (e) {
error = String(e); const msg = String(e);
const isTransient = /timeout|connect|network|unreachable|refused/i.test(msg);
syncStatus = isTransient ? "offline" : "error";
// Only show the error banner for non-transient failures; connectivity issues just update the status dot
if (!isTransient) error = msg;
} finally { } finally {
syncing = false; syncing = false;
} }
} }
function setSyncMode(mode: "full" | "push" | "pull") { function debouncedSync() {
syncMode = mode; if (_syncDebounce) clearTimeout(_syncDebounce);
_syncDebounce = setTimeout(() => { _syncDebounce = null; triggerSync(); }, SYNC_DEBOUNCE_MS);
}
function startAutoSync() {
stopAutoSync();
triggerSync();
_syncInterval = setInterval(triggerSync, syncIntervalSecs * 1000);
getCurrentWindow().onFocusChanged(({ payload: focused }) => {
if (focused && Date.now() - lastSyncTime > SYNC_FOCUS_THRESHOLD_MS) triggerSync();
}).then((unlisten) => { _focusUnlisten = unlisten; });
}
function stopAutoSync() {
if (_syncInterval) { clearInterval(_syncInterval); _syncInterval = null; }
if (_syncDebounce) { clearTimeout(_syncDebounce); _syncDebounce = null; }
if (_focusUnlisten) { _focusUnlisten(); _focusUnlisten = null; }
}
async function setSyncInterval(secs: number | null) {
if (!config?.current_workspace) return;
try {
await invoke("set_sync_interval", {
workspaceId: config.current_workspace,
intervalSecs: secs,
});
config = await invoke<AppConfig>("get_config");
if (isWebdav) startAutoSync();
} catch (e) {
error = String(e);
}
} }
async function setTheme(theme: string | null) { async function setTheme(theme: string | null) {
if (!config?.current_workspace) return; if (!config?.current_workspace) return;
try { try {
await invoke("set_workspace_theme", { await invoke("set_workspace_theme", {
workspaceName: config.current_workspace, workspaceId: config.current_workspace,
theme, theme,
}); });
config = await invoke<AppConfig>("get_config"); config = await invoke<AppConfig>("get_config");
@ -319,20 +405,51 @@ async function setTheme(theme: string | null) {
} }
} }
async function addWebdavWorkspace(name: string, webdavUrl: string, username: string, password: string) { async function addWebdavWorkspace(name: string, webdavUrl: string, webdavPath: string, username: string, password: string) {
try { try {
await invoke("add_webdav_workspace", { name, webdavUrl, username, password }); await invoke("add_webdav_workspace", { name, webdavUrl, webdavPath, username, password });
config = await invoke<AppConfig>("get_config"); 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"; screen = "tasks";
error = null; error = null;
// Run initial sync before showing content so the workspace isn't empty
initialSync = true;
try {
await triggerSync();
} finally {
initialSync = false;
}
await loadLists();
if (config?.current_workspace) {
const ws = config.workspaces[config.current_workspace];
if (ws) invoke("watch_workspace", { path: ws.path }).catch((e) => console.warn("File watcher failed:", e));
}
if (isWebdav) startAutoSync();
} catch (e) { } catch (e) {
initialSync = false;
error = String(e); error = String(e);
} }
} }
async function forgetMissingWorkspace() {
if (!missingWorkspace) return;
await removeWorkspace(missingWorkspace);
missingWorkspace = null;
config = await invoke<AppConfig>("get_config");
if (hasWorkspace) {
// Switch to the next available workspace
const nextName = Object.keys(config!.workspaces)[0];
if (nextName) {
await switchWorkspace(nextName);
screen = "tasks";
return;
}
}
screen = "setup";
lists = [];
tasks = [];
activeListId = null;
}
function setScreen(s: Screen) { function setScreen(s: Screen) {
screen = s; screen = s;
} }
@ -377,8 +494,17 @@ export const app = {
get syncing() { get syncing() {
return syncing; return syncing;
}, },
get syncMode() { get initialSync() {
return syncMode; return initialSync;
},
get syncStatus() {
return syncStatus;
},
get isWebdav() {
return isWebdav;
},
get syncIntervalSecs() {
return syncIntervalSecs;
}, },
get lastSyncResult() { get lastSyncResult() {
return lastSyncResult; return lastSyncResult;
@ -389,10 +515,14 @@ export const app = {
get hasWorkspace() { get hasWorkspace() {
return hasWorkspace; return hasWorkspace;
}, },
get missingWorkspace() {
return missingWorkspace;
},
getSubtasks, getSubtasks,
loadConfig, loadConfig,
addWorkspace, addWorkspace,
switchWorkspace, switchWorkspace,
renameWorkspace,
removeWorkspace, removeWorkspace,
loadLists, loadLists,
loadTasks, loadTasks,
@ -408,9 +538,12 @@ export const app = {
renameList, renameList,
setGroupByDueDate, setGroupByDueDate,
triggerSync, triggerSync,
setSyncMode, startAutoSync,
stopAutoSync,
setSyncInterval,
setTheme, setTheme,
addWebdavWorkspace, addWebdavWorkspace,
forgetMissingWorkspace,
setScreen, setScreen,
clearError, clearError,
}; };

View file

@ -5,8 +5,7 @@ export interface Task {
status: "backlog" | "completed"; status: "backlog" | "completed";
due_date: string | null; due_date: string | null;
has_time: boolean; has_time: boolean;
created_at: string; version: number;
updated_at: string;
parent_id: string | null; parent_id: string | null;
} }
@ -22,11 +21,14 @@ export interface TaskList {
export type WorkspaceMode = "local" | "webdav"; export type WorkspaceMode = "local" | "webdav";
export interface WorkspaceConfig { export interface WorkspaceConfig {
name: string;
path: string; path: string;
mode: WorkspaceMode; mode: WorkspaceMode;
webdav_url: string | null; webdav_url: string | null;
webdav_path: string | null;
last_sync: string | null; last_sync: string | null;
theme: string | null; theme: string | null;
sync_interval_secs: number | null;
} }
export interface AppConfig { export interface AppConfig {
@ -43,4 +45,4 @@ export interface SyncResult {
errors: string[]; errors: string[];
} }
export type Screen = "setup" | "tasks" | "settings"; export type Screen = "setup" | "tasks" | "settings" | "missing";

View file

@ -0,0 +1,19 @@
[package]
name = "tauri-plugin-credentials"
version = "0.1.0"
edition = "2021"
links = "tauri-plugin-credentials"
[build-dependencies]
tauri-plugin = { version = "2", features = ["build"] }
[dependencies]
tauri = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
log = "0.4"
keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"], optional = true }
[features]
default = ["desktop"]
desktop = ["keyring"]

View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,47 @@
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "app.tauri"
compileSdk = 36
defaultConfig {
minSdk = 21
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("proguard-rules.pro")
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
buildConfig = true
}
}
dependencies {
implementation("androidx.core:core-ktx:1.7.0")
implementation("androidx.appcompat:appcompat:1.6.0")
implementation("com.google.android.material:material:1.7.0")
implementation("com.fasterxml.jackson.core:jackson-databind:2.15.3")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
}

View file

@ -0,0 +1,41 @@
-keep class app.tauri.** {
@app.tauri.JniMethod public <methods>;
native <methods>;
}
-keep class app.tauri.plugin.JSArray {
public <init>(...);
}
-keepclassmembers class org.json.JSONArray {
public put(...);
}
-keep class app.tauri.plugin.JSObject {
public <init>(...);
public put(...);
}
-keep @app.tauri.annotation.TauriPlugin public class * {
@app.tauri.annotation.Command public <methods>;
@app.tauri.annotation.PermissionCallback <methods>;
@app.tauri.annotation.ActivityCallback <methods>;
@app.tauri.annotation.Permission <methods>;
public <init>(...);
}
-keep @app.tauri.annotation.InvokeArg public class * {
*;
}
-keep @com.fasterxml.jackson.databind.annotation.JsonDeserialize public class * {
*;
}
-keep @com.fasterxml.jackson.databind.annotation.JsonSerialize public class * {
*;
}
-keep class * extends com.fasterxml.jackson.databind.JsonDeserializer { *; }
-keep class * extends com.fasterxml.jackson.databind.JsonSerializer { *; }

View file

@ -0,0 +1,28 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
package app.tauri
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("app.tauri.test", appContext.packageName)
}
}

View file

@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>

View file

@ -0,0 +1,54 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
package app.tauri
import android.app.Activity
import android.webkit.WebView
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AppCompatActivity
import app.tauri.annotation.Command
import app.tauri.annotation.TauriPlugin
import app.tauri.plugin.Plugin
import app.tauri.plugin.Invoke
import app.tauri.plugin.JSObject
@TauriPlugin
class AppPlugin(private val activity: Activity): Plugin(activity) {
private val BACK_BUTTON_EVENT = "back-button"
private var webView: WebView? = null
override fun load(webView: WebView) {
this.webView = webView
}
init {
val callback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
if (!hasListener(BACK_BUTTON_EVENT)) {
if (this@AppPlugin.webView?.canGoBack() == true) {
this@AppPlugin.webView!!.goBack()
} else {
this.isEnabled = false
this@AppPlugin.activity.onBackPressed()
this.isEnabled = true
}
} else {
val data = JSObject().apply {
put("canGoBack", this@AppPlugin.webView?.canGoBack() ?: false)
}
trigger(BACK_BUTTON_EVENT, data)
}
}
}
(activity as AppCompatActivity).onBackPressedDispatcher.addCallback(activity, callback)
}
@Command
fun exit(invoke: Invoke) {
invoke.resolve()
activity.finish()
}
}

View file

@ -0,0 +1,208 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
package app.tauri
import android.content.ContentUris
import android.content.Context
import android.content.res.AssetManager
import android.database.Cursor
import android.net.Uri
import android.os.Environment
import android.provider.DocumentsContract
import android.provider.MediaStore
import android.provider.OpenableColumns
import java.io.File
import java.io.FileOutputStream
import kotlin.math.min
internal class FsUtils {
companion object {
fun readAsset(assetManager: AssetManager, fileName: String): String {
assetManager.open(fileName).bufferedReader().use {
return it.readText()
}
}
fun getFileUrlForUri(context: Context, uri: Uri): String? {
// DocumentProvider
if (DocumentsContract.isDocumentUri(context, uri)) {
// ExternalStorageProvider
if (isExternalStorageDocument(uri)) {
val docId: String = DocumentsContract.getDocumentId(uri)
val split = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }
.toTypedArray()
val type = split[0]
if ("primary".equals(type, ignoreCase = true)) {
return legacyPrimaryPath(split[1])
} else {
val splitIndex = docId.indexOf(':', 1)
val tag = docId.substring(0, splitIndex)
val path = docId.substring(splitIndex + 1)
val nonPrimaryVolume = getPathToNonPrimaryVolume(context, tag)
if (nonPrimaryVolume != null) {
val result = "$nonPrimaryVolume/$path"
val file = File(result)
return if (file.exists() && file.canRead()) {
result
} else null
}
}
} else if (isDownloadsDocument(uri)) {
val id: String = DocumentsContract.getDocumentId(uri)
val contentUri: Uri = ContentUris.withAppendedId(
Uri.parse("content://downloads/public_downloads"),
java.lang.Long.valueOf(id)
)
return getDataColumn(context, contentUri, null, null)
} else if (isMediaDocument(uri)) {
val docId: String = DocumentsContract.getDocumentId(uri)
val split = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }
.toTypedArray()
val type = split[0]
var contentUri: Uri? = null
when (type) {
"image" -> {
contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
}
"video" -> {
contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI
}
"audio" -> {
contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
}
}
val selection = "_id=?"
val selectionArgs = arrayOf(split[1])
if (contentUri != null) {
return getDataColumn(context, contentUri, selection, selectionArgs)
}
}
} else if ("content".equals(uri.scheme, ignoreCase = true)) {
// Return the remote address
return if (isGooglePhotosUri(uri)) uri.lastPathSegment else getDataColumn(
context,
uri,
null,
null
)
} else if ("file".equals(uri.scheme, ignoreCase = true)) {
return uri.path
}
return null
}
/**
* Get the value of the data column for this Uri. This is useful for
* MediaStore Uris, and other file-based ContentProviders.
*
* @param context The context.
* @param uri The Uri to query.
* @param selection (Optional) Filter used in the query.
* @param selectionArgs (Optional) Selection arguments used in the query.
* @return The value of the _data column, which is typically a file path.
*/
private fun getDataColumn(
context: Context,
uri: Uri,
selection: String?,
selectionArgs: Array<String>?
): String? {
var path: String? = null
var cursor: Cursor? = null
val column = "_data"
val projection = arrayOf(column)
try {
cursor = context.contentResolver.query(uri, projection, selection, selectionArgs, null)
if (cursor != null && cursor.moveToFirst()) {
val index = cursor.getColumnIndexOrThrow(column)
path = cursor.getString(index)
}
} catch (ex: IllegalArgumentException) {
return getCopyFilePath(uri, context)
} finally {
cursor?.close()
}
return path ?: getCopyFilePath(uri, context)
}
private fun getCopyFilePath(uri: Uri, context: Context): String? {
val cursor = context.contentResolver.query(uri, null, null, null, null)!!
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
cursor.moveToFirst()
val name = cursor.getString(nameIndex)
val file = File(context.filesDir, name)
try {
val inputStream = context.contentResolver.openInputStream(uri)
val outputStream = FileOutputStream(file)
var read: Int
val maxBufferSize = 1024 * 1024
val bufferSize = min(inputStream!!.available(), maxBufferSize)
val buffers = ByteArray(bufferSize)
while (inputStream.read(buffers).also { read = it } != -1) {
outputStream.write(buffers, 0, read)
}
inputStream.close()
outputStream.close()
} catch (e: Exception) {
return null
} finally {
cursor.close()
}
return file.path
}
private fun legacyPrimaryPath(pathPart: String): String {
return Environment.getExternalStorageDirectory().toString() + "/" + pathPart
}
/**
* @param uri The Uri to check.
* @return Whether the Uri authority is ExternalStorageProvider.
*/
private fun isExternalStorageDocument(uri: Uri): Boolean {
return "com.android.externalstorage.documents" == uri.authority
}
/**
* @param uri The Uri to check.
* @return Whether the Uri authority is DownloadsProvider.
*/
private fun isDownloadsDocument(uri: Uri): Boolean {
return "com.android.providers.downloads.documents" == uri.authority
}
/**
* @param uri The Uri to check.
* @return Whether the Uri authority is MediaProvider.
*/
private fun isMediaDocument(uri: Uri): Boolean {
return "com.android.providers.media.documents" == uri.authority
}
/**
* @param uri The Uri to check.
* @return Whether the Uri authority is Google Photos.
*/
private fun isGooglePhotosUri(uri: Uri): Boolean {
return "com.google.android.apps.photos.content" == uri.authority
}
private fun getPathToNonPrimaryVolume(context: Context, tag: String): String? {
val volumes = context.externalCacheDirs
if (volumes != null) {
for (volume in volumes) {
if (volume != null) {
val path = volume.absolutePath
val index = path.indexOf(tag)
if (index != -1) {
return path.substring(0, index) + tag
}
}
}
}
return null
}
}
}

View file

@ -0,0 +1,8 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
package app.tauri
@Retention(AnnotationRetention.RUNTIME)
internal annotation class JniMethod

View file

@ -0,0 +1,85 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
package app.tauri
// taken from https://github.com/ionic-team/capacitor/blob/6658bca41e78239347e458175b14ca8bd5c1d6e8/android/capacitor/src/main/java/com/getcapacitor/Logger.java
import android.text.TextUtils;
import android.util.Log;
class Logger {
companion object {
private const val LOG_TAG_CORE = "Tauri"
fun tags(vararg subtags: String): String {
return if (subtags.isNotEmpty()) {
LOG_TAG_CORE + "/" + TextUtils.join("/", subtags)
} else LOG_TAG_CORE
}
fun verbose(message: String) {
verbose(LOG_TAG_CORE, message)
}
fun verbose(tag: String, message: String) {
if (!shouldLog()) {
return
}
Log.v(tag, message)
}
fun debug(message: String) {
debug(LOG_TAG_CORE, message)
}
fun debug(tag: String, message: String) {
if (!shouldLog()) {
return
}
Log.d(tag, message)
}
fun info(message: String) {
info(LOG_TAG_CORE, message)
}
fun info(tag: String, message: String) {
if (!shouldLog()) {
return
}
Log.i(tag, message)
}
fun warn(message: String) {
warn(LOG_TAG_CORE, message)
}
fun warn(tag: String, message: String) {
if (!shouldLog()) {
return
}
Log.w(tag, message)
}
fun error(message: String) {
error(LOG_TAG_CORE, message, null)
}
fun error(message: String, e: Throwable?) {
error(LOG_TAG_CORE, message, e)
}
fun error(tag: String, message: String, e: Throwable?) {
if (!shouldLog()) {
return
}
Log.e(tag, message, e)
}
private fun shouldLog(): Boolean {
return BuildConfig.DEBUG
}
}
}

View file

@ -0,0 +1,132 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
package app.tauri
import android.app.Activity
import android.database.Cursor
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.OpenableColumns
import app.tauri.annotation.Command
import app.tauri.annotation.InvokeArg
import app.tauri.annotation.TauriPlugin
import app.tauri.plugin.Plugin
import app.tauri.plugin.Invoke
import app.tauri.plugin.JSObject
const val TAURI_ASSETS_DIRECTORY_URI = "asset://localhost/"
@InvokeArg
class GetFileNameFromUriArgs {
lateinit var uri: String
}
@TauriPlugin
class PathPlugin(private val activity: Activity): Plugin(activity) {
private fun resolvePath(invoke: Invoke, path: String?) {
val obj = JSObject()
obj.put("path", path)
invoke.resolve(obj)
}
@Command
fun getFileNameFromUri(invoke: Invoke) {
val args = invoke.parseArgs(GetFileNameFromUriArgs::class.java)
val name = getRealNameFromURI(activity, Uri.parse(args.uri))
val res = JSObject()
res.put("name", name)
invoke.resolve(res)
}
@Command
fun getAudioDir(invoke: Invoke) {
resolvePath(invoke, activity.getExternalFilesDir(Environment.DIRECTORY_MUSIC)?.absolutePath)
}
@Command
fun getExternalCacheDir(invoke: Invoke) {
resolvePath(invoke, activity.externalCacheDir?.absolutePath)
}
@Command
fun getConfigDir(invoke: Invoke) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
resolvePath(invoke, activity.dataDir.absolutePath)
} else {
resolvePath(invoke, activity.applicationInfo.dataDir)
}
}
@Command
fun getDataDir(invoke: Invoke) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
resolvePath(invoke, activity.dataDir.absolutePath)
} else {
resolvePath(invoke, activity.applicationInfo.dataDir)
}
}
@Command
fun getDocumentDir(invoke: Invoke) {
resolvePath(invoke, activity.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS)?.absolutePath)
}
@Command
fun getDownloadDir(invoke: Invoke) {
resolvePath(invoke, activity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)?.absolutePath)
}
@Command
fun getPictureDir(invoke: Invoke) {
resolvePath(invoke, activity.getExternalFilesDir(Environment.DIRECTORY_PICTURES)?.absolutePath)
}
@Command
fun getPublicDir(invoke: Invoke) {
resolvePath(invoke, activity.getExternalFilesDir(Environment.DIRECTORY_DCIM)?.absolutePath)
}
@Command
fun getVideoDir(invoke: Invoke) {
resolvePath(invoke, activity.externalCacheDir?.absolutePath)
}
@Command
fun getResourcesDir(invoke: Invoke) {
resolvePath(invoke, TAURI_ASSETS_DIRECTORY_URI)
}
@Command
fun getCacheDir(invoke: Invoke) {
resolvePath(invoke, activity.cacheDir.absolutePath)
}
@Command
fun getHomeDir(invoke: Invoke) {
resolvePath(invoke, Environment.getExternalStorageDirectory().absolutePath)
}
}
fun getRealNameFromURI(activity: Activity, contentUri: Uri): String? {
var cursor: Cursor? = null
try {
val projection = arrayOf(OpenableColumns.DISPLAY_NAME)
cursor = activity.contentResolver.query(contentUri, projection, null, null, null)
cursor?.let {
val columnIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (it.moveToFirst()) {
return it.getString(columnIndex)
}
}
} catch (e: Exception) {
Logger.error("failed to get real name from URI $e")
} finally {
cursor?.close()
}
return null // Return null if no file name could be resolved
}

View file

@ -0,0 +1,113 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
package app.tauri
// taken from https://github.com/ionic-team/capacitor/blob/6658bca41e78239347e458175b14ca8bd5c1d6e8/android/capacitor/src/main/java/com/getcapacitor/PermissionHelper.java
import android.content.Context;
import android.content.pm.PackageManager;
import android.os.Build;
import androidx.core.app.ActivityCompat;
import java.util.ArrayList;
object PermissionHelper {
/**
* Checks if a list of given permissions are all granted by the user
*
* @param permissions Permissions to check.
* @return True if all permissions are granted, false if at least one is not.
*/
fun hasPermissions(context: Context?, permissions: Array<String>): Boolean {
for (perm in permissions) {
if (ActivityCompat.checkSelfPermission(
context!!,
perm
) != PackageManager.PERMISSION_GRANTED
) {
return false
}
}
return true
}
/**
* Check whether the given permission has been defined in the AndroidManifest.xml
*
* @param permission A permission to check.
* @return True if the permission has been defined in the Manifest, false if not.
*/
fun hasDefinedPermission(context: Context, permission: String): Boolean {
var hasPermission = false
val requestedPermissions = getManifestPermissions(context)
if (requestedPermissions != null && requestedPermissions.isNotEmpty()) {
val requestedPermissionsList = listOf(*requestedPermissions)
val requestedPermissionsArrayList = ArrayList(requestedPermissionsList)
if (requestedPermissionsArrayList.contains(permission)) {
hasPermission = true
}
}
return hasPermission
}
/**
* Check whether all of the given permissions have been defined in the AndroidManifest.xml
* @param context the app context
* @param permissions a list of permissions
* @return true only if all permissions are defined in the AndroidManifest.xml
*/
fun hasDefinedPermissions(context: Context, permissions: Array<String>): Boolean {
for (permission in permissions) {
if (!hasDefinedPermission(context, permission)) {
return false
}
}
return true
}
/**
* Get the permissions defined in AndroidManifest.xml
*
* @return The permissions defined in AndroidManifest.xml
*/
private fun getManifestPermissions(context: Context): Array<String>? {
var requestedPermissions: Array<String>? = null
try {
val pm = context.packageManager
val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
pm.getPackageInfo(context.packageName, PackageManager.PackageInfoFlags.of(PackageManager.GET_PERMISSIONS.toLong()))
} else {
@Suppress("DEPRECATION")
pm.getPackageInfo(context.packageName, PackageManager.GET_PERMISSIONS)
}
if (packageInfo != null) {
requestedPermissions = packageInfo.requestedPermissions
}
} catch (_: Exception) {
}
return requestedPermissions
}
/**
* Given a list of permissions, return a new list with the ones not present in AndroidManifest.xml
*
* @param neededPermissions The permissions needed.
* @return The permissions not present in AndroidManifest.xml
*/
fun getUndefinedPermissions(context: Context, neededPermissions: Array<String>): Array<String> {
val undefinedPermissions = ArrayList<String>()
val requestedPermissions = getManifestPermissions(context)
if (!requestedPermissions.isNullOrEmpty()) {
val requestedPermissionsList = listOf(*requestedPermissions)
val requestedPermissionsArrayList = ArrayList(requestedPermissionsList)
for (permission in neededPermissions) {
if (!requestedPermissionsArrayList.contains(permission)) {
undefinedPermissions.add(permission)
}
}
return undefinedPermissions.toTypedArray()
}
return neededPermissions
}
}

View file

@ -0,0 +1,21 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
package app.tauri
import java.util.*
enum class PermissionState(private val state: String) {
GRANTED("granted"), DENIED("denied"), PROMPT("prompt"), PROMPT_WITH_RATIONALE("prompt-with-rationale");
override fun toString(): String {
return state
}
companion object {
fun byState(state: String): PermissionState {
return valueOf(state.uppercase(Locale.ROOT).replace('-', '_'))
}
}
}

View file

@ -0,0 +1,9 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
package app.tauri.annotation
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.FUNCTION)
annotation class ActivityCallback

View file

@ -0,0 +1,9 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
package app.tauri.annotation
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.CLASS)
annotation class InvokeArg

View file

@ -0,0 +1,19 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
package app.tauri.annotation
@Retention(AnnotationRetention.RUNTIME)
annotation class Permission(
/**
* An array of Android permission strings.
* Eg: {Manifest.permission.ACCESS_COARSE_LOCATION}
* or {"android.permission.ACCESS_COARSE_LOCATION"}
*/
val strings: Array<String> = [],
/**
* An optional name to use instead of the Android permission string.
*/
val alias: String = ""
)

View file

@ -0,0 +1,9 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
package app.tauri.annotation
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.FUNCTION)
annotation class PermissionCallback

View file

@ -0,0 +1,8 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
package app.tauri.annotation
@Retention(AnnotationRetention.RUNTIME)
annotation class Command

View file

@ -0,0 +1,19 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
package app.tauri.annotation
import app.tauri.annotation.Permission
/**
* Base annotation for all Plugins
*/
@Retention(AnnotationRetention.RUNTIME)
annotation class TauriPlugin(
/**
* Permissions this plugin needs, in order to make permission requests
* easy if the plugin only needs basic permission prompting
*/
val permissions: Array<Permission> = []
)

View file

@ -0,0 +1,33 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
package app.tauri.plugin
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonDeserializer
import com.fasterxml.jackson.databind.ObjectMapper
const val CHANNEL_PREFIX = "__CHANNEL__:"
internal class ChannelDeserializer(val sendChannelData: (channelId: Long, data: String) -> Unit, private val objectMapper: ObjectMapper): JsonDeserializer<Channel>() {
override fun deserialize(
jsonParser: JsonParser?,
deserializationContext: DeserializationContext
): Channel {
val channelDef = deserializationContext.readValue(jsonParser, String::class.java)
val callback = channelDef.substring(CHANNEL_PREFIX.length).toLongOrNull() ?: throw Error("unexpected channel value $channelDef")
return Channel(callback, { res -> sendChannelData(callback, res) }, objectMapper)
}
}
class Channel(val id: Long, private val handler: (data: String) -> Unit, private val objectMapper: ObjectMapper) {
fun send(data: JSObject) {
handler(PluginResult(data).toString())
}
fun sendObject(data: Any) {
handler(objectMapper.writeValueAsString(data))
}
}

View file

@ -0,0 +1,11 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
package app.tauri.plugin
internal class InvalidCommandException : Exception {
constructor(s: String?) : super(s) {}
constructor(t: Throwable?) : super(t) {}
constructor(s: String?, t: Throwable?) : super(s, t) {}
}

View file

@ -0,0 +1,93 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
package app.tauri.plugin
import app.tauri.Logger
import com.fasterxml.jackson.core.type.TypeReference
import com.fasterxml.jackson.databind.ObjectMapper
class Invoke(
val id: Long,
val command: String,
val callback: Long,
val error: Long,
private val sendResponse: (callback: Long, data: String) -> Unit,
private val argsJson: String,
private val jsonMapper: ObjectMapper
) {
fun getRawArgs(): String {
return argsJson
}
fun getArgs(): JSObject {
return JSObject(argsJson)
}
fun<T> parseArgs(cls: Class<T>): T {
return jsonMapper.readValue(argsJson, cls)
}
fun<T> parseArgs(ref: TypeReference<T>): T {
return jsonMapper.readValue(argsJson, ref)
}
fun resolve(data: JSObject?) {
sendResponse(callback, PluginResult(data).toString())
}
fun resolveObject(data: Any) {
sendResponse(callback, jsonMapper.writeValueAsString(data))
}
fun resolve() {
sendResponse(callback, "null")
}
fun reject(msg: String?, code: String?, ex: Exception?, data: JSObject?) {
val errorResult = PluginResult()
if (ex != null) {
Logger.error(Logger.tags("Plugin"), msg!!, ex)
}
errorResult.put("message", msg)
if (code != null) {
errorResult.put("code", code)
}
if (data != null) {
errorResult.put("data", data)
}
sendResponse(error, errorResult.toString())
}
fun reject(msg: String?, ex: Exception?, data: JSObject?) {
reject(msg, null, ex, data)
}
fun reject(msg: String?, code: String?, data: JSObject?) {
reject(msg, code, null, data)
}
fun reject(msg: String?, code: String?, ex: Exception?) {
reject(msg, code, ex, null)
}
fun reject(msg: String?, data: JSObject?) {
reject(msg, null, null, data)
}
fun reject(msg: String?, ex: Exception?) {
reject(msg, null, ex, null)
}
fun reject(msg: String?, code: String?) {
reject(msg, code, null, null)
}
fun reject(msg: String?) {
reject(msg, null, null, null)
}
}

View file

@ -0,0 +1,45 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
package app.tauri.plugin
import org.json.JSONArray
import org.json.JSONException
class JSArray : JSONArray {
constructor() : super() {}
constructor(json: String?) : super(json) {}
constructor(copyFrom: Collection<*>?) : super(copyFrom) {}
constructor(array: Any?) : super(array) {}
@Suppress("UNCHECKED_CAST", "ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE")
@Throws(JSONException::class)
fun <E> toList(): List<E> {
val items: MutableList<E> = ArrayList()
var o: Any? = null
for (i in 0 until this.length()) {
this.get(i).also { o = it }
try {
items.add(this.get(i) as E)
} catch (ex: Exception) {
throw JSONException("Not all items are instances of the given type")
}
}
return items
}
companion object {
/**
* Create a new JSArray without throwing a error
*/
fun from(array: Any?): JSArray? {
try {
return JSArray(array)
} catch (ex: JSONException) {
//
}
return null
}
}
}

View file

@ -0,0 +1,152 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
package app.tauri.plugin
import org.json.JSONException
import org.json.JSONObject
class JSObject : JSONObject {
constructor() : super()
constructor(json: String) : super(json)
constructor(obj: JSONObject, names: Array<String>) : super(obj, names)
override fun getString(key: String): String {
return getString(key, "")!!
}
fun getString(key: String, defaultValue: String?): String? {
try {
if (!super.isNull(key)) {
return super.getString(key)
}
} catch (_: JSONException) {
}
return defaultValue
}
fun getInteger(key: String): Int? {
return getIntegerInternal(key, null)
}
fun getInteger(key: String, defaultValue: Int): Int {
return getIntegerInternal(key, defaultValue)!!
}
private fun getIntegerInternal(key: String, defaultValue: Int?): Int? {
try {
return super.getInt(key)
} catch (_: JSONException) {
}
return defaultValue
}
override fun getBoolean(key: String): Boolean {
return getBooleanInternal(key, false)!!
}
fun getBoolean(key: String, defaultValue: Boolean?): Boolean {
return getBooleanInternal(key, defaultValue)!!
}
private fun getBooleanInternal(key: String, defaultValue: Boolean?): Boolean? {
try {
return super.getBoolean(key)
} catch (_: JSONException) {
}
return defaultValue
}
fun getJSObject(name: String): JSObject? {
try {
return getJSObjectInternal(name, null)
} catch (_: JSONException) {
}
return null
}
fun getJSObject(name: String, defaultValue: JSObject): JSObject {
return getJSObjectInternal(name, defaultValue)!!
}
private fun getJSObjectInternal(name: String, defaultValue: JSObject?): JSObject? {
try {
val obj = get(name)
if (obj is JSONObject) {
val keysIter = obj.keys()
val keys: MutableList<String> = ArrayList()
while (keysIter.hasNext()) {
keys.add(keysIter.next())
}
return JSObject(obj, keys.toTypedArray())
}
} catch (_: JSONException) {
}
return defaultValue
}
override fun put(key: String, value: Boolean): JSObject {
try {
super.put(key, value)
} catch (_: JSONException) {
}
return this
}
override fun put(key: String, value: Int): JSObject {
try {
super.put(key, value)
} catch (_: JSONException) {
}
return this
}
override fun put(key: String, value: Long): JSObject {
try {
super.put(key, value)
} catch (_: JSONException) {
}
return this
}
override fun put(key: String, value: Double): JSObject {
try {
super.put(key, value)
} catch (_: JSONException) {
}
return this
}
override fun put(key: String, value: Any?): JSObject {
try {
super.put(key, value)
} catch (_: JSONException) {
}
return this
}
fun put(key: String, value: String?): JSObject {
try {
super.put(key, value)
} catch (_: JSONException) {
}
return this
}
companion object {
/**
* Convert a pathetic JSONObject into a JSObject
* @param obj
*/
@Throws(JSONException::class)
fun fromJSONObject(obj: JSONObject): JSObject {
val keysIter = obj.keys()
val keys: MutableList<String> = ArrayList()
while (keysIter.hasNext()) {
keys.add(keysIter.next())
}
return JSObject(obj, keys.toTypedArray())
}
}
}

View file

@ -0,0 +1,490 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
package app.tauri.plugin
import android.app.Activity
import android.content.res.Configuration
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.webkit.WebView
import androidx.activity.result.IntentSenderRequest
import androidx.core.app.ActivityCompat
import app.tauri.FsUtils
import app.tauri.Logger
import app.tauri.PermissionHelper
import app.tauri.PermissionState
import app.tauri.annotation.ActivityCallback
import app.tauri.annotation.Command
import app.tauri.annotation.InvokeArg
import app.tauri.annotation.PermissionCallback
import app.tauri.annotation.TauriPlugin
import com.fasterxml.jackson.databind.ObjectMapper
import java.util.*
import java.util.concurrent.CopyOnWriteArrayList
@InvokeArg
internal class RegisterListenerArgs {
lateinit var event: String
lateinit var handler: Channel
}
@InvokeArg
internal class RemoveListenerArgs {
lateinit var event: String
var channelId: Long = 0
}
@InvokeArg internal class RequestPermissionsArgs {
var permissions: List<String>? = null
}
abstract class Plugin(private val activity: Activity) {
var handle: PluginHandle? = null
private val listeners: MutableMap<String, MutableList<Channel>> = mutableMapOf()
open fun load(webView: WebView) {}
fun jsonMapper(): ObjectMapper {
return handle!!.jsonMapper
}
fun<T> getConfig(cls: Class<T>): T {
return jsonMapper().readValue(handle!!.config, cls)
}
/**
* Handle a new intent being received by the application
*/
open fun onNewIntent(intent: Intent) {}
/**
* This event is called just before another activity comes into the foreground.
*/
open fun onPause() {}
/**
* This event is called when the user returns to the activity. It is also called on cold starts.
*/
open fun onResume() {}
/**
* This event is called after onStop() when the current activity is being re-displayed to the user (the user has navigated back to it).
* It will be followed by onStart() and then onResume().
*/
open fun onRestart() {}
/**
* This event is called when the app is no longer visible to the user.
* You will next receive either onRestart(), onDestroy(), or nothing, depending on later user activity.
*/
open fun onStop() {}
/**
* This event is called before the activity is destroyed.
*/
open fun onDestroy() {}
/**
* This event is called when a configuration change occurs but the app does not recreate the activity.
*/
open fun onConfigurationChanged(newConfig: Configuration) {}
/**
* Start activity for result with the provided Intent and resolve calling the provided callback method name.
*
* If there is no registered activity callback for the method name passed in, the call will
* be rejected. Make sure a valid activity result callback method is registered using the
* [ActivityCallback] annotation.
*
* @param invoke the invoke object
* @param intent the intent used to start an activity
* @param callbackName the name of the callback to run when the launched activity is finished
*/
fun startActivityForResult(invoke: Invoke, intent: Intent, callbackName: String) {
handle!!.startActivityForResult(invoke, intent, callbackName)
}
/**
* Like startActivityForResult() but taking an IntentSender to describe the activity to be started.
*
* If there is no registered activity callback for the method name passed in, the call will
* be rejected. Make sure a valid activity result callback method is registered using the
* [ActivityCallback] annotation.
*
* @param invoke the invoke object
* @param intentSender the intent used to start an activity
* @param callbackName the name of the callback to run when the launched activity is finished
*/
fun startIntentSenderForResult(invoke: Invoke, intentSender: IntentSenderRequest, callbackName: String) {
handle!!.startIntentSenderForResult(invoke, intentSender, callbackName)
}
/**
* Get the plugin log tags.
* @param subTags
*/
protected fun getLogTag(vararg subTags: String): String {
return Logger.tags(*subTags)
}
/**
* Gets a log tag with the plugin's class name as subTag.
*/
protected fun getLogTag(): String {
return Logger.tags(this.javaClass.simpleName)
}
/**
* Convert an URI to an URL that can be loaded by the webview.
*/
fun assetUrl(u: Uri): String {
var path = FsUtils.getFileUrlForUri(activity, u)
if (path?.startsWith("file://") == true) {
path = path.replace("file://", "")
}
return "asset://localhost$path"
}
fun trigger(event: String, payload: JSObject) {
val eventListeners = listeners[event]
if (!eventListeners.isNullOrEmpty()) {
val listeners = CopyOnWriteArrayList(eventListeners)
for (channel in listeners) {
channel.send(payload)
}
}
}
fun triggerObject(event: String, payload: Any) {
val eventListeners = listeners[event]
if (!eventListeners.isNullOrEmpty()) {
val listeners = CopyOnWriteArrayList(eventListeners)
for (channel in listeners) {
channel.sendObject(payload)
}
}
}
fun hasListener(event: String): Boolean {
return !listeners[event].isNullOrEmpty()
}
@Command
open fun registerListener(invoke: Invoke) {
val args = invoke.parseArgs(RegisterListenerArgs::class.java)
val eventListeners = listeners[args.event]
if (eventListeners.isNullOrEmpty()) {
listeners[args.event] = mutableListOf(args.handler)
} else {
eventListeners.add(args.handler)
}
invoke.resolve()
}
@Command
open fun removeListener(invoke: Invoke) {
val args = invoke.parseArgs(RemoveListenerArgs::class.java)
val eventListeners = listeners[args.event]
if (!eventListeners.isNullOrEmpty()) {
val c = eventListeners.find { c -> c.id == args.channelId }
if (c != null) {
eventListeners.remove(c)
}
}
invoke.resolve()
}
/**
* Exported plugin method for checking the granted status for each permission
* declared on the plugin. This plugin call responds with a mapping of permissions to
* the associated granted status.
*/
@Command
@PermissionCallback
open fun checkPermissions(invoke: Invoke) {
val permissionsResult: Map<String, PermissionState?> = getPermissionStates()
if (permissionsResult.isEmpty()) {
// if no permissions are defined on the plugin, resolve undefined
invoke.resolve()
} else {
val permissionsResultJSON = JSObject()
for ((key, value) in permissionsResult) {
permissionsResultJSON.put(key, value)
}
invoke.resolve(permissionsResultJSON)
}
}
/**
* Exported plugin method to request all permissions for this plugin.
* To manually request permissions within a plugin use:
* [.requestAllPermissions], or
* [.requestPermissionForAlias], or
* [.requestPermissionForAliases]
*
* @param invoke
*/
@Command
open fun requestPermissions(invoke: Invoke) {
val annotation = handle?.annotation
if (annotation != null) {
// handle permission requests for plugins defined with @TauriPlugin
var permAliases: Array<String>? = null
val autoGrantPerms: MutableSet<String> = HashSet()
val args = invoke.parseArgs(RequestPermissionsArgs::class.java)
args.permissions?.let {
val aliasSet: MutableSet<String> = HashSet()
for (perm in annotation.permissions) {
if (it.contains(perm.alias)) {
aliasSet.add(perm.alias)
}
}
if (aliasSet.isEmpty()) {
invoke.reject("No valid permission alias was requested of this plugin.")
return
} else {
permAliases = aliasSet.toTypedArray()
}
} ?: run {
val aliasSet: MutableSet<String> = HashSet()
for (perm in annotation.permissions) {
// If a permission is defined with no permission strings, separate it for auto-granting.
// Otherwise, the alias is added to the list to be requested.
if (perm.strings.isEmpty() || perm.strings.size == 1 && perm.strings[0]
.isEmpty()
) {
if (perm.alias.isNotEmpty()) {
autoGrantPerms.add(perm.alias)
}
} else {
aliasSet.add(perm.alias)
}
}
permAliases = aliasSet.toTypedArray()
}
permAliases?.let {
// request permissions using provided aliases or all defined on the plugin
requestPermissionForAliases(it, invoke, "checkPermissions")
} ?: run {
if (autoGrantPerms.isNotEmpty()) {
// if the plugin only has auto-grant permissions, return all as GRANTED
val permissionsResults = JSObject()
for (perm in autoGrantPerms) {
permissionsResults.put(perm, PermissionState.GRANTED.toString())
}
invoke.resolve(permissionsResults)
} else {
// no permissions are defined on the plugin, resolve undefined
invoke.resolve()
}
}
}
}
/**
* Checks if the given permission alias is correctly declared in AndroidManifest.xml
* @param alias a permission alias defined on the plugin
* @return true only if all permissions associated with the given alias are declared in the manifest
*/
fun isPermissionDeclared(alias: String): Boolean {
val annotation = handle?.annotation
if (annotation != null) {
for (perm in annotation.permissions) {
if (alias.equals(perm.alias, ignoreCase = true)) {
var result = true
for (permString in perm.strings) {
result = result && PermissionHelper.hasDefinedPermission(activity, permString)
}
return result
}
}
}
Logger.error(
String.format(
"isPermissionDeclared: No alias defined for %s " + "or missing @TauriPlugin annotation.",
alias
)
)
return false
}
private fun permissionActivityResult(
invoke: Invoke,
permissionStrings: Array<String>,
callbackName: String
) {
handle!!.requestPermissions(invoke, permissionStrings, callbackName)
}
/**
* Request all of the specified permissions in the TauriPlugin annotation (if any)
*
* If there is no registered permission callback for the Invoke passed in, the call will
* be rejected. Make sure a valid permission callback method is registered using the
* [PermissionCallback] annotation.
*
* @param invoke
* @param callbackName the name of the callback to run when the permission request is complete
*/
protected fun requestAllPermissions(
invoke: Invoke,
callbackName: String
) {
val annotation = handle!!.annotation
if (annotation != null) {
val perms: HashSet<String> = HashSet()
for (perm in annotation.permissions) {
perms.addAll(perm.strings)
}
permissionActivityResult(invoke, perms.toArray(arrayOfNulls<String>(0)), callbackName)
}
}
/**
* Request permissions using an alias defined on the plugin.
*
* If there is no registered permission callback for the Invoke passed in, the call will
* be rejected. Make sure a valid permission callback method is registered using the
* [PermissionCallback] annotation.
*
* @param alias an alias defined on the plugin
* @param invoke the invoke involved in originating the request
* @param callbackName the name of the callback to run when the permission request is complete
*/
protected fun requestPermissionForAlias(
alias: String,
invoke: Invoke,
callbackName: String
) {
requestPermissionForAliases(arrayOf(alias), invoke, callbackName)
}
/**
* Request permissions using aliases defined on the plugin.
*
* If there is no registered permission callback for the Invoke passed in, the call will
* be rejected. Make sure a valid permission callback method is registered using the
* [PermissionCallback] annotation.
*
* @param aliases a set of aliases defined on the plugin
* @param invoke the invoke involved in originating the request
* @param callbackName the name of the callback to run when the permission request is complete
*/
fun requestPermissionForAliases(
aliases: Array<String>,
invoke: Invoke,
callbackName: String
) {
if (aliases.isEmpty()) {
Logger.error("No permission alias was provided")
return
}
val permissions = getPermissionStringsForAliases(aliases)
if (permissions.isNotEmpty()) {
permissionActivityResult(invoke, permissions, callbackName)
}
}
/**
* Gets the Android permission strings defined on the [TauriPlugin] annotation with
* the provided aliases.
*
* @param aliases aliases for permissions defined on the plugin
* @return Android permission strings associated with the provided aliases, if exists
*/
private fun getPermissionStringsForAliases(aliases: Array<String>): Array<String> {
val annotation = handle?.annotation
val perms: HashSet<String> = HashSet()
if (annotation != null) {
for (perm in annotation.permissions) {
if (aliases.contains(perm.alias)) {
perms.addAll(perm.strings)
}
}
}
return perms.toArray(arrayOfNulls(0))
}
/**
* Get the permission state for the provided permission alias.
*
* @param alias the permission alias to get
* @return the state of the provided permission alias or null
*/
fun getPermissionState(alias: String): PermissionState? {
return getPermissionStates()[alias]
}
/**
* Helper to check all permissions defined on a plugin and see the state of each.
*
* @return A mapping of permission aliases to the associated granted status.
*/
open fun getPermissionStates(): Map<String, PermissionState> {
val permissionsResults: MutableMap<String, PermissionState> = HashMap()
val annotation = handle?.annotation
if (annotation != null) {
for (perm in annotation.permissions) {
// If a permission is defined with no permission constants, return GRANTED for it.
// Otherwise, get its true state.
if (perm.strings.isEmpty() || perm.strings.size == 1 && perm.strings[0]
.isEmpty()
) {
val key = perm.alias
if (key.isNotEmpty()) {
val existingResult = permissionsResults[key]
// auto set permission state to GRANTED if the alias is empty.
if (existingResult == null) {
permissionsResults[key] = PermissionState.GRANTED
}
}
} else {
for (permString in perm.strings) {
val key = perm.alias.ifEmpty { permString }
var permissionStatus: PermissionState
if (ActivityCompat.checkSelfPermission(
activity,
permString
) == PackageManager.PERMISSION_GRANTED
) {
permissionStatus = PermissionState.GRANTED
} else {
permissionStatus = PermissionState.PROMPT
// Check if there is a cached permission state for the "Never ask again" state
val prefs =
activity.getSharedPreferences("PluginPermStates", Activity.MODE_PRIVATE)
val state = prefs.getString(permString, null)
if (state != null) {
permissionStatus = PermissionState.byState(state)
}
}
val existingResult = permissionsResults[key]
// multiple permissions with the same alias must all be true, otherwise all false.
if (existingResult == null || existingResult === PermissionState.GRANTED) {
permissionsResults[key] = permissionStatus
}
}
}
}
}
return permissionsResults
}
}

View file

@ -0,0 +1,168 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
package app.tauri.plugin
import android.app.Activity
import android.content.Intent
import android.content.SharedPreferences
import android.webkit.WebView
import androidx.activity.result.IntentSenderRequest
import androidx.core.app.ActivityCompat
import app.tauri.PermissionHelper
import app.tauri.PermissionState
import app.tauri.annotation.ActivityCallback
import app.tauri.annotation.Command
import app.tauri.annotation.PermissionCallback
import app.tauri.annotation.TauriPlugin
import com.fasterxml.jackson.databind.ObjectMapper
import java.lang.reflect.Method
class PluginHandle(private val manager: PluginManager, val name: String, val instance: Plugin, val config: String, val jsonMapper: ObjectMapper) {
private val commands: HashMap<String, CommandData> = HashMap()
private val permissionCallbackMethods: HashMap<String, Method> = HashMap()
private val startActivityCallbackMethods: HashMap<String, Method> = HashMap()
var annotation: TauriPlugin?
var loaded = false
init {
indexMethods()
instance.handle = this
annotation = instance.javaClass.getAnnotation(TauriPlugin::class.java)
}
fun load(webView: WebView) {
instance.load(webView)
loaded = true
}
fun startActivityForResult(invoke: Invoke, intent: Intent, callbackName: String) {
manager.startActivityForResult(intent) { result ->
val method = startActivityCallbackMethods[callbackName]
if (method != null) {
method.isAccessible = true
method(instance, invoke, result)
}
}
}
fun startIntentSenderForResult(invoke: Invoke, intentSender: IntentSenderRequest, callbackName: String) {
manager.startIntentSenderForResult(intentSender) { result ->
val method = startActivityCallbackMethods[callbackName]
if (method != null) {
method.isAccessible = true
method(instance, invoke, result)
}
}
}
fun requestPermissions(
invoke: Invoke,
permissions: Array<String>,
callbackName: String
) {
manager.requestPermissions(permissions) { result ->
if (validatePermissions(invoke, result)) {
val method = permissionCallbackMethods[callbackName]
if (method != null) {
method.isAccessible = true
method(instance, invoke)
}
}
}
}
/**
* Saves permission states and rejects if permissions were not correctly defined in
* the AndroidManifest.xml file.
*
* @param permissions
* @return true if permissions were saved and defined correctly, false if not
*/
private fun validatePermissions(
invoke: Invoke,
permissions: Map<String, Boolean>
): Boolean {
val activity = manager.activity
val prefs =
activity.getSharedPreferences("PluginPermStates", Activity.MODE_PRIVATE)
for ((permString, isGranted) in permissions) {
if (isGranted) {
// Permission granted. If previously denied, remove cached state
val state = prefs.getString(permString, null)
if (state != null) {
val editor: SharedPreferences.Editor = prefs.edit()
editor.remove(permString)
editor.apply()
}
} else {
val editor: SharedPreferences.Editor = prefs.edit()
if (ActivityCompat.shouldShowRequestPermissionRationale(
activity,
permString
)
) {
// Permission denied, can prompt again with rationale
editor.putString(permString, PermissionState.PROMPT_WITH_RATIONALE.toString())
} else {
// Permission denied permanently, store this state for future reference
editor.putString(permString, PermissionState.DENIED.toString())
}
editor.apply()
}
}
val permStrings = permissions.keys.toTypedArray()
if (!PermissionHelper.hasDefinedPermissions(activity, permStrings)) {
val builder = StringBuilder()
builder.append("Missing the following permissions in AndroidManifest.xml:\n")
val missing = PermissionHelper.getUndefinedPermissions(activity, permStrings)
for (perm in missing) {
builder.append(
"""
$perm
""".trimIndent()
)
}
invoke.reject(builder.toString())
return false
}
return true
}
@Throws(
InvalidCommandException::class,
IllegalAccessException::class
)
fun invoke(invoke: Invoke) {
val methodMeta = commands[invoke.command]
?: throw InvalidCommandException("No command " + invoke.command + " found for plugin " + instance.javaClass.name)
methodMeta.method.invoke(instance, invoke)
}
private fun indexMethods() {
val methods = mutableListOf<Method>()
var pluginCursor: Class<*> = instance.javaClass
while (pluginCursor.name != Any::class.java.name) {
methods.addAll(listOf(*pluginCursor.declaredMethods))
pluginCursor = pluginCursor.superclass
}
for (method in methods) {
if (method.isAnnotationPresent(Command::class.java)) {
val command = method.getAnnotation(Command::class.java) ?: continue
val methodMeta = CommandData(method, command)
commands[method.name] = methodMeta
}
if (method.isAnnotationPresent(ActivityCallback::class.java)) {
startActivityCallbackMethods[method.name] = method
}
if (method.isAnnotationPresent(PermissionCallback::class.java)) {
permissionCallbackMethods[method.name] = method
}
}
}
}

View file

@ -0,0 +1,221 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
package app.tauri.plugin
import android.app.PendingIntent
import android.content.res.Configuration
import android.content.Context
import android.content.Intent
import android.webkit.WebView
import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.IntentSenderRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import app.tauri.annotation.InvokeArg
import app.tauri.FsUtils
import app.tauri.JniMethod
import app.tauri.Logger
import com.fasterxml.jackson.annotation.JsonAutoDetect
import com.fasterxml.jackson.annotation.PropertyAccessor
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.module.SimpleModule
import java.lang.reflect.InvocationTargetException
class PluginManager(val activity: AppCompatActivity) {
fun interface RequestPermissionsCallback {
fun onResult(permissions: Map<String, Boolean>)
}
fun interface ActivityResultCallback {
fun onResult(result: ActivityResult)
}
private val plugins: HashMap<String, PluginHandle> = HashMap()
private val startActivityForResultLauncher: ActivityResultLauncher<Intent>
private val startIntentSenderForResultLauncher: ActivityResultLauncher<IntentSenderRequest>
private val requestPermissionsLauncher: ActivityResultLauncher<Array<String>>
private var requestPermissionsCallback: RequestPermissionsCallback? = null
private var startActivityForResultCallback: ActivityResultCallback? = null
private var startIntentSenderForResultCallback: ActivityResultCallback? = null
private var jsonMapper: ObjectMapper
init {
startActivityForResultLauncher =
activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()
) { result ->
if (startActivityForResultCallback != null) {
startActivityForResultCallback!!.onResult(result)
}
}
startIntentSenderForResultLauncher =
activity.registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()
) { result ->
if (startIntentSenderForResultCallback != null) {
startIntentSenderForResultCallback!!.onResult(result)
}
}
requestPermissionsLauncher =
activity.registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()
) { result ->
if (requestPermissionsCallback != null) {
requestPermissionsCallback!!.onResult(result)
}
}
jsonMapper = ObjectMapper()
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.enable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES)
.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY)
val channelDeserializer = ChannelDeserializer({ channelId, payload ->
sendChannelData(channelId, payload)
}, jsonMapper)
jsonMapper
.registerModule(SimpleModule().addDeserializer(Channel::class.java, channelDeserializer))
}
fun onNewIntent(intent: Intent) {
for (plugin in plugins.values) {
plugin.instance.onNewIntent(intent)
}
}
fun onPause() {
for (plugin in plugins.values) {
plugin.instance.onPause()
}
}
fun onResume() {
for (plugin in plugins.values) {
plugin.instance.onResume()
}
}
fun onRestart() {
for (plugin in plugins.values) {
plugin.instance.onRestart()
}
}
fun onStop() {
for (plugin in plugins.values) {
plugin.instance.onStop()
}
}
fun onDestroy() {
for (plugin in plugins.values) {
plugin.instance.onDestroy()
}
}
fun onConfigurationChanged(newConfig: Configuration) {
for (plugin in plugins.values) {
plugin.instance.onConfigurationChanged(newConfig)
}
}
fun startActivityForResult(intent: Intent, callback: ActivityResultCallback) {
startActivityForResultCallback = callback
startActivityForResultLauncher.launch(intent)
}
fun startIntentSenderForResult(intent: IntentSenderRequest, callback: ActivityResultCallback) {
startIntentSenderForResultCallback = callback
startIntentSenderForResultLauncher.launch(intent)
}
fun requestPermissions(
permissionStrings: Array<String>,
callback: RequestPermissionsCallback
) {
requestPermissionsCallback = callback
requestPermissionsLauncher.launch(permissionStrings)
}
@JniMethod
fun onWebViewCreated(webView: WebView) {
for ((_, plugin) in plugins) {
if (!plugin.loaded) {
plugin.load(webView)
}
}
}
@JniMethod
fun load(webView: WebView?, name: String, plugin: Plugin, config: String) {
val handle = PluginHandle(this, name, plugin, config, jsonMapper)
plugins[name] = handle
if (webView != null) {
plugin.load(webView)
}
}
@JniMethod
fun runCommand(id: Int, pluginId: String, command: String, data: String) {
val successId = 0L
val errorId = 1L
val invoke = Invoke(id.toLong(), command, successId, errorId, { fn, result ->
var success: String? = null
var error: String? = null
if (fn == successId) {
success = result
} else {
error = result
}
handlePluginResponse(id, success, error)
}, data, jsonMapper)
dispatchPluginMessage(invoke, pluginId)
}
private fun dispatchPluginMessage(invoke: Invoke, pluginId: String) {
Logger.verbose(
Logger.tags("Plugin"),
"Tauri plugin: pluginId: $pluginId, command: ${invoke.command}"
)
try {
val plugin = plugins[pluginId]
if (plugin == null) {
invoke.reject("Plugin $pluginId not initialized")
} else {
plugins[pluginId]?.invoke(invoke)
}
} catch (e: Exception) {
var exception: Throwable = e
if (exception.message?.isEmpty() != false) {
if (e is InvocationTargetException) {
exception = e.targetException
}
}
invoke.reject(if (exception.message?.isEmpty() != false) { exception.toString() } else { exception.message })
}
}
companion object {
fun<T> loadConfig(context: Context, plugin: String, cls: Class<T>): T {
val tauriConfigJson = FsUtils.readAsset(context.assets, "tauri.conf.json")
val mapper = ObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
val config = mapper.readValue(tauriConfigJson, Config::class.java)
return mapper.readValue(config.plugins[plugin].toString(), cls)
}
}
private external fun handlePluginResponse(id: Int, success: String?, error: String?)
private external fun sendChannelData(id: Long, data: String)
}
@InvokeArg
internal class Config {
lateinit var plugins: Map<String, JsonNode>
}

View file

@ -0,0 +1,16 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
package app.tauri.plugin
import app.tauri.annotation.Command
import java.lang.reflect.Method
class CommandData(
val method: Method, methodDecorator: Command
) {
// The name of the method
val name: String = method.name
}

View file

@ -0,0 +1,67 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
package app.tauri.plugin
import android.annotation.SuppressLint
import app.tauri.Logger
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.*
class PluginResult @JvmOverloads constructor(json: JSObject? = JSObject()) {
private val json: JSObject
init {
this.json = json ?: JSObject()
}
fun put(name: String, value: Boolean): PluginResult {
return jsonPut(name, value)
}
fun put(name: String, value: Double): PluginResult {
return jsonPut(name, value)
}
fun put(name: String, value: Int): PluginResult {
return jsonPut(name, value)
}
fun put(name: String, value: Long): PluginResult {
return jsonPut(name, value)
}
/**
* Format a date as an ISO string
*/
@SuppressLint("SimpleDateFormat")
fun put(name: String, value: Date): PluginResult {
val tz: TimeZone = TimeZone.getTimeZone("UTC")
val df: DateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm'Z'")
df.timeZone = tz
return jsonPut(name, df.format(value))
}
fun put(name: String, value: Any?): PluginResult {
return jsonPut(name, value)
}
fun put(name: String, value: PluginResult): PluginResult {
return jsonPut(name, value.json)
}
private fun jsonPut(name: String, value: Any?): PluginResult {
try {
json.put(name, value)
} catch (ex: Exception) {
Logger.error(Logger.tags("Plugin"), "", ex)
}
return this
}
override fun toString(): String {
return json.toString()
}
}

View file

@ -0,0 +1,21 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
package app.tauri
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

View file

@ -0,0 +1,33 @@
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "app.tauri.credentials"
compileSdk = 36
defaultConfig {
minSdk = 24
}
buildTypes {
release {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
}
dependencies {
implementation("androidx.security:security-crypto:1.0.0")
implementation(project(":tauri-android"))
}

View file

@ -0,0 +1 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />

View file

@ -0,0 +1,96 @@
package app.tauri.credentials
import android.app.Activity
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKeys
import app.tauri.annotation.Command
import app.tauri.annotation.InvokeArg
import app.tauri.annotation.TauriPlugin
import app.tauri.plugin.Invoke
import app.tauri.plugin.JSObject
import app.tauri.plugin.Plugin
@InvokeArg
class StoreArgs {
lateinit var domain: String
lateinit var username: String
lateinit var password: String
}
@InvokeArg
class DomainArgs {
lateinit var domain: String
}
/// Credential storage plugin using Android EncryptedSharedPreferences (backed by Android Keystore).
@TauriPlugin
class CredentialPlugin(private val activity: Activity) : Plugin(activity) {
private fun getPrefs(): SharedPreferences {
val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
return EncryptedSharedPreferences.create(
"onyx_credentials",
masterKeyAlias,
activity,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
@Command
fun store(invoke: Invoke) {
val args = invoke.parseArgs(StoreArgs::class.java)
try {
getPrefs().edit()
.putString("${args.domain}::username", args.username)
.putString("${args.domain}::${args.username}::password", args.password)
.apply()
invoke.resolve()
} catch (e: Exception) {
invoke.reject("Failed to store credentials: ${e.message}")
}
}
@Command
fun load(invoke: Invoke) {
val args = invoke.parseArgs(DomainArgs::class.java)
try {
val prefs = getPrefs()
val username = prefs.getString("${args.domain}::username", null)
if (username == null) {
invoke.reject("No credentials found for '${args.domain}'. Run setup or configure environment variables.")
return
}
val password = prefs.getString("${args.domain}::${username}::password", null)
if (password == null) {
invoke.reject("No password found for '${args.domain}' user '$username'")
return
}
val result = JSObject()
result.put("username", username)
result.put("password", password)
invoke.resolve(result)
} catch (e: Exception) {
invoke.reject("Failed to load credentials: ${e.message}")
}
}
@Command
fun delete(invoke: Invoke) {
val args = invoke.parseArgs(DomainArgs::class.java)
try {
val prefs = getPrefs()
val username = prefs.getString("${args.domain}::username", null)
val editor = prefs.edit().remove("${args.domain}::username")
if (username != null) {
editor.remove("${args.domain}::${username}::password")
}
editor.apply()
invoke.resolve()
} catch (e: Exception) {
invoke.reject("Failed to delete credentials: ${e.message}")
}
}
}

View file

@ -0,0 +1,7 @@
const COMMANDS: &[&str] = &["store", "load", "delete"];
fn main() {
tauri_plugin::Builder::new(COMMANDS)
.android_path("android")
.build();
}

View file

@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-delete"
description = "Enables the delete command without any pre-configured scope."
commands.allow = ["delete"]
[[permission]]
identifier = "deny-delete"
description = "Denies the delete command without any pre-configured scope."
commands.deny = ["delete"]

View file

@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-load"
description = "Enables the load command without any pre-configured scope."
commands.allow = ["load"]
[[permission]]
identifier = "deny-load"
description = "Denies the load command without any pre-configured scope."
commands.deny = ["load"]

View file

@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-store"
description = "Enables the store command without any pre-configured scope."
commands.allow = ["store"]
[[permission]]
identifier = "deny-store"
description = "Denies the store command without any pre-configured scope."
commands.deny = ["store"]

View file

@ -0,0 +1,87 @@
## Permission Table
<table>
<tr>
<th>Identifier</th>
<th>Description</th>
</tr>
<tr>
<td>
`credentials:allow-delete`
</td>
<td>
Enables the delete command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`credentials:deny-delete`
</td>
<td>
Denies the delete command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`credentials:allow-load`
</td>
<td>
Enables the load command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`credentials:deny-load`
</td>
<td>
Denies the load command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`credentials:allow-store`
</td>
<td>
Enables the store command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`credentials:deny-store`
</td>
<td>
Denies the store command without any pre-configured scope.
</td>
</tr>
</table>

View file

@ -0,0 +1,336 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "PermissionFile",
"description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.",
"type": "object",
"properties": {
"default": {
"description": "The default permission set for the plugin",
"anyOf": [
{
"$ref": "#/definitions/DefaultPermission"
},
{
"type": "null"
}
]
},
"set": {
"description": "A list of permissions sets defined",
"type": "array",
"items": {
"$ref": "#/definitions/PermissionSet"
}
},
"permission": {
"description": "A list of inlined permissions",
"default": [],
"type": "array",
"items": {
"$ref": "#/definitions/Permission"
}
}
},
"definitions": {
"DefaultPermission": {
"description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.",
"type": "object",
"required": [
"permissions"
],
"properties": {
"version": {
"description": "The version of the permission.",
"type": [
"integer",
"null"
],
"format": "uint64",
"minimum": 1.0
},
"description": {
"description": "Human-readable description of what the permission does. Tauri convention is to use `<h4>` headings in markdown content for Tauri documentation generation purposes.",
"type": [
"string",
"null"
]
},
"permissions": {
"description": "All permissions this set contains.",
"type": "array",
"items": {
"type": "string"
}
}
}
},
"PermissionSet": {
"description": "A set of direct permissions grouped together under a new name.",
"type": "object",
"required": [
"description",
"identifier",
"permissions"
],
"properties": {
"identifier": {
"description": "A unique identifier for the permission.",
"type": "string"
},
"description": {
"description": "Human-readable description of what the permission does.",
"type": "string"
},
"permissions": {
"description": "All permissions this set contains.",
"type": "array",
"items": {
"$ref": "#/definitions/PermissionKind"
}
}
}
},
"Permission": {
"description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.",
"type": "object",
"required": [
"identifier"
],
"properties": {
"version": {
"description": "The version of the permission.",
"type": [
"integer",
"null"
],
"format": "uint64",
"minimum": 1.0
},
"identifier": {
"description": "A unique identifier for the permission.",
"type": "string"
},
"description": {
"description": "Human-readable description of what the permission does. Tauri internal convention is to use `<h4>` headings in markdown content for Tauri documentation generation purposes.",
"type": [
"string",
"null"
]
},
"commands": {
"description": "Allowed or denied commands when using this permission.",
"default": {
"allow": [],
"deny": []
},
"allOf": [
{
"$ref": "#/definitions/Commands"
}
]
},
"scope": {
"description": "Allowed or denied scoped when using this permission.",
"allOf": [
{
"$ref": "#/definitions/Scopes"
}
]
},
"platforms": {
"description": "Target platforms this permission applies. By default all platforms are affected by this permission.",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/Target"
}
}
}
},
"Commands": {
"description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.",
"type": "object",
"properties": {
"allow": {
"description": "Allowed command.",
"default": [],
"type": "array",
"items": {
"type": "string"
}
},
"deny": {
"description": "Denied command, which takes priority.",
"default": [],
"type": "array",
"items": {
"type": "string"
}
}
}
},
"Scopes": {
"description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```",
"type": "object",
"properties": {
"allow": {
"description": "Data that defines what is allowed by the scope.",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/Value"
}
},
"deny": {
"description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/Value"
}
}
}
},
"Value": {
"description": "All supported ACL values.",
"anyOf": [
{
"description": "Represents a null JSON value.",
"type": "null"
},
{
"description": "Represents a [`bool`].",
"type": "boolean"
},
{
"description": "Represents a valid ACL [`Number`].",
"allOf": [
{
"$ref": "#/definitions/Number"
}
]
},
{
"description": "Represents a [`String`].",
"type": "string"
},
{
"description": "Represents a list of other [`Value`]s.",
"type": "array",
"items": {
"$ref": "#/definitions/Value"
}
},
{
"description": "Represents a map of [`String`] keys to [`Value`]s.",
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/Value"
}
}
]
},
"Number": {
"description": "A valid ACL number.",
"anyOf": [
{
"description": "Represents an [`i64`].",
"type": "integer",
"format": "int64"
},
{
"description": "Represents a [`f64`].",
"type": "number",
"format": "double"
}
]
},
"Target": {
"description": "Platform target.",
"oneOf": [
{
"description": "MacOS.",
"type": "string",
"enum": [
"macOS"
]
},
{
"description": "Windows.",
"type": "string",
"enum": [
"windows"
]
},
{
"description": "Linux.",
"type": "string",
"enum": [
"linux"
]
},
{
"description": "Android.",
"type": "string",
"enum": [
"android"
]
},
{
"description": "iOS.",
"type": "string",
"enum": [
"iOS"
]
}
]
},
"PermissionKind": {
"type": "string",
"oneOf": [
{
"description": "Enables the delete command without any pre-configured scope.",
"type": "string",
"const": "allow-delete",
"markdownDescription": "Enables the delete command without any pre-configured scope."
},
{
"description": "Denies the delete command without any pre-configured scope.",
"type": "string",
"const": "deny-delete",
"markdownDescription": "Denies the delete command without any pre-configured scope."
},
{
"description": "Enables the load command without any pre-configured scope.",
"type": "string",
"const": "allow-load",
"markdownDescription": "Enables the load command without any pre-configured scope."
},
{
"description": "Denies the load command without any pre-configured scope.",
"type": "string",
"const": "deny-load",
"markdownDescription": "Denies the load command without any pre-configured scope."
},
{
"description": "Enables the store command without any pre-configured scope.",
"type": "string",
"const": "allow-store",
"markdownDescription": "Enables the store command without any pre-configured scope."
},
{
"description": "Denies the store command without any pre-configured scope.",
"type": "string",
"const": "deny-store",
"markdownDescription": "Denies the store command without any pre-configured scope."
}
]
}
}
}

View file

@ -0,0 +1,211 @@
use serde::{Deserialize, Serialize};
use tauri::{
plugin::{Builder, TauriPlugin},
Manager, Runtime,
};
#[cfg(target_os = "android")]
use tauri::plugin::PluginHandle;
const PLUGIN_IDENTIFIER: &str = "app.tauri.credentials";
#[derive(Serialize, Deserialize)]
struct StoreArgs {
domain: String,
username: String,
password: String,
}
#[derive(Serialize, Deserialize)]
struct DomainArgs {
domain: String,
}
#[derive(Serialize, Deserialize)]
struct LoadResult {
username: String,
password: String,
}
/// Credential storage handle. Desktop uses the system keychain; Android uses EncryptedSharedPreferences.
pub struct Credentials<R: Runtime> {
#[cfg(target_os = "android")]
_handle: PluginHandle<R>,
#[cfg(not(target_os = "android"))]
_phantom: std::marker::PhantomData<fn() -> R>,
}
impl<R: Runtime> Credentials<R> {
pub fn store(&self, domain: &str, username: &str, password: &str) -> Result<(), String> {
#[cfg(target_os = "android")]
{
self._handle
.run_mobile_plugin::<()>(
"store",
StoreArgs {
domain: domain.to_string(),
username: username.to_string(),
password: password.to_string(),
},
)
.map_err(|e| e.to_string())
}
#[cfg(not(target_os = "android"))]
{
desktop_store(domain, username, password)
}
}
pub fn load(&self, domain: &str) -> Result<(String, String), String> {
#[cfg(target_os = "android")]
{
let result: LoadResult = self
._handle
.run_mobile_plugin("load", DomainArgs { domain: domain.to_string() })
.map_err(|e| e.to_string())?;
Ok((result.username, result.password))
}
#[cfg(not(target_os = "android"))]
{
desktop_load(domain)
}
}
pub fn delete(&self, domain: &str) -> Result<(), String> {
#[cfg(target_os = "android")]
{
self._handle
.run_mobile_plugin::<()>("delete", DomainArgs { domain: domain.to_string() })
.map_err(|e| e.to_string())
}
#[cfg(not(target_os = "android"))]
{
desktop_delete(domain)
}
}
}
// ── Desktop keyring implementation ──────────────────────────────────
#[cfg(all(not(target_os = "android"), feature = "desktop"))]
fn desktop_store(domain: &str, username: &str, password: &str) -> Result<(), String> {
let service = format!("com.onyx.webdav.{}", domain);
let scoped_service = format!("com.onyx.webdav.{}::{}", domain, username);
keyring::Entry::new(&service, "username")
.map_err(|e| format!("Failed to create keyring entry: {}", e))?
.set_password(username)
.map_err(|e| format!("Failed to store username: {}", e))?;
keyring::Entry::new(&scoped_service, "password")
.map_err(|e| format!("Failed to create keyring entry: {}", e))?
.set_password(password)
.map_err(|e| format!("Failed to store password: {}", e))?;
if let Ok(legacy) = keyring::Entry::new(&service, "password") {
let _ = legacy.delete_credential();
}
Ok(())
}
#[cfg(all(not(target_os = "android"), not(feature = "desktop")))]
fn desktop_store(_domain: &str, _username: &str, _password: &str) -> Result<(), String> {
Err("Credential storage not available on this platform".into())
}
#[cfg(all(not(target_os = "android"), feature = "desktop"))]
fn desktop_load(domain: &str) -> Result<(String, String), String> {
let service = format!("com.onyx.webdav.{}", domain);
let username = keyring::Entry::new(&service, "username")
.map_err(|e| format!("Failed to create keyring entry: {}", e))?
.get_password()
.map_err(|_| {
format!(
"No credentials found for '{}'. Run setup or configure environment variables.",
domain
)
})?;
let scoped_service = format!("com.onyx.webdav.{}::{}", domain, username);
let password = keyring::Entry::new(&scoped_service, "password")
.ok()
.and_then(|e| e.get_password().ok())
.or_else(|| {
keyring::Entry::new(&service, "password")
.ok()
.and_then(|e| e.get_password().ok())
})
.ok_or_else(|| format!("No password found for '{}' user '{}'", domain, username))?;
// Auto-migrate legacy credentials to scoped format
if keyring::Entry::new(&scoped_service, "password")
.ok()
.and_then(|e| e.get_password().ok())
.is_none()
{
if let Ok(entry) = keyring::Entry::new(&scoped_service, "password") {
let _ = entry.set_password(&password);
}
if let Ok(legacy) = keyring::Entry::new(&service, "password") {
let _ = legacy.delete_credential();
}
}
Ok((username, password))
}
#[cfg(all(not(target_os = "android"), not(feature = "desktop")))]
fn desktop_load(domain: &str) -> Result<(String, String), String> {
Err(format!(
"No credentials found for '{}'. Credential storage not available on this platform.",
domain
))
}
#[cfg(all(not(target_os = "android"), feature = "desktop"))]
fn desktop_delete(domain: &str) -> Result<(), String> {
let service = format!("com.onyx.webdav.{}", domain);
let username = keyring::Entry::new(&service, "username")
.ok()
.and_then(|e| e.get_password().ok());
if let Some(user) = &username {
let scoped = format!("com.onyx.webdav.{}::{}", domain, user);
if let Ok(e) = keyring::Entry::new(&scoped, "password") {
let _ = e.delete_credential();
}
}
if let Ok(e) = keyring::Entry::new(&service, "password") {
let _ = e.delete_credential();
}
if let Ok(e) = keyring::Entry::new(&service, "username") {
let _ = e.delete_credential();
}
Ok(())
}
#[cfg(all(not(target_os = "android"), not(feature = "desktop")))]
fn desktop_delete(_domain: &str) -> Result<(), String> {
Ok(())
}
// ── Plugin init ─────────────────────────────────────────────────────
/// Initialize the credentials plugin. Call `.plugin(tauri_plugin_credentials::init())` on the Tauri builder.
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("credentials")
.setup(|app, api| {
#[cfg(target_os = "android")]
let credentials = Credentials {
_handle: api.register_android_plugin(PLUGIN_IDENTIFIER, "CredentialPlugin")?,
};
#[cfg(not(target_os = "android"))]
let credentials: Credentials<R> = Credentials {
_phantom: std::marker::PhantomData,
};
let _ = api;
app.manage(credentials);
Ok(())
})
.build()
}

View file

@ -28,8 +28,8 @@ pub fn execute(path: String, name: String) -> Result<()> {
.unwrap_or_else(|_| AppConfig::new()); .unwrap_or_else(|_| AppConfig::new());
// Add workspace // Add workspace
config.add_workspace(name.clone(), WorkspaceConfig::new(path_buf.clone())); let id = config.add_workspace(WorkspaceConfig::new(name.clone(), path_buf.clone()));
config.set_current_workspace(name.clone())?; config.set_current_workspace(id)?;
// Save config // Save config
config.save_to_file(&config_path) config.save_to_file(&config_path)

View file

@ -2,26 +2,30 @@ use anyhow::{Context, Result};
use colored::Colorize; use colored::Colorize;
use onyx_core::sync::{SyncMode, sync_workspace, get_sync_status}; use onyx_core::sync::{SyncMode, sync_workspace, get_sync_status};
use onyx_core::webdav::{WebDavClient, store_credentials, load_credentials}; use onyx_core::webdav::{WebDavClient, store_credentials, load_credentials};
use onyx_core::config::AppConfig;
use crate::output; use crate::output;
use super::{load_config, save_config}; use super::{load_config, save_config};
/// Resolve a workspace name to (id, config). Falls back to current workspace if name is None.
fn resolve_workspace(config: &AppConfig, name: Option<&str>) -> Result<(String, onyx_core::config::WorkspaceConfig)> {
if let Some(name) = name {
let (id, ws) = config.find_by_name(name)
.ok_or_else(|| anyhow::anyhow!("Workspace '{}' not found", name))?;
Ok((id.clone(), ws.clone()))
} else {
let (id, ws) = config.get_current_workspace()
.context("No workspace set. Use 'onyx init' to create one.")?;
Ok((id.clone(), ws.clone()))
}
}
/// Run sync setup: prompt for URL, username, password, test connection, store credentials. /// Run sync setup: prompt for URL, username, password, test connection, store credentials.
pub fn setup(workspace_name: Option<String>) -> Result<()> { pub fn setup(workspace_name: Option<String>) -> Result<()> {
let mut config = load_config()?; let mut config = load_config()?;
let (id, workspace) = resolve_workspace(&config, workspace_name.as_deref())?;
let (name, workspace) = if let Some(name) = workspace_name {
let ws = config.get_workspace(&name)
.ok_or_else(|| anyhow::anyhow!("Workspace '{}' not found", name))?
.clone();
(name, ws)
} else {
let (n, ws) = config.get_current_workspace()
.context("No workspace set. Use 'onyx init' to create one.")?;
(n.clone(), ws.clone())
};
// Prompt for WebDAV URL // Prompt for WebDAV URL
output::header(&format!("WebDAV sync setup for workspace \"{}\"", name.green())); output::header(&format!("WebDAV sync setup for workspace \"{}\"", workspace.name.green()));
output::blank(); output::blank();
let url = prompt("WebDAV URL: ")?; let url = prompt("WebDAV URL: ")?;
@ -65,9 +69,9 @@ pub fn setup(workspace_name: Option<String>) -> Result<()> {
} }
// Update workspace config with WebDAV URL // Update workspace config with WebDAV URL
let mut ws = workspace; if let Some(ws) = config.workspaces.get_mut(&id) {
ws.webdav_url = Some(url); ws.webdav_url = Some(url);
config.add_workspace(name, ws); }
save_config(&config)?; save_config(&config)?;
output::success("Sync setup complete. Run 'onyx sync' to sync."); output::success("Sync setup complete. Run 'onyx sync' to sync.");
@ -77,21 +81,11 @@ pub fn setup(workspace_name: Option<String>) -> Result<()> {
/// Execute a sync operation. /// Execute a sync operation.
pub fn execute(mode: SyncMode, workspace_name: Option<String>) -> Result<()> { pub fn execute(mode: SyncMode, workspace_name: Option<String>) -> Result<()> {
let config = load_config()?; let config = load_config()?;
let (_id, workspace) = resolve_workspace(&config, workspace_name.as_deref())?;
let (name, workspace) = if let Some(name) = workspace_name {
let ws = config.get_workspace(&name)
.ok_or_else(|| anyhow::anyhow!("Workspace '{}' not found", name))?
.clone();
(name, ws)
} else {
let (n, ws) = config.get_current_workspace()
.context("No workspace set. Use 'onyx init' to create one.")?;
(n.clone(), ws.clone())
};
let url = workspace.webdav_url.as_ref() let url = workspace.webdav_url.as_ref()
.ok_or_else(|| anyhow::anyhow!( .ok_or_else(|| anyhow::anyhow!(
"No WebDAV URL configured for workspace '{}'. Run 'onyx sync --setup' first.", name "No WebDAV URL configured for workspace '{}'. Run 'onyx sync --setup' first.", workspace.name
))?; ))?;
let domain = extract_domain(url); let domain = extract_domain(url);
@ -103,7 +97,7 @@ pub fn execute(mode: SyncMode, workspace_name: Option<String>) -> Result<()> {
SyncMode::Push => "Pushing", SyncMode::Push => "Pushing",
SyncMode::Pull => "Pulling", SyncMode::Pull => "Pulling",
}; };
output::info(&format!("{} workspace \"{}\"...", mode_str, name.green())); output::info(&format!("{} workspace \"{}\"...", mode_str, workspace.name.green()));
let rt = tokio::runtime::Runtime::new().context("Failed to create async runtime")?; let rt = tokio::runtime::Runtime::new().context("Failed to create async runtime")?;
let result = rt.block_on(sync_workspace( let result = rt.block_on(sync_workspace(
@ -147,13 +141,12 @@ pub fn status(workspace_name: Option<String>, all: bool) -> Result<()> {
if all { if all {
// Show status for all workspaces that have sync configured // Show status for all workspaces that have sync configured
let mut found_any = false; let mut found_any = false;
let mut names: Vec<_> = config.workspaces.keys().cloned().collect(); let mut workspaces: Vec<_> = config.workspaces.values().collect();
names.sort(); workspaces.sort_by(|a, b| a.name.cmp(&b.name));
for name in names { for ws in workspaces {
let ws = config.get_workspace(&name).unwrap();
if ws.webdav_url.is_some() { if ws.webdav_url.is_some() {
found_any = true; found_any = true;
print_workspace_status(&name, &ws.path, ws.webdav_url.as_deref())?; print_workspace_status(&ws.name, &ws.path, ws.webdav_url.as_deref())?;
output::blank(); output::blank();
} }
} }
@ -163,18 +156,8 @@ pub fn status(workspace_name: Option<String>, all: bool) -> Result<()> {
return Ok(()); return Ok(());
} }
let (name, workspace) = if let Some(name) = workspace_name { let (_id, workspace) = resolve_workspace(&config, workspace_name.as_deref())?;
let ws = config.get_workspace(&name) print_workspace_status(&workspace.name, &workspace.path, workspace.webdav_url.as_deref())?;
.ok_or_else(|| anyhow::anyhow!("Workspace '{}' not found", name))?
.clone();
(name, ws)
} else {
let (n, ws) = config.get_current_workspace()
.context("No workspace set.")?;
(n.clone(), ws.clone())
};
print_workspace_status(&name, &workspace.path, workspace.webdav_url.as_deref())?;
Ok(()) Ok(())
} }
@ -207,17 +190,13 @@ fn print_workspace_status(name: &str, path: &std::path::Path, webdav_url: Option
/// Extract host from a URL for credential storage. /// Extract host from a URL for credential storage.
fn extract_domain(url: &str) -> String { fn extract_domain(url: &str) -> String {
// Strip scheme
let after_scheme = url.split("://").nth(1).unwrap_or(url); let after_scheme = url.split("://").nth(1).unwrap_or(url);
// Strip path
let authority = after_scheme.split('/').next().unwrap_or(after_scheme); let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
// Strip userinfo (user:pass@host)
let host_port = if let Some(at_pos) = authority.rfind('@') { let host_port = if let Some(at_pos) = authority.rfind('@') {
&authority[at_pos + 1..] &authority[at_pos + 1..]
} else { } else {
authority authority
}; };
// Strip port
host_port.split(':').next().unwrap_or(host_port).to_string() host_port.split(':').next().unwrap_or(host_port).to_string()
} }

View file

@ -192,7 +192,6 @@ pub fn edit(task_id_str: String, workspace: Option<String>) -> Result<()> {
let mut updated_task = task.clone(); let mut updated_task = task.clone();
updated_task.title = title; updated_task.title = title;
updated_task.description = description; updated_task.description = description;
updated_task.updated_at = Utc::now();
repo.update_task(list_id, updated_task.clone()) repo.update_task(list_id, updated_task.clone())
.context("Failed to update task")?; .context("Failed to update task")?;

View file

@ -27,18 +27,13 @@ pub fn add(name: String, path: String) -> Result<()> {
// Load config // Load config
let mut config = load_config()?; let mut config = load_config()?;
// Check if workspace already exists
if config.get_workspace(&name).is_some() {
anyhow::bail!("Workspace '{}' already exists", name);
}
// Add workspace // Add workspace
config.add_workspace(name.clone(), WorkspaceConfig::new(path_buf.clone())); let id = config.add_workspace(WorkspaceConfig::new(name.clone(), path_buf.clone()));
// Save config // Save config
save_config(&config)?; save_config(&config)?;
output::success(&format!("Added workspace \"{}\" at {}", name, path_buf.display())); output::success(&format!("Added workspace \"{}\" ({}) at {}", name, &id[..8], path_buf.display()));
output::success("Created default list \"My Tasks\""); output::success("Created default list \"My Tasks\"");
Ok(()) Ok(())
@ -55,29 +50,37 @@ pub fn list() -> Result<()> {
let current = config.current_workspace.as_deref(); let current = config.current_workspace.as_deref();
let mut workspaces: Vec<_> = config.workspaces.iter().collect(); let mut workspaces: Vec<_> = config.workspaces.iter().collect();
workspaces.sort_by(|a, b| a.0.cmp(b.0)); workspaces.sort_by(|a, b| a.1.name.cmp(&b.1.name));
for (name, workspace_config) in workspaces { for (id, workspace_config) in workspaces {
let marker = if Some(name.as_str()) == current { let marker = if Some(id.as_str()) == current {
" (current)".green() " (current)".green()
} else { } else {
"".normal() "".normal()
}; };
output::item(&format!("{}: {}{}", name, workspace_config.path.display(), marker)); output::item(&format!("{}: {}{}", workspace_config.name, workspace_config.path.display(), marker));
} }
Ok(()) Ok(())
} }
/// Resolve a workspace name to its ID. Errors if not found or ambiguous.
fn resolve_name(config: &onyx_core::config::AppConfig, name: &str) -> Result<String> {
let matches: Vec<_> = config.workspaces.iter()
.filter(|(_, ws)| ws.name == name)
.collect();
match matches.len() {
0 => anyhow::bail!("Workspace '{}' not found", name),
1 => Ok(matches[0].0.clone()),
n => anyhow::bail!("Ambiguous: {} workspaces named '{}'. Use the workspace ID instead.", n, name),
}
}
pub fn switch(name: String) -> Result<()> { pub fn switch(name: String) -> Result<()> {
let mut config = load_config()?; let mut config = load_config()?;
let id = resolve_name(&config, &name)?;
// Verify workspace exists config.set_current_workspace(id)?;
if config.get_workspace(&name).is_none() {
anyhow::bail!("Workspace '{}' not found", name);
}
config.set_current_workspace(name.clone())?;
save_config(&config)?; save_config(&config)?;
output::success(&format!("Switched to workspace \"{}\"", name)); output::success(&format!("Switched to workspace \"{}\"", name));
@ -87,11 +90,7 @@ pub fn switch(name: String) -> Result<()> {
pub fn remove(name: String) -> Result<()> { pub fn remove(name: String) -> Result<()> {
let mut config = load_config()?; let mut config = load_config()?;
let id = resolve_name(&config, &name)?;
// Verify workspace exists
if config.get_workspace(&name).is_none() {
anyhow::bail!("Workspace '{}' not found", name);
}
// Confirm // Confirm
output::warning("This will delete workspace config (files remain on disk)"); output::warning("This will delete workspace config (files remain on disk)");
@ -107,7 +106,7 @@ pub fn remove(name: String) -> Result<()> {
return Ok(()); return Ok(());
} }
config.remove_workspace(&name); config.remove_workspace(&id);
save_config(&config)?; save_config(&config)?;
output::success(&format!("Removed workspace \"{}\"", name)); output::success(&format!("Removed workspace \"{}\"", name));
@ -124,14 +123,10 @@ pub fn retarget(name: String, path: String) -> Result<()> {
}; };
let mut config = load_config()?; let mut config = load_config()?;
let id = resolve_name(&config, &name)?;
// Verify workspace exists
if config.get_workspace(&name).is_none() {
anyhow::bail!("Workspace '{}' not found", name);
}
// Update path // Update path
config.add_workspace(name.clone(), WorkspaceConfig::new(path_buf.clone())); config.workspaces.get_mut(&id).unwrap().path = path_buf.clone();
save_config(&config)?; save_config(&config)?;
output::success(&format!("Workspace \"{}\" now points to {}", name, path_buf.display())); output::success(&format!("Workspace \"{}\" now points to {}", name, path_buf.display()));
@ -148,9 +143,10 @@ pub fn migrate(name: String, new_path: String) -> Result<()> {
}; };
let mut config = load_config()?; let mut config = load_config()?;
let id = resolve_name(&config, &name)?;
// Get current workspace config // Get current workspace config
let old_path = config.get_workspace(&name) let old_path = config.get_workspace(&id)
.ok_or_else(|| anyhow::anyhow!("Workspace '{}' not found", name))? .ok_or_else(|| anyhow::anyhow!("Workspace '{}' not found", name))?
.path.clone(); .path.clone();
@ -225,7 +221,7 @@ pub fn migrate(name: String, new_path: String) -> Result<()> {
} }
// Update config // Update config
config.add_workspace(name.clone(), WorkspaceConfig::new(new_path_buf.clone())); config.workspaces.get_mut(&id).unwrap().path = new_path_buf.clone();
save_config(&config)?; save_config(&config)?;
output::success(&format!("Migrated {} items to {}", moved.len(), new_path_buf.display())); output::success(&format!("Migrated {} items to {}", moved.len(), new_path_buf.display()));

View file

@ -1,6 +1,7 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::path::PathBuf; use std::path::PathBuf;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::error::{Error, Result}; use crate::error::{Error, Result};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@ -18,23 +19,29 @@ impl Default for WorkspaceMode {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceConfig { pub struct WorkspaceConfig {
pub name: String,
pub path: PathBuf, pub path: PathBuf,
#[serde(default)] #[serde(default)]
pub mode: WorkspaceMode, pub mode: WorkspaceMode,
#[serde(skip_serializing_if = "Option::is_none", default)] #[serde(skip_serializing_if = "Option::is_none", default)]
pub webdav_url: Option<String>, pub webdav_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)] #[serde(skip_serializing_if = "Option::is_none", default)]
pub webdav_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub last_sync: Option<chrono::DateTime<chrono::Utc>>, pub last_sync: Option<chrono::DateTime<chrono::Utc>>,
#[serde(skip_serializing_if = "Option::is_none", default)] #[serde(skip_serializing_if = "Option::is_none", default)]
pub theme: Option<String>, pub theme: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub sync_interval_secs: Option<u64>,
} }
impl WorkspaceConfig { impl WorkspaceConfig {
pub fn new(path: PathBuf) -> Self { pub fn new(name: String, path: PathBuf) -> Self {
Self { path, mode: WorkspaceMode::Local, webdav_url: None, last_sync: None, theme: None } Self { name, path, mode: WorkspaceMode::Local, webdav_url: None, webdav_path: None, last_sync: None, theme: None, sync_interval_secs: None }
} }
} }
/// Workspaces keyed by UUID string.
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AppConfig { pub struct AppConfig {
pub workspaces: HashMap<String, WorkspaceConfig>, pub workspaces: HashMap<String, WorkspaceConfig>,
@ -49,37 +56,51 @@ impl AppConfig {
} }
} }
pub fn add_workspace(&mut self, name: String, config: WorkspaceConfig) { pub fn add_workspace(&mut self, config: WorkspaceConfig) -> String {
self.workspaces.insert(name, config); let id = Uuid::new_v4().to_string();
self.workspaces.insert(id.clone(), config);
id
} }
pub fn remove_workspace(&mut self, name: &str) -> Option<WorkspaceConfig> { pub fn remove_workspace(&mut self, id: &str) -> Option<WorkspaceConfig> {
if self.current_workspace.as_deref() == Some(name) { if self.current_workspace.as_deref() == Some(id) {
self.current_workspace = None; self.current_workspace = None;
} }
self.workspaces.remove(name) self.workspaces.remove(id)
} }
pub fn get_workspace(&self, name: &str) -> Option<&WorkspaceConfig> { pub fn rename_workspace(&mut self, id: &str, new_name: String) -> Result<()> {
self.workspaces.get(name) let ws = self.workspaces.get_mut(id)
.ok_or_else(|| Error::InvalidData(format!("Workspace '{}' not found", id)))?;
ws.name = new_name;
Ok(())
}
pub fn get_workspace(&self, id: &str) -> Option<&WorkspaceConfig> {
self.workspaces.get(id)
} }
pub fn get_current_workspace(&self) -> Result<(&String, &WorkspaceConfig)> { pub fn get_current_workspace(&self) -> Result<(&String, &WorkspaceConfig)> {
let name = self.current_workspace.as_ref() let id = self.current_workspace.as_ref()
.ok_or_else(|| Error::WorkspaceNotFound("No current workspace set".to_string()))?; .ok_or_else(|| Error::WorkspaceNotFound("No current workspace set".to_string()))?;
let config = self.workspaces.get(name) let config = self.workspaces.get(id)
.ok_or_else(|| Error::WorkspaceNotFound(name.clone()))?; .ok_or_else(|| Error::WorkspaceNotFound(id.clone()))?;
Ok((name, config)) Ok((id, config))
} }
pub fn set_current_workspace(&mut self, name: String) -> Result<()> { pub fn set_current_workspace(&mut self, id: String) -> Result<()> {
if !self.workspaces.contains_key(&name) { if !self.workspaces.contains_key(&id) {
return Err(Error::WorkspaceNotFound(name)); return Err(Error::WorkspaceNotFound(id));
} }
self.current_workspace = Some(name); self.current_workspace = Some(id);
Ok(()) Ok(())
} }
/// Find a workspace by display name. Returns (id, config) of the first match.
pub fn find_by_name(&self, name: &str) -> Option<(&String, &WorkspaceConfig)> {
self.workspaces.iter().find(|(_, ws)| ws.name == name)
}
pub fn load_from_file(path: &PathBuf) -> Result<Self> { pub fn load_from_file(path: &PathBuf) -> Result<Self> {
if !path.exists() { if !path.exists() {
return Ok(Self::new()); return Ok(Self::new());
@ -119,11 +140,11 @@ mod tests {
} }
#[test] #[test]
fn test_get_current_workspace_name_points_to_removed_workspace() { fn test_get_current_workspace_id_points_to_removed_workspace() {
let mut config = AppConfig::new(); let mut config = AppConfig::new();
config.add_workspace("test".to_string(), WorkspaceConfig::new(PathBuf::from("/tmp"))); let id = config.add_workspace(WorkspaceConfig::new("test".into(), PathBuf::from("/tmp")));
config.current_workspace = Some("test".to_string()); config.current_workspace = Some(id.clone());
config.workspaces.remove("test"); config.workspaces.remove(&id);
let result = config.get_current_workspace(); let result = config.get_current_workspace();
assert!(result.is_err()); assert!(result.is_err());
@ -141,31 +162,31 @@ mod tests {
#[test] #[test]
fn test_set_current_workspace_valid() { fn test_set_current_workspace_valid() {
let mut config = AppConfig::new(); let mut config = AppConfig::new();
config.add_workspace("real".to_string(), WorkspaceConfig::new(PathBuf::from("/tmp"))); let id = config.add_workspace(WorkspaceConfig::new("real".into(), PathBuf::from("/tmp")));
assert!(config.set_current_workspace("real".to_string()).is_ok()); assert!(config.set_current_workspace(id.clone()).is_ok());
assert_eq!(config.current_workspace.as_deref(), Some("real")); assert_eq!(config.current_workspace.as_deref(), Some(id.as_str()));
} }
#[test] #[test]
fn test_remove_current_workspace_clears_current() { fn test_remove_current_workspace_clears_current() {
let mut config = AppConfig::new(); let mut config = AppConfig::new();
config.add_workspace("ws".to_string(), WorkspaceConfig::new(PathBuf::from("/tmp"))); let id = config.add_workspace(WorkspaceConfig::new("ws".into(), PathBuf::from("/tmp")));
config.set_current_workspace("ws".to_string()).unwrap(); config.set_current_workspace(id.clone()).unwrap();
config.remove_workspace("ws"); config.remove_workspace(&id);
assert!(config.current_workspace.is_none()); assert!(config.current_workspace.is_none());
assert!(config.get_workspace("ws").is_none()); assert!(config.get_workspace(&id).is_none());
} }
#[test] #[test]
fn test_remove_noncurrent_workspace_keeps_current() { fn test_remove_noncurrent_workspace_keeps_current() {
let mut config = AppConfig::new(); let mut config = AppConfig::new();
config.add_workspace("a".to_string(), WorkspaceConfig::new(PathBuf::from("/a"))); let id_a = config.add_workspace(WorkspaceConfig::new("a".into(), PathBuf::from("/a")));
config.add_workspace("b".to_string(), WorkspaceConfig::new(PathBuf::from("/b"))); let id_b = config.add_workspace(WorkspaceConfig::new("b".into(), PathBuf::from("/b")));
config.set_current_workspace("a".to_string()).unwrap(); config.set_current_workspace(id_a.clone()).unwrap();
config.remove_workspace("b"); config.remove_workspace(&id_b);
assert_eq!(config.current_workspace.as_deref(), Some("a")); assert_eq!(config.current_workspace.as_deref(), Some(id_a.as_str()));
} }
#[test] #[test]
@ -174,16 +195,16 @@ mod tests {
let config_path = temp_dir.path().join("config.json"); let config_path = temp_dir.path().join("config.json");
let mut config = AppConfig::new(); let mut config = AppConfig::new();
config.add_workspace("ws1".to_string(), WorkspaceConfig::new(PathBuf::from("/path/one"))); let id1 = config.add_workspace(WorkspaceConfig::new("ws1".into(), PathBuf::from("/path/one")));
config.add_workspace("ws2".to_string(), WorkspaceConfig::new(PathBuf::from("/path/two"))); let _id2 = config.add_workspace(WorkspaceConfig::new("ws2".into(), PathBuf::from("/path/two")));
config.set_current_workspace("ws1".to_string()).unwrap(); config.set_current_workspace(id1.clone()).unwrap();
config.save_to_file(&config_path).unwrap(); config.save_to_file(&config_path).unwrap();
let loaded = AppConfig::load_from_file(&config_path).unwrap(); let loaded = AppConfig::load_from_file(&config_path).unwrap();
assert_eq!(loaded.current_workspace.as_deref(), Some("ws1")); assert_eq!(loaded.current_workspace.as_deref(), Some(id1.as_str()));
assert_eq!(loaded.workspaces.len(), 2); assert_eq!(loaded.workspaces.len(), 2);
assert_eq!(loaded.get_workspace("ws1").unwrap().path, PathBuf::from("/path/one")); assert_eq!(loaded.get_workspace(&id1).unwrap().path, PathBuf::from("/path/one"));
assert_eq!(loaded.get_workspace("ws2").unwrap().path, PathBuf::from("/path/two")); assert_eq!(loaded.get_workspace(&id1).unwrap().name, "ws1");
} }
#[test] #[test]
@ -214,13 +235,35 @@ mod tests {
} }
#[test] #[test]
fn test_add_workspace_overwrites_existing() { fn test_duplicate_names_allowed() {
let mut config = AppConfig::new(); let mut config = AppConfig::new();
config.add_workspace("ws".to_string(), WorkspaceConfig::new(PathBuf::from("/old"))); let id1 = config.add_workspace(WorkspaceConfig::new("Onyx".into(), PathBuf::from("/a")));
config.add_workspace("ws".to_string(), WorkspaceConfig::new(PathBuf::from("/new"))); let id2 = config.add_workspace(WorkspaceConfig::new("Onyx".into(), PathBuf::from("/b")));
assert_eq!(config.get_workspace("ws").unwrap().path, PathBuf::from("/new")); assert_ne!(id1, id2);
assert_eq!(config.workspaces.len(), 1); assert_eq!(config.workspaces.len(), 2);
assert_eq!(config.get_workspace(&id1).unwrap().name, "Onyx");
assert_eq!(config.get_workspace(&id2).unwrap().name, "Onyx");
}
#[test]
fn test_find_by_name() {
let mut config = AppConfig::new();
let id = config.add_workspace(WorkspaceConfig::new("Tasks".into(), PathBuf::from("/tasks")));
let found = config.find_by_name("Tasks");
assert!(found.is_some());
assert_eq!(found.unwrap().0, &id);
assert!(config.find_by_name("Nonexistent").is_none());
}
#[test]
fn test_rename_workspace() {
let mut config = AppConfig::new();
let id = config.add_workspace(WorkspaceConfig::new("Old".into(), PathBuf::from("/tmp")));
config.rename_workspace(&id, "New".into()).unwrap();
assert_eq!(config.get_workspace(&id).unwrap().name, "New");
} }
#[test] #[test]
@ -229,38 +272,15 @@ mod tests {
let config_path = temp_dir.path().join("config.json"); let config_path = temp_dir.path().join("config.json");
let mut config = AppConfig::new(); let mut config = AppConfig::new();
let mut ws = WorkspaceConfig::new(PathBuf::from("/tasks")); let mut ws = WorkspaceConfig::new("synced".into(), PathBuf::from("/tasks"));
ws.webdav_url = Some("https://dav.example.com/tasks".to_string()); ws.webdav_url = Some("https://dav.example.com/tasks".to_string());
ws.last_sync = Some(chrono::Utc::now()); ws.last_sync = Some(chrono::Utc::now());
config.add_workspace("synced".to_string(), ws); let id = config.add_workspace(ws);
config.save_to_file(&config_path).unwrap(); config.save_to_file(&config_path).unwrap();
let loaded = AppConfig::load_from_file(&config_path).unwrap(); let loaded = AppConfig::load_from_file(&config_path).unwrap();
let ws = loaded.get_workspace("synced").unwrap(); let ws = loaded.get_workspace(&id).unwrap();
assert_eq!(ws.webdav_url.as_deref(), Some("https://dav.example.com/tasks")); assert_eq!(ws.webdav_url.as_deref(), Some("https://dav.example.com/tasks"));
assert!(ws.last_sync.is_some()); assert!(ws.last_sync.is_some());
} }
#[test]
fn test_backwards_compat_loading_old_format() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.json");
// Write old-format JSON without webdav_url, last_sync, mode, or theme fields
let old_json = r#"{
"workspaces": {
"personal": { "path": "/home/user/tasks" }
},
"current_workspace": "personal"
}"#;
std::fs::write(&config_path, old_json).unwrap();
let loaded = AppConfig::load_from_file(&config_path).unwrap();
let ws = loaded.get_workspace("personal").unwrap();
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

@ -19,15 +19,13 @@ pub struct Task {
pub due_date: Option<DateTime<Utc>>, pub due_date: Option<DateTime<Utc>>,
#[serde(default)] #[serde(default)]
pub has_time: bool, pub has_time: bool,
pub created_at: DateTime<Utc>, pub version: u64,
pub updated_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub parent_id: Option<Uuid>, pub parent_id: Option<Uuid>,
} }
impl Task { impl Task {
pub fn new(title: String) -> Self { pub fn new(title: String) -> Self {
let now = Utc::now();
Self { Self {
id: Uuid::new_v4(), id: Uuid::new_v4(),
title, title,
@ -35,8 +33,7 @@ impl Task {
status: TaskStatus::Backlog, status: TaskStatus::Backlog,
due_date: None, due_date: None,
has_time: false, has_time: false,
created_at: now, version: 0,
updated_at: now,
parent_id: None, parent_id: None,
} }
} }
@ -58,12 +55,10 @@ impl Task {
pub fn complete(&mut self) { pub fn complete(&mut self) {
self.status = TaskStatus::Completed; self.status = TaskStatus::Completed;
self.updated_at = Utc::now();
} }
pub fn uncomplete(&mut self) { pub fn uncomplete(&mut self) {
self.status = TaskStatus::Backlog; self.status = TaskStatus::Backlog;
self.updated_at = Utc::now();
} }
} }

View file

@ -24,8 +24,9 @@ impl TaskRepository {
} }
// Task operations // Task operations
pub fn create_task(&mut self, list_id: Uuid, task: Task) -> Result<Task> { pub fn create_task(&mut self, list_id: Uuid, mut task: Task) -> Result<Task> {
self.storage.write_task(list_id, &task)?; self.storage.write_task(list_id, &task)?;
task.version += 1;
Ok(task) Ok(task)
} }

View file

@ -7,7 +7,7 @@ use uuid::Uuid;
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::models::{Task, TaskList, TaskStatus}; use crate::models::{Task, TaskList, TaskStatus};
/// Metadata stored in root .metadata.json /// Metadata stored in root .onyx-workspace.json
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RootMetadata { pub struct RootMetadata {
pub version: u32, pub version: u32,
@ -49,6 +49,9 @@ impl ListMetadata {
} }
} }
fn is_false(v: &bool) -> bool { !v }
fn default_version() -> u64 { 1 }
/// Frontmatter for task markdown files /// Frontmatter for task markdown files
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskFrontmatter { pub struct TaskFrontmatter {
@ -56,10 +59,10 @@ pub struct TaskFrontmatter {
pub status: TaskStatus, pub status: TaskStatus,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub due: Option<DateTime<Utc>>, pub due: Option<DateTime<Utc>>,
#[serde(default)] #[serde(default, skip_serializing_if = "is_false")]
pub has_time: bool, pub has_time: bool,
pub created: DateTime<Utc>, #[serde(default = "default_version")]
pub updated: DateTime<Utc>, pub version: u64,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub parent: Option<Uuid>, pub parent: Option<Uuid>,
} }
@ -71,8 +74,7 @@ impl From<&Task> for TaskFrontmatter {
status: task.status, status: task.status,
due: task.due_date, due: task.due_date,
has_time: task.has_time, has_time: task.has_time,
created: task.created_at, version: task.version,
updated: task.updated_at,
parent: task.parent_id, parent: task.parent_id,
} }
} }
@ -124,7 +126,7 @@ impl FileSystemStorage {
} }
fn metadata_path(&self) -> PathBuf { fn metadata_path(&self) -> PathBuf {
self.root_path.join(".metadata.json") self.root_path.join(".onyx-workspace.json")
} }
fn list_dir_path(&self, list_id: Uuid) -> Result<PathBuf> { fn list_dir_path(&self, list_id: Uuid) -> Result<PathBuf> {
@ -219,7 +221,8 @@ impl FileSystemStorage {
} }
fn write_markdown_with_frontmatter(&self, task: &Task) -> Result<String> { fn write_markdown_with_frontmatter(&self, task: &Task) -> Result<String> {
let frontmatter = TaskFrontmatter::from(task); let mut frontmatter = TaskFrontmatter::from(task);
frontmatter.version = task.version + 1;
let yaml = serde_yaml::to_string(&frontmatter)?; let yaml = serde_yaml::to_string(&frontmatter)?;
let mut content = String::new(); let mut content = String::new();
@ -277,8 +280,7 @@ impl Storage for FileSystemStorage {
status: frontmatter.status, status: frontmatter.status,
due_date: frontmatter.due, due_date: frontmatter.due,
has_time: frontmatter.has_time, has_time: frontmatter.has_time,
created_at: frontmatter.created, version: frontmatter.version,
updated_at: frontmatter.updated,
parent_id: frontmatter.parent, parent_id: frontmatter.parent,
}); });
} }
@ -343,7 +345,7 @@ impl Storage for FileSystemStorage {
let list_dir = self.list_dir_path(list_id)?; let list_dir = self.list_dir_path(list_id)?;
let list_metadata = self.read_list_metadata(list_id)?; let list_metadata = self.read_list_metadata(list_id)?;
let mut tasks = Vec::new(); let mut file_tasks: Vec<(PathBuf, Task)> = Vec::new();
let entries = fs::read_dir(&list_dir)?; let entries = fs::read_dir(&list_dir)?;
for entry in entries { for entry in entries {
@ -366,15 +368,32 @@ impl Storage for FileSystemStorage {
status: frontmatter.status, status: frontmatter.status,
due_date: frontmatter.due, due_date: frontmatter.due,
has_time: frontmatter.has_time, has_time: frontmatter.has_time,
created_at: frontmatter.created, version: frontmatter.version,
updated_at: frontmatter.updated,
parent_id: frontmatter.parent, parent_id: frontmatter.parent,
}; };
tasks.push(task); file_tasks.push((path, task));
} }
} }
// Self-healing dedup: group by UUID, keep highest version, delete stale files
let mut by_id: HashMap<Uuid, Vec<(PathBuf, Task)>> = HashMap::new();
for entry in file_tasks {
by_id.entry(entry.1.id).or_default().push(entry);
}
let mut tasks = Vec::new();
for (_id, mut entries) in by_id {
if entries.len() > 1 {
entries.sort_by(|a, b| b.1.version.cmp(&a.1.version));
for (stale_path, _) in entries.drain(1..) {
let _ = fs::remove_file(&stale_path);
}
}
let (_, task) = entries.into_iter().next().unwrap();
tasks.push(task);
}
// Sort by task_order // Sort by task_order
let order_map: HashMap<Uuid, usize> = list_metadata.task_order let order_map: HashMap<Uuid, usize> = list_metadata.task_order
.iter() .iter()
@ -557,10 +576,11 @@ mod tests {
let temp_dir = TempDir::new().unwrap(); let temp_dir = TempDir::new().unwrap();
let storage = init_storage(&temp_dir); let storage = init_storage(&temp_dir);
let content = "---\nid: 550e8400-e29b-41d4-a716-446655440000\nstatus: backlog\ncreated: 2026-01-01T00:00:00Z\nupdated: 2026-01-01T00:00:00Z\n---\n\nSome description"; let content = "---\nid: 550e8400-e29b-41d4-a716-446655440000\nstatus: backlog\nversion: 3\n---\n\nSome description";
let (fm, desc) = storage.parse_markdown_with_frontmatter(content).unwrap(); let (fm, desc) = storage.parse_markdown_with_frontmatter(content).unwrap();
assert_eq!(fm.id.to_string(), "550e8400-e29b-41d4-a716-446655440000"); assert_eq!(fm.id.to_string(), "550e8400-e29b-41d4-a716-446655440000");
assert_eq!(fm.status, TaskStatus::Backlog); assert_eq!(fm.status, TaskStatus::Backlog);
assert_eq!(fm.version, 3);
assert_eq!(desc, "Some description"); assert_eq!(desc, "Some description");
} }
@ -569,7 +589,7 @@ mod tests {
let temp_dir = TempDir::new().unwrap(); let temp_dir = TempDir::new().unwrap();
let storage = init_storage(&temp_dir); let storage = init_storage(&temp_dir);
let content = "---\nid: 550e8400-e29b-41d4-a716-446655440000\nstatus: completed\ncreated: 2026-01-01T00:00:00Z\nupdated: 2026-01-01T00:00:00Z\n---"; let content = "---\nid: 550e8400-e29b-41d4-a716-446655440000\nstatus: completed\nversion: 1\n---";
let (fm, desc) = storage.parse_markdown_with_frontmatter(content).unwrap(); let (fm, desc) = storage.parse_markdown_with_frontmatter(content).unwrap();
assert_eq!(fm.status, TaskStatus::Completed); assert_eq!(fm.status, TaskStatus::Completed);
assert!(desc.is_empty()); assert!(desc.is_empty());
@ -621,7 +641,7 @@ mod tests {
let temp_dir = TempDir::new().unwrap(); let temp_dir = TempDir::new().unwrap();
let storage = init_storage(&temp_dir); let storage = init_storage(&temp_dir);
let content = "---\nid: 550e8400-e29b-41d4-a716-446655440000\nstatus: backlog\ndue: 2026-06-15T12:00:00Z\ncreated: 2026-01-01T00:00:00Z\nupdated: 2026-01-01T00:00:00Z\nparent: 660e8400-e29b-41d4-a716-446655440001\n---\n\nNotes"; let content = "---\nid: 550e8400-e29b-41d4-a716-446655440000\nstatus: backlog\ndue: 2026-06-15T12:00:00Z\nversion: 2\nparent: 660e8400-e29b-41d4-a716-446655440001\n---\n\nNotes";
let (fm, _) = storage.parse_markdown_with_frontmatter(content).unwrap(); let (fm, _) = storage.parse_markdown_with_frontmatter(content).unwrap();
assert!(fm.due.is_some()); assert!(fm.due.is_some());
assert!(fm.parent.is_some()); assert!(fm.parent.is_some());
@ -658,7 +678,7 @@ mod tests {
fn test_init_creates_metadata() { fn test_init_creates_metadata() {
let temp_dir = TempDir::new().unwrap(); let temp_dir = TempDir::new().unwrap();
let _storage = init_storage(&temp_dir); let _storage = init_storage(&temp_dir);
assert!(temp_dir.path().join(".metadata.json").exists()); assert!(temp_dir.path().join(".onyx-workspace.json").exists());
} }
#[test] #[test]
@ -683,7 +703,7 @@ mod tests {
let storage = init_storage(&temp_dir); let storage = init_storage(&temp_dir);
// Delete the metadata file to simulate missing // Delete the metadata file to simulate missing
fs::remove_file(temp_dir.path().join(".metadata.json")).unwrap(); fs::remove_file(temp_dir.path().join(".onyx-workspace.json")).unwrap();
let meta = storage.read_root_metadata().unwrap(); let meta = storage.read_root_metadata().unwrap();
assert_eq!(meta.version, 1); assert_eq!(meta.version, 1);
@ -832,4 +852,74 @@ mod tests {
let tasks = storage.list_tasks(list.id).unwrap(); let tasks = storage.list_tasks(list.id).unwrap();
assert!(tasks.is_empty()); assert!(tasks.is_empty());
} }
#[test]
fn test_missing_version_defaults_to_1() {
let temp_dir = TempDir::new().unwrap();
let storage = init_storage(&temp_dir);
let content = "---\nid: 550e8400-e29b-41d4-a716-446655440000\nstatus: backlog\n---\n\nOld task";
let (fm, _) = storage.parse_markdown_with_frontmatter(content).unwrap();
assert_eq!(fm.version, 1);
}
#[test]
fn test_missing_has_time_defaults_to_false() {
let temp_dir = TempDir::new().unwrap();
let storage = init_storage(&temp_dir);
let content = "---\nid: 550e8400-e29b-41d4-a716-446655440000\nstatus: backlog\nversion: 1\n---\n";
let (fm, _) = storage.parse_markdown_with_frontmatter(content).unwrap();
assert!(!fm.has_time);
}
#[test]
fn test_version_increments_on_write() {
let temp_dir = TempDir::new().unwrap();
let mut storage = init_storage(&temp_dir);
let list = storage.create_list("Tasks".to_string()).unwrap();
let task = Task::new("Versioned".to_string());
assert_eq!(task.version, 0);
storage.write_task(list.id, &task).unwrap();
let read_back = storage.read_task(list.id, task.id).unwrap();
assert_eq!(read_back.version, 1);
// Write again — version should increment again
storage.write_task(list.id, &read_back).unwrap();
let read_again = storage.read_task(list.id, task.id).unwrap();
assert_eq!(read_again.version, 2);
}
#[test]
fn test_dedup_keeps_highest_version() {
let temp_dir = TempDir::new().unwrap();
let mut storage = init_storage(&temp_dir);
let list = storage.create_list("Dedup".to_string()).unwrap();
let task = Task::new("Original".to_string());
let task_id = task.id;
storage.write_task(list.id, &task).unwrap();
// Simulate a sync duplicate: manually write a second file with the same UUID but lower version
let list_dir = storage.list_dir_path(list.id).unwrap();
let stale_content = format!(
"---\nid: {}\nstatus: backlog\nversion: 1\n---\n\nStale copy",
task_id
);
let stale_path = list_dir.join("Original_old.md");
fs::write(&stale_path, &stale_content).unwrap();
let tasks = storage.list_tasks(list.id).unwrap();
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].id, task_id);
// The winner should be the one written by write_task (version 1), not the manually created stale copy (also version 1 but alphabetically second)
// Actually both are version 1, so the first sorted wins — but the stale file should be cleaned up
// Let's verify only one .md file remains
let md_count = fs::read_dir(&list_dir).unwrap()
.filter(|e| e.as_ref().unwrap().path().extension().and_then(|s| s.to_str()) == Some("md"))
.count();
assert_eq!(md_count, 1);
}
} }

View file

@ -3,7 +3,9 @@ use std::path::Path;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Sha256, Digest}; use sha2::{Sha256, Digest};
use uuid::Uuid;
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::storage::{ListMetadata, TaskFrontmatter};
use crate::webdav::WebDavClient; use crate::webdav::WebDavClient;
// --- Sync State --- // --- Sync State ---
@ -32,8 +34,7 @@ pub enum SyncAction {
Download { path: String }, Download { path: String },
DeleteLocal { path: String }, DeleteLocal { path: String },
DeleteRemote { path: String }, DeleteRemote { path: String },
ConflictLocalWins { path: String }, Conflict { path: String },
ConflictRemoteWins { path: String },
} }
impl SyncAction { impl SyncAction {
@ -43,8 +44,7 @@ impl SyncAction {
| SyncAction::Download { path } | SyncAction::Download { path }
| SyncAction::DeleteLocal { path } | SyncAction::DeleteLocal { path }
| SyncAction::DeleteRemote { path } | SyncAction::DeleteRemote { path }
| SyncAction::ConflictLocalWins { path } | SyncAction::Conflict { path } => path,
| SyncAction::ConflictRemoteWins { path } => path,
} }
} }
} }
@ -137,12 +137,7 @@ pub fn compute_sync_actions(
(true, false) => actions.push(SyncAction::Upload { path: path.to_string() }), (true, false) => actions.push(SyncAction::Upload { path: path.to_string() }),
(false, true) => actions.push(SyncAction::Download { path: path.to_string() }), (false, true) => actions.push(SyncAction::Download { path: path.to_string() }),
(true, true) => { (true, true) => {
// Both modified: last-write-wins based on timestamps actions.push(SyncAction::Conflict { path: path.to_string() });
if local_wins(l.modified_at.as_deref(), r.last_modified.as_deref()) {
actions.push(SyncAction::ConflictLocalWins { path: path.to_string() });
} else {
actions.push(SyncAction::ConflictRemoteWins { path: path.to_string() });
}
} }
} }
} }
@ -157,19 +152,21 @@ pub fn compute_sync_actions(
actions.push(SyncAction::Download { path: path.to_string() }); actions.push(SyncAction::Download { path: path.to_string() });
} }
// Both present, no base (both added): last-write-wins // Both present, no base (both added): conflict
(Some(l), Some(r), None) => { (Some(_), Some(_), None) => {
if local_wins(l.modified_at.as_deref(), r.last_modified.as_deref()) { actions.push(SyncAction::Conflict { path: path.to_string() });
actions.push(SyncAction::ConflictLocalWins { path: path.to_string() });
} else {
actions.push(SyncAction::ConflictRemoteWins { path: path.to_string() });
}
} }
// Local present, remote gone, base known: remote was deleted // Local present, remote gone, base known: remote was deleted
(Some(_), None, Some(_)) => { (Some(l), None, Some(b)) => {
// modified locally + deleted remote -> upload (local wins) let local_changed = l.checksum != b.checksum;
if local_changed {
// modified locally + deleted remotely -> upload (local wins)
actions.push(SyncAction::Upload { path: path.to_string() }); actions.push(SyncAction::Upload { path: path.to_string() });
} else {
// unchanged locally + deleted remotely -> delete local
actions.push(SyncAction::DeleteLocal { path: path.to_string() });
}
} }
// Remote present, local gone, base known: local was deleted // Remote present, local gone, base known: local was deleted
@ -215,19 +212,6 @@ fn timestamps_equal(a: Option<&str>, b: Option<&str>) -> bool {
} }
} }
/// Determine if local wins based on timestamps. True means local wins.
fn local_wins(local_modified: Option<&str>, remote_modified: Option<&str>) -> bool {
// Try parsing both; if we can't parse, local wins by default
let local_ts = local_modified.and_then(parse_timestamp);
let remote_ts = remote_modified.and_then(parse_timestamp);
match (local_ts, remote_ts) {
(Some(l), Some(r)) => l >= r,
(Some(_), None) => true,
(None, Some(_)) => false,
(None, None) => true, // Default to local
}
}
/// Parse a timestamp string (ISO 8601 or HTTP date format). /// Parse a timestamp string (ISO 8601 or HTTP date format).
fn parse_timestamp(s: &str) -> Option<DateTime<Utc>> { fn parse_timestamp(s: &str) -> Option<DateTime<Utc>> {
// Try ISO 8601 / RFC 3339 // Try ISO 8601 / RFC 3339
@ -336,8 +320,7 @@ fn queued_op_to_action(op: &QueuedOperation) -> Option<SyncAction> {
"download" => Some(SyncAction::Download { path }), "download" => Some(SyncAction::Download { path }),
"delete_local" => Some(SyncAction::DeleteLocal { path }), "delete_local" => Some(SyncAction::DeleteLocal { path }),
"delete_remote" => Some(SyncAction::DeleteRemote { path }), "delete_remote" => Some(SyncAction::DeleteRemote { path }),
"conflict_local_wins" => Some(SyncAction::ConflictLocalWins { path }), "conflict" => Some(SyncAction::Conflict { path }),
"conflict_remote_wins" => Some(SyncAction::ConflictRemoteWins { path }),
_ => None, _ => None,
} }
} }
@ -348,8 +331,7 @@ fn action_to_queued_op(action: &SyncAction) -> QueuedOperation {
SyncAction::Download { path } => ("download", path), SyncAction::Download { path } => ("download", path),
SyncAction::DeleteLocal { path } => ("delete_local", path), SyncAction::DeleteLocal { path } => ("delete_local", path),
SyncAction::DeleteRemote { path } => ("delete_remote", path), SyncAction::DeleteRemote { path } => ("delete_remote", path),
SyncAction::ConflictLocalWins { path } => ("conflict_local_wins", path), SyncAction::Conflict { path } => ("conflict", path),
SyncAction::ConflictRemoteWins { path } => ("conflict_remote_wins", path),
}; };
QueuedOperation { QueuedOperation {
action_type: action_type.to_string(), action_type: action_type.to_string(),
@ -371,8 +353,8 @@ pub fn compute_checksum(data: &[u8]) -> String {
fn is_syncable(path: &str) -> bool { fn is_syncable(path: &str) -> bool {
let parts: Vec<&str> = path.split('/').collect(); let parts: Vec<&str> = path.split('/').collect();
let filename = parts.last().copied().unwrap_or(path); let filename = parts.last().copied().unwrap_or(path);
// .metadata.json only at workspace root (depth 1) // .onyx-workspace.json only at workspace root (depth 1)
if filename == ".metadata.json" { if filename == ".onyx-workspace.json" {
return parts.len() == 1; return parts.len() == 1;
} }
// .listdata.json only inside a list directory (depth 2) // .listdata.json only inside a list directory (depth 2)
@ -529,9 +511,7 @@ async fn sync_workspace_inner(
mode: SyncMode, mode: SyncMode,
on_progress: Option<ProgressCallback>, on_progress: Option<ProgressCallback>,
) -> Result<SyncResult> { ) -> Result<SyncResult> {
// Sync into an "Onyx" subfolder so we don't scan the user's entire cloud storage let client = WebDavClient::new(webdav_url, username, password)?;
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 mut sync_state = SyncState::load(workspace_path);
let queue = OfflineQueue::load(workspace_path); let queue = OfflineQueue::load(workspace_path);
let mut result = SyncResult::default(); let mut result = SyncResult::default();
@ -542,8 +522,6 @@ async fn sync_workspace_inner(
} }
}; };
// Ensure remote Onyx folder exists (creates it on first sync)
client.create_dir("").await.ok();
client.test_connection().await?; client.test_connection().await?;
// Scan local files // Scan local files
@ -565,36 +543,34 @@ async fn sync_workspace_inner(
// Merge with offline queue // Merge with offline queue
let all_actions = queue.merge_with_actions(fresh_actions); let all_actions = queue.merge_with_actions(fresh_actions);
// Filter by sync mode // Filter by sync mode (conflicts always run in any mode since they need both sides)
let actions: Vec<SyncAction> = all_actions.into_iter().filter(|a| match mode { let actions: Vec<SyncAction> = all_actions.into_iter().filter(|a| match mode {
SyncMode::Full => true, SyncMode::Full => true,
SyncMode::Push => matches!(a, SyncAction::Upload { .. } | SyncAction::DeleteRemote { .. } | SyncAction::ConflictLocalWins { .. }), SyncMode::Push => matches!(a, SyncAction::Upload { .. } | SyncAction::DeleteRemote { .. } | SyncAction::Conflict { .. }),
SyncMode::Pull => matches!(a, SyncAction::Download { .. } | SyncAction::DeleteLocal { .. } | SyncAction::ConflictRemoteWins { .. }), SyncMode::Pull => matches!(a, SyncAction::Download { .. } | SyncAction::DeleteLocal { .. } | SyncAction::Conflict { .. }),
}).collect(); }).collect();
// Execute actions, collecting failures for the queue // Execute actions, collecting failures for the queue
let mut failed_actions = Vec::new(); let mut failed_actions = Vec::new();
// Build remote timestamp lookup for recording accurate download times
let remote_meta: HashMap<&str, &RemoteFileSnapshot> = remote_files.iter().map(|f| (f.path.as_str(), f)).collect();
for action in &actions { for action in &actions {
match execute_action(&client, workspace_path, action, &mut sync_state, &report).await { match execute_action(&client, workspace_path, action, &mut sync_state, &remote_meta, &report).await {
Ok(()) => { Ok(()) => {
match action { match action {
SyncAction::Upload { .. } | SyncAction::ConflictLocalWins { .. } => result.uploaded += 1, SyncAction::Upload { .. } => result.uploaded += 1,
SyncAction::Download { .. } | SyncAction::ConflictRemoteWins { .. } => result.downloaded += 1, SyncAction::Download { .. } => result.downloaded += 1,
SyncAction::DeleteLocal { .. } => result.deleted_local += 1, SyncAction::DeleteLocal { .. } => result.deleted_local += 1,
SyncAction::DeleteRemote { .. } => result.deleted_remote += 1, SyncAction::DeleteRemote { .. } => result.deleted_remote += 1,
SyncAction::Conflict { .. } => result.conflicts += 1,
} }
} }
Err(e) => { Err(e) => {
let msg = format!("Failed {}: {}", action.path(), e); let msg = format!("Failed {}: {}", action.path(), e);
report(&format!(" ! {}", msg)); report(&format!(" ! {}", msg));
result.errors.push(msg); result.errors.push(msg);
if matches!(action,
SyncAction::Upload { .. } | SyncAction::Download { .. }
| SyncAction::ConflictLocalWins { .. } | SyncAction::ConflictRemoteWins { .. }
) {
result.conflicts += 1;
}
failed_actions.push(action.clone()); failed_actions.push(action.clone());
} }
} }
@ -619,6 +595,7 @@ async fn execute_action(
workspace_path: &Path, workspace_path: &Path,
action: &SyncAction, action: &SyncAction,
sync_state: &mut SyncState, sync_state: &mut SyncState,
remote_meta: &HashMap<&str, &RemoteFileSnapshot>,
report: &(dyn Fn(&str) + Send + Sync), report: &(dyn Fn(&str) + Send + Sync),
) -> Result<()> { ) -> Result<()> {
match action { match action {
@ -641,22 +618,80 @@ async fn execute_action(
sync_state.record_file(path, &checksum, modified.as_deref(), data.len() as u64); sync_state.record_file(path, &checksum, modified.as_deref(), data.len() as u64);
} }
SyncAction::ConflictLocalWins { path } => { SyncAction::Conflict { path } => {
let local_path = workspace_path.join(path.replace('/', std::path::MAIN_SEPARATOR_STR)); let local_path = workspace_path.join(path.replace('/', std::path::MAIN_SEPARATOR_STR));
let data = std::fs::read(&local_path)?; let local_data = std::fs::read(&local_path)?;
let checksum = compute_checksum(&data); let local_checksum = compute_checksum(&local_data);
if let Some(parent) = path_parent(path) { let remote_data = client.get_file(path).await?;
client.ensure_dir(parent).await?; let remote_checksum = compute_checksum(&remote_data);
}
report(&format!(" ^ Conflict: uploading local version of {}", path));
client.put_file(path, data.clone()).await?;
// If checksums match, it's a false conflict — both sides made the same edit
if local_checksum == remote_checksum {
report(&format!(" = Conflict resolved: identical content for {}", path));
let modified = std::fs::metadata(&local_path).ok() let modified = std::fs::metadata(&local_path).ok()
.and_then(|m| m.modified().ok()) .and_then(|m| m.modified().ok())
.map(|t| { let dt: DateTime<Utc> = t.into(); dt.to_rfc3339() }); .map(|t| { let dt: DateTime<Utc> = t.into(); dt.to_rfc3339() });
sync_state.record_file(path, &checksum, modified.as_deref(), data.len() as u64); sync_state.record_file(path, &local_checksum, modified.as_deref(), local_data.len() as u64);
} else {
report(&format!(" ! Conflict: remote wins for {}, recovering local as duplicate", path));
// Remote wins: overwrite local with remote content
std::fs::write(&local_path, &remote_data)?;
let modified = std::fs::metadata(&local_path).ok()
.and_then(|m| m.modified().ok())
.map(|t| { let dt: DateTime<Utc> = t.into(); dt.to_rfc3339() });
sync_state.record_file(path, &remote_checksum, modified.as_deref(), remote_data.len() as u64);
// For .md task files inside a list dir, create a duplicate of the local version
let parts: Vec<&str> = path.split('/').collect();
if parts.len() == 2 && parts[1].ends_with(".md") && parts[1] != ".listdata.json" {
let local_content = String::from_utf8_lossy(&local_data);
if let Ok((frontmatter, description)) = parse_frontmatter_for_conflict(&local_content) {
let original_id = frontmatter.id;
let new_id = Uuid::new_v4();
let prefixed_desc = if description.is_empty() {
"[RECOVERED FROM CONFLICT]".to_string()
} else {
format!("[RECOVERED FROM CONFLICT]\n{}", description)
};
let new_frontmatter = TaskFrontmatter {
id: new_id,
..frontmatter
};
let yaml = serde_yaml::to_string(&new_frontmatter)
.map_err(|e| Error::Sync(e.to_string()))?;
let new_content = format!("---\n{}---\n\n{}", yaml, prefixed_desc);
// Write the duplicate file using the new UUID as filename
let list_dir = workspace_path.join(parts[0]);
let dup_filename = format!("{}.md", new_id);
let dup_path = list_dir.join(&dup_filename);
std::fs::write(&dup_path, &new_content)?;
// Insert new task adjacent to original in .listdata.json
let listdata_path = list_dir.join(".listdata.json");
if listdata_path.exists() {
if let Ok(content) = std::fs::read_to_string(&listdata_path) {
if let Ok(mut metadata) = serde_json::from_str::<ListMetadata>(&content) {
let insert_pos = metadata.task_order.iter()
.position(|id| *id == original_id)
.map(|p| p + 1)
.unwrap_or(metadata.task_order.len());
metadata.task_order.insert(insert_pos, new_id);
if let Ok(json) = serde_json::to_string_pretty(&metadata) {
let _ = std::fs::write(&listdata_path, json);
}
}
}
}
// Don't record the duplicate in sync state — next sync will see it
// as "local added, remote absent" and upload it automatically.
}
}
}
} }
SyncAction::Download { path } => { SyncAction::Download { path } => {
@ -670,33 +705,8 @@ async fn execute_action(
} }
std::fs::write(&local_path, &data)?; std::fs::write(&local_path, &data)?;
// Record in sync state // Record remote's last_modified so next diff won't see a timestamp mismatch
let modified = std::fs::metadata(&local_path).ok() let modified = remote_meta.get(path.as_str()).and_then(|r| r.last_modified.clone());
.and_then(|m| m.modified().ok())
.map(|t| { let dt: DateTime<Utc> = t.into(); dt.to_rfc3339() });
sync_state.record_file(path, &checksum, modified.as_deref(), data.len() as u64);
}
SyncAction::ConflictRemoteWins { path } => {
let local_path = workspace_path.join(path.replace('/', std::path::MAIN_SEPARATOR_STR));
// Back up local version before overwriting with remote
if local_path.exists() {
let backup_path = local_path.with_extension("conflict-backup");
let _ = std::fs::copy(&local_path, &backup_path);
report(&format!(" ! Backed up local version to {}", backup_path.display()));
}
report(&format!(" v Conflict: downloading remote version of {}", path));
let data = client.get_file(path).await?;
let checksum = compute_checksum(&data);
if let Some(parent) = local_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&local_path, &data)?;
let modified = std::fs::metadata(&local_path).ok()
.and_then(|m| m.modified().ok())
.map(|t| { let dt: DateTime<Utc> = t.into(); dt.to_rfc3339() });
sync_state.record_file(path, &checksum, modified.as_deref(), data.len() as u64); sync_state.record_file(path, &checksum, modified.as_deref(), data.len() as u64);
} }
@ -718,6 +728,25 @@ async fn execute_action(
Ok(()) Ok(())
} }
/// Parse frontmatter and description from a markdown task file for conflict recovery.
fn parse_frontmatter_for_conflict(content: &str) -> Result<(TaskFrontmatter, String)> {
let lines: Vec<&str> = content.lines().collect();
if lines.is_empty() || lines[0] != "---" {
return Err(Error::InvalidData("Missing frontmatter delimiter".to_string()));
}
let end_idx = lines[1..].iter().position(|&line| line == "---")
.ok_or_else(|| Error::InvalidData("Missing closing frontmatter delimiter".to_string()))?;
let frontmatter_str = lines[1..=end_idx].join("\n");
let frontmatter: TaskFrontmatter = serde_yaml::from_str(&frontmatter_str)
.map_err(|e| Error::Sync(format!("Failed to parse frontmatter: {}", e)))?;
let description = if end_idx + 2 < lines.len() {
lines[end_idx + 2..].join("\n").trim().to_string()
} else {
String::new()
};
Ok((frontmatter, description))
}
/// Get the parent path of a sync path (e.g., "My Tasks/file.md" -> "My Tasks"). /// Get the parent path of a sync path (e.g., "My Tasks/file.md" -> "My Tasks").
fn path_parent(path: &str) -> Option<&str> { fn path_parent(path: &str) -> Option<&str> {
path.rfind('/').map(|i| &path[..i]) path.rfind('/').map(|i| &path[..i])
@ -876,16 +905,27 @@ mod tests {
let actions = compute_sync_actions(&local, &remote, &state); let actions = compute_sync_actions(&local, &remote, &state);
assert_eq!(actions.len(), 1); assert_eq!(actions.len(), 1);
// Local present, remote gone, base known -> upload (local wins) // Local unchanged, remote deleted -> delete local
assert_eq!(actions[0], SyncAction::DeleteLocal { path: "file.md".to_string() });
}
#[test]
fn test_remote_deleted_local_modified() {
let local = vec![make_local("file.md", "new_checksum")];
let remote = vec![];
let mut state = SyncState::default();
state.files.insert("file.md".to_string(), make_base("abc123"));
let actions = compute_sync_actions(&local, &remote, &state);
assert_eq!(actions.len(), 1);
// Local modified, remote deleted -> upload (local wins)
assert_eq!(actions[0], SyncAction::Upload { path: "file.md".to_string() }); assert_eq!(actions[0], SyncAction::Upload { path: "file.md".to_string() });
} }
#[test] #[test]
fn test_both_modified_local_newer() { fn test_both_modified_emits_conflict() {
let mut local = make_local("file.md", "new_local"); let local = make_local("file.md", "new_local");
local.modified_at = Some("2026-03-15T12:00:00+00:00".to_string());
let mut remote = make_remote("file.md"); let mut remote = make_remote("file.md");
remote.last_modified = Some("Mon, 01 Mar 2026 00:00:00 GMT".to_string());
remote.size = 200; remote.size = 200;
let mut state = SyncState::default(); let mut state = SyncState::default();
@ -893,23 +933,7 @@ mod tests {
let actions = compute_sync_actions(&[local], &[remote], &state); let actions = compute_sync_actions(&[local], &[remote], &state);
assert_eq!(actions.len(), 1); assert_eq!(actions.len(), 1);
assert_eq!(actions[0], SyncAction::ConflictLocalWins { path: "file.md".to_string() }); assert_eq!(actions[0], SyncAction::Conflict { path: "file.md".to_string() });
}
#[test]
fn test_both_modified_remote_newer() {
let mut local = make_local("file.md", "new_local");
local.modified_at = Some("2026-01-01T00:00:00+00:00".to_string());
let mut remote = make_remote("file.md");
remote.last_modified = Some("Sun, 15 Mar 2026 12:00:00 GMT".to_string());
remote.size = 200;
let mut state = SyncState::default();
state.files.insert("file.md".to_string(), make_base("old_base"));
let actions = compute_sync_actions(&[local], &[remote], &state);
assert_eq!(actions.len(), 1);
assert_eq!(actions[0], SyncAction::ConflictRemoteWins { path: "file.md".to_string() });
} }
#[test] #[test]
@ -939,17 +963,15 @@ mod tests {
} }
#[test] #[test]
fn test_both_added_local_newer() { fn test_both_added_emits_conflict() {
let mut local = make_local("file.md", "local_content"); let local = make_local("file.md", "local_content");
local.modified_at = Some("2026-03-15T12:00:00+00:00".to_string()); let remote = make_remote("file.md");
let mut remote = make_remote("file.md");
remote.last_modified = Some("Mon, 01 Jan 2026 00:00:00 GMT".to_string());
let state = SyncState::default(); // No base entry let state = SyncState::default(); // No base entry
let actions = compute_sync_actions(&[local], &[remote], &state); let actions = compute_sync_actions(&[local], &[remote], &state);
assert_eq!(actions.len(), 1); assert_eq!(actions.len(), 1);
assert_eq!(actions[0], SyncAction::ConflictLocalWins { path: "file.md".to_string() }); assert_eq!(actions[0], SyncAction::Conflict { path: "file.md".to_string() });
} }
#[test] #[test]
@ -1117,9 +1139,9 @@ mod tests {
// .listdata.json inside a list dir (depth 2) // .listdata.json inside a list dir (depth 2)
assert!(is_syncable("My Tasks/.listdata.json")); assert!(is_syncable("My Tasks/.listdata.json"));
assert!(!is_syncable(".listdata.json")); // root-level not valid assert!(!is_syncable(".listdata.json")); // root-level not valid
// .metadata.json only at root (depth 1) // .onyx-workspace.json only at root (depth 1)
assert!(is_syncable(".metadata.json")); assert!(is_syncable(".onyx-workspace.json"));
assert!(!is_syncable("My Tasks/.metadata.json")); // nested not valid assert!(!is_syncable("My Tasks/.onyx-workspace.json")); // nested not valid
// Non-syncable // Non-syncable
assert!(!is_syncable(".syncstate.json")); assert!(!is_syncable(".syncstate.json"));
assert!(!is_syncable("random.txt")); assert!(!is_syncable("random.txt"));
@ -1133,7 +1155,7 @@ mod tests {
let root = temp_dir.path(); let root = temp_dir.path();
// Create a workspace-like structure // Create a workspace-like structure
std::fs::write(root.join(".metadata.json"), "{}").unwrap(); std::fs::write(root.join(".onyx-workspace.json"), "{}").unwrap();
std::fs::create_dir_all(root.join("My Tasks")).unwrap(); std::fs::create_dir_all(root.join("My Tasks")).unwrap();
std::fs::write(root.join("My Tasks").join(".listdata.json"), "{}").unwrap(); std::fs::write(root.join("My Tasks").join(".listdata.json"), "{}").unwrap();
std::fs::write(root.join("My Tasks").join("task1.md"), "# Task 1").unwrap(); std::fs::write(root.join("My Tasks").join("task1.md"), "# Task 1").unwrap();
@ -1144,8 +1166,8 @@ mod tests {
std::fs::write(root.join(".syncstate.json"), "{}").unwrap(); std::fs::write(root.join(".syncstate.json"), "{}").unwrap();
let files = scan_local_files(root).unwrap(); let files = scan_local_files(root).unwrap();
assert_eq!(files.len(), 4); // .metadata.json, .listdata.json, task1.md, task2.md assert_eq!(files.len(), 4); // .onyx-workspace.json, .listdata.json, task1.md, task2.md
assert!(files.iter().any(|f| f.path == ".metadata.json")); assert!(files.iter().any(|f| f.path == ".onyx-workspace.json"));
assert!(files.iter().any(|f| f.path == "My Tasks/.listdata.json")); assert!(files.iter().any(|f| f.path == "My Tasks/.listdata.json"));
assert!(files.iter().any(|f| f.path == "My Tasks/task1.md")); assert!(files.iter().any(|f| f.path == "My Tasks/task1.md"));
assert!(files.iter().any(|f| f.path == "My Tasks/task2.md")); assert!(files.iter().any(|f| f.path == "My Tasks/task2.md"));
@ -1159,12 +1181,12 @@ mod tests {
fn test_get_sync_status_no_state() { fn test_get_sync_status_no_state() {
let temp_dir = TempDir::new().unwrap(); let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path(); let root = temp_dir.path();
std::fs::write(root.join(".metadata.json"), "{}").unwrap(); std::fs::write(root.join(".onyx-workspace.json"), "{}").unwrap();
let status = get_sync_status(root).unwrap(); let status = get_sync_status(root).unwrap();
assert!(status.last_sync.is_none()); assert!(status.last_sync.is_none());
assert_eq!(status.tracked_files, 0); assert_eq!(status.tracked_files, 0);
assert_eq!(status.pending_changes, 1); // .metadata.json is new assert_eq!(status.pending_changes, 1); // .onyx-workspace.json is new
assert_eq!(status.queued_operations, 0); assert_eq!(status.queued_operations, 0);
} }
@ -1188,22 +1210,6 @@ mod tests {
assert!(result.is_none()); assert!(result.is_none());
} }
#[test]
fn test_local_wins_local_newer() {
assert!(local_wins(
Some("2026-03-15T12:00:00+00:00"),
Some("Mon, 01 Jan 2026 00:00:00 GMT"),
));
}
#[test]
fn test_local_wins_remote_newer() {
assert!(!local_wins(
Some("2026-01-01T00:00:00+00:00"),
Some("Sun, 15 Mar 2026 12:00:00 GMT"),
));
}
// --- path_parent --- // --- path_parent ---
#[test] #[test]

View file

@ -187,6 +187,28 @@ impl WebDavClient {
Ok(()) Ok(())
} }
/// Move/rename a resource (file or directory) on the server using WebDAV MOVE.
pub async fn move_resource(&self, from: &str, to: &str) -> Result<()> {
let from_url = self.full_url(from);
let to_url = self.full_url(to);
let resp = self._client
.request(reqwest::Method::from_bytes(b"MOVE").unwrap(), &from_url)
.basic_auth(self._username.as_str(), Some(self._password.as_str()))
.header("Destination", &to_url)
.header("Overwrite", "F")
.send()
.await?;
let status = resp.status().as_u16();
if status == 412 {
return Err(Error::WebDav("Destination already exists".into()));
}
if !(200..=299).contains(&status) {
return Err(Error::WebDav(format!("MOVE failed with status {}", status)));
}
Ok(())
}
/// Ensure a directory exists, creating it and parents as needed. /// Ensure a directory exists, creating it and parents as needed.
pub async fn ensure_dir(&self, path: &str) -> Result<()> { pub async fn ensure_dir(&self, path: &str) -> Result<()> {
let parts: Vec<&str> = path.trim_matches('/').split('/').filter(|s| !s.is_empty()).collect(); let parts: Vec<&str> = path.trim_matches('/').split('/').filter(|s| !s.is_empty()).collect();

View file

@ -20,8 +20,7 @@ pub struct Task {
pub status: TaskStatus, pub status: TaskStatus,
pub due_date: Option<DateTime<Utc>>, pub due_date: Option<DateTime<Utc>>,
pub has_time: bool, // Whether due_date includes a specific time pub has_time: bool, // Whether due_date includes a specific time
pub created_at: DateTime<Utc>, pub version: u64, // Increments on every write; used for sync dedup
pub updated_at: DateTime<Utc>,
pub parent_id: Option<Uuid>, pub parent_id: Option<Uuid>,
} }
@ -64,12 +63,12 @@ pub struct TaskList {
#### AppConfig #### AppConfig
Global application configuration supporting multiple workspaces. Global application configuration supporting multiple workspaces. Workspaces are keyed by UUID string.
```rust ```rust
pub struct AppConfig { pub struct AppConfig {
pub workspaces: HashMap<String, WorkspaceConfig>, pub workspaces: HashMap<String, WorkspaceConfig>, // UUID keys
pub current_workspace: Option<String>, pub current_workspace: Option<String>, // UUID of active workspace
} }
``` ```
@ -87,14 +86,18 @@ use onyx_core::AppConfig;
let config_path = AppConfig::get_config_path(); let config_path = AppConfig::get_config_path();
let mut config = AppConfig::load_from_file(&config_path)?; let mut config = AppConfig::load_from_file(&config_path)?;
// Add workspace // Add workspace (returns generated UUID)
config.add_workspace( let id = config.add_workspace(
"personal".to_string(), WorkspaceConfig::new("personal".to_string(), PathBuf::from("/home/user/tasks"))
WorkspaceConfig::new(PathBuf::from("/home/user/tasks"))
); );
// Set current workspace // Set current workspace by ID
config.set_current_workspace("personal".to_string())?; config.set_current_workspace(id)?;
// Find workspace by display name
if let Some((id, ws)) = config.find_by_name("personal") {
println!("Found: {} at {:?}", id, ws.path);
}
// Save config // Save config
config.save_to_file(&config_path)?; config.save_to_file(&config_path)?;
@ -106,9 +109,14 @@ Configuration for a single workspace.
```rust ```rust
pub struct WorkspaceConfig { pub struct WorkspaceConfig {
pub name: String, // Display name
pub path: PathBuf, pub path: PathBuf,
pub mode: WorkspaceMode, // Local or Webdav
pub webdav_url: Option<String>, pub webdav_url: Option<String>,
pub webdav_path: Option<String>, // User-selected remote folder path
pub last_sync: Option<DateTime<Utc>>, pub last_sync: Option<DateTime<Utc>>,
pub theme: Option<String>,
pub sync_interval_secs: Option<u64>, // Auto-sync polling interval
} }
``` ```
@ -232,9 +240,8 @@ Tasks are stored as `.md` files with YAML frontmatter:
--- ---
id: 550e8400-e29b-41d4-a716-446655440000 id: 550e8400-e29b-41d4-a716-446655440000
status: backlog status: backlog
version: 3
due: 2026-11-15T14:00:00Z due: 2026-11-15T14:00:00Z
created: 2026-10-26T10:00:00Z
updated: 2026-10-26T12:30:00Z
parent: 550e8400-e29b-41d4-a716-446655440001 parent: 550e8400-e29b-41d4-a716-446655440001
--- ---
@ -267,7 +274,7 @@ Each list folder contains a `.listdata.json` file:
### Root Metadata ### Root Metadata
The root folder contains a `.metadata.json` file: The root folder contains a `.onyx-workspace.json` file:
```json ```json
{ {
@ -318,7 +325,7 @@ let status = get_sync_status(Path::new("/home/user/tasks"))?;
Credentials are stored in the platform keychain (Windows Credential Manager, macOS Keychain, Linux Secret Service). Credentials are stored in the platform keychain (Windows Credential Manager, macOS Keychain, Linux Secret Service).
Keyring service keys use the format `com.onyx.webdav.<domain>::<username>` — the `::` separator prevents key collisions when usernames contain dots. On first load, credentials stored in the legacy `.`-separated format (`com.onyx.webdav.<domain>.<username>`) are automatically migrated to the scoped format and the old entries are removed. **Core library** (`onyx-core::webdav`): Keyring service keys use the format `com.onyx.webdav.<domain>::<username>` — the `::` separator prevents key collisions when usernames contain dots. On first load, credentials stored in the legacy `.`-separated format are automatically migrated.
```rust ```rust
use onyx_core::webdav::{store_credentials, load_credentials, delete_credentials}; use onyx_core::webdav::{store_credentials, load_credentials, delete_credentials};
@ -333,6 +340,8 @@ let (username, password) = load_credentials("nextcloud.example.com")?;
delete_credentials("nextcloud.example.com")?; delete_credentials("nextcloud.example.com")?;
``` ```
**Tauri GUI**: Uses `tauri-plugin-credentials` instead of direct keyring calls. This plugin provides cross-platform support: EncryptedSharedPreferences (Android Keystore) on Android, keyring crate on desktop. Plugin crate at `apps/tauri/tauri-plugin-credentials/`.
### WebDAV Client ### WebDAV Client
```rust ```rust
@ -342,7 +351,7 @@ let client = WebDavClient::new(
"https://nextcloud.example.com/remote.php/dav/files/user/Tasks", "https://nextcloud.example.com/remote.php/dav/files/user/Tasks",
"username", "username",
"password", "password",
); )?; // Returns Result — rejects non-HTTPS URLs
// Test connection // Test connection
client.test_connection().await?; client.test_connection().await?;
@ -361,11 +370,13 @@ client.delete_file("old-task.md").await?;
### Sync Strategy ### Sync Strategy
- **Three-way diff**: Compares local state, remote state, and last-known baseline to determine actions (upload, download, delete local/remote) - **Three-way diff**: Compares local state, remote state, and last-known baseline to determine actions (upload, download, delete local/remote, conflict)
- **Conflict resolution**: Last-write-wins using file timestamps - **Conflict resolution**: Checksum-based — downloads remote file and compares SHA-256 checksums. Identical content is a false conflict (skipped). When different, remote wins and the local version is recovered as a duplicate task with a new UUID and `[RECOVERED FROM CONFLICT]` prefix, inserted adjacent to the original in `.listdata.json`
- **Offline queue**: Pending operations are queued and replayed when connectivity returns - **Offline queue**: Pending operations are queued and replayed when connectivity returns
- **Sync state**: Stored in `.syncstate.json` within the workspace directory - **Sync state**: Stored in `.syncstate.json` within the workspace directory
- **Auto-sync**: Periodic polling (configurable `sync_interval_secs`), debounced file-change trigger (5s), window-focus trigger (30s stale threshold)
- **Response size cap**: PROPFIND responses are limited to 10 MB (checked via `Content-Length` header and actual body size) to prevent memory exhaustion from malicious servers - **Response size cap**: PROPFIND responses are limited to 10 MB (checked via `Content-Length` header and actual body size) to prevent memory exhaustion from malicious servers
- **Syncable files**: Only processes files at expected depths — `.onyx-workspace.json` at root (depth 1), `.listdata.json` and `*.md` inside list directories (depth 2)
## Error Handling ## Error Handling
@ -424,8 +435,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// Configure workspace // Configure workspace
let mut config = AppConfig::new(); let mut config = AppConfig::new();
config.add_workspace("personal".to_string(), WorkspaceConfig::new(path)); let ws_id = config.add_workspace(WorkspaceConfig::new("personal".to_string(), path));
config.set_current_workspace("personal".to_string())?; config.set_current_workspace(ws_id)?;
config.save_to_file(&AppConfig::get_config_path())?; config.save_to_file(&AppConfig::get_config_path())?;
Ok(()) Ok(())

View file

@ -76,6 +76,11 @@ onyx/
│ │ ├── components/ # Reusable UI components │ │ ├── components/ # Reusable UI components
│ │ ├── stores/ # Svelte state (app.svelte.ts) │ │ ├── stores/ # Svelte state (app.svelte.ts)
│ │ └── types.ts # TypeScript type definitions │ │ └── types.ts # TypeScript type definitions
│ ├── tauri-plugin-credentials/ # Cross-platform credential storage plugin
│ │ ├── Cargo.toml
│ │ ├── src/
│ │ │ └── lib.rs # Desktop (keyring) + plugin API
│ │ └── android/ # Android (EncryptedSharedPreferences)
│ └── src-tauri/ # Rust backend (Tauri commands) │ └── src-tauri/ # Rust backend (Tauri commands)
│ ├── Cargo.toml │ ├── Cargo.toml
│ ├── tauri.conf.json │ ├── tauri.conf.json