Compare commits

...

10 Commits

Author SHA1 Message Date
40a836afd8 Adds .worktrees to gitignore. 2026-09-23 19:56:30 -07:00
15538fd68a Renames Project description files. 2026-09-23 18:47:38 -07:00
08ebe0976c Adds basic project description and adds build instructions to readme. 2026-09-20 02:39:16 -07:00
75ec29ea86 Adds more documentation and automation guides.
Some checks are pending
CI / Rust & Workspace Tests (macos-latest) (push) Waiting to run
CI / Rust & Workspace Tests (ubuntu-latest) (push) Waiting to run
CI / Rust & Workspace Tests (windows-latest) (push) Waiting to run
2026-08-23 23:54:31 -07:00
8a7a201daa Adds shell completion generation. 2026-08-23 23:52:59 -07:00
5b29784c07 Adds comprehensive documentation. 2026-08-23 23:49:37 -07:00
2b6f379273 Adds build and packaging scripts for proper apps. 2026-08-23 23:46:16 -07:00
f9eaf0f215 Implements multi account switching in the gui. 2026-08-23 23:43:23 -07:00
30cdc61ae0 Implements shared auth token reuse. 2026-08-23 23:10:00 -07:00
a4838bf8d9 Implements progress bars in gui app. 2026-08-23 23:08:01 -07:00
23 changed files with 2504 additions and 366 deletions

47
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,47 @@
name: CI
on:
push:
branches: [ "main", "phase-*" ]
pull_request:
branches: [ "main" ]
jobs:
test:
name: Rust & Workspace Tests (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: Setup Rust cache
uses: Swatinem/rust-cache@v2
- name: Install Linux dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install GUI frontend dependencies
working-directory: gui
run: npm ci
- name: Build GUI frontend
working-directory: gui
run: npm run build
- name: Run Workspace Tests
run: cargo test --workspace --verbose

168
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,168 @@
name: Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
permissions:
contents: write
jobs:
build-cli:
name: Build CLI (${{ matrix.target }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: macos-latest
target: aarch64-apple-darwin
binary_name: nut
artifact_name: nut-aarch64-apple-darwin.tar.gz
- os: macos-latest
target: x86_64-apple-darwin
binary_name: nut
artifact_name: nut-x86_64-apple-darwin.tar.gz
- os: ubuntu-latest
target: x86_64-unknown-linux-musl
binary_name: nut
artifact_name: nut-x86_64-unknown-linux-musl.tar.gz
use_cross: true
- os: ubuntu-latest
target: aarch64-unknown-linux-musl
binary_name: nut
artifact_name: nut-aarch64-unknown-linux-musl.tar.gz
use_cross: true
- os: windows-latest
target: x86_64-pc-windows-msvc
binary_name: nut.exe
artifact_name: nut-x86_64-pc-windows-msvc.zip
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Setup Rust cache
uses: Swatinem/rust-cache@v2
- name: Install cross
if: matrix.use_cross
run: cargo install cross --git https://github.com/cross-rs/cross
- name: Build static CLI binary (with cross)
if: matrix.use_cross
run: cross build --release --target ${{ matrix.target }} -p nut
- name: Build CLI binary (standard)
if: "!matrix.use_cross"
run: cargo build --release --target ${{ matrix.target }} -p nut
- name: Package Unix binary
if: runner.os != 'Windows'
run: |
mkdir -p staging
cp target/${{ matrix.target }}/release/${{ matrix.binary_name }} staging/
cp README.md LICENSE staging/
tar -czf ${{ matrix.artifact_name }} -C staging .
- name: Package Windows binary
if: runner.os == 'Windows'
shell: pwsh
run: |
New-Item -ItemType Directory -Force -Path staging
Copy-Item target/${{ matrix.target }}/release/${{ matrix.binary_name }} staging/
Copy-Item README.md, LICENSE staging/
Compress-Archive -Path staging/* -DestinationPath ${{ matrix.artifact_name }}
- name: Upload CLI Artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact_name }}
path: ${{ matrix.artifact_name }}
build-gui:
name: Build GUI (${{ matrix.platform }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- platform: macOS
os: macos-latest
- platform: Linux
os: ubuntu-latest
- platform: Windows
os: windows-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: Setup Rust cache
uses: Swatinem/rust-cache@v2
- name: Install Linux dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev rpm
- name: Install frontend dependencies
working-directory: gui
run: npm ci
- name: Build Tauri GUI
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
projectPath: "./gui"
tagName: ${{ github.ref_name }}
releaseName: "Nextcloud Upload Tool ${{ github.ref_name }}"
releaseBody: "See release notes for changes."
releaseDraft: true
prerelease: false
publish-release:
name: Publish Release & Checksums
needs: [build-cli, build-gui]
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Download all CLI artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Generate Checksums
run: |
cd artifacts
find . -type f -name "*.tar.gz" -o -name "*.zip" -exec sha256sum {} + > SHA256SUMS
cat SHA256SUMS
- name: Upload Checksums to GitHub Release
if: startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@v2
with:
files: |
artifacts/**/*
artifacts/SHA256SUMS
draft: false
prerelease: false

2
.gitignore vendored
View File

@@ -2,6 +2,7 @@
/target/ /target/
**/target/ **/target/
**/*.rs.bk **/*.rs.bk
dist-packages/
# Cargo local configs # Cargo local configs
.cargo/config.toml.local .cargo/config.toml.local
@@ -40,3 +41,4 @@ Thumbs.db
.env .env
.env.* .env.*
*.local *.local
.worktrees

10
Cargo.lock generated
View File

@@ -558,6 +558,15 @@ dependencies = [
"strsim", "strsim",
] ]
[[package]]
name = "clap_complete"
version = "4.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3be2ad0423bdbbb0e25bc89add796f3559706d4a95e1bc98e4d9662a957b6a19"
dependencies = [
"clap",
]
[[package]] [[package]]
name = "clap_derive" name = "clap_derive"
version = "4.6.4" version = "4.6.4"
@@ -2492,6 +2501,7 @@ name = "nut"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"clap", "clap",
"clap_complete",
"indicatif", "indicatif",
"nextcloud_client", "nextcloud_client",
"open", "open",

25
Project-Description.json Normal file
View File

@@ -0,0 +1,25 @@
{
"nextcloud-upload-tool": {
"id": "nextcloud-upload-tool",
"title": "Nextcloud Upload Tool",
"description": "Cross-platform CLI and desktop GUI for uploading files to Nextcloud and generating direct-download or public share links.",
"docId": "Nextcloud-Upload-Tool",
"image": {
"url": "PROMPT: Clean product screenshot-style illustration of a cross-platform Nextcloud file upload tool, showing a modern desktop upload queue with progress bars, a terminal window with a share URL, subtle Nextcloud-inspired blue accents, crisp technical portfolio asset, no readable text, wide landscape composition",
"alt": "Nextcloud Upload Tool desktop GUI and CLI",
"prompt": "Clean product screenshot-style illustration of a cross-platform Nextcloud file upload tool, showing a modern desktop upload queue with progress bars, a terminal window with a share URL, subtle Nextcloud-inspired blue accents, crisp technical portfolio asset, no readable text, wide landscape composition",
"width": 1600,
"height": 900,
"backgroundClassName": "bg-surface"
},
"url": "/projects/nextcloud-upload-tool",
"tags": ["featured", "open-source"],
"source": {
"type": "github",
"url": "https://github.com/majinnaibu/nextcloud-upload-tool"
},
"feedback": {
"url": "https://github.com/majinnaibu/nextcloud-upload-tool/issues"
}
}
}

16
Project-Description.md Normal file
View File

@@ -0,0 +1,16 @@
# Nextcloud Upload Tool
Nextcloud Upload Tool (`nut`) is a fast, cross-platform command-line tool and desktop GUI for uploading files to Nextcloud and generating direct-download or public share links.
- Built as a Rust workspace with a shared `nextcloud_client` library, CLI, and Tauri desktop GUI.
- Streams uploads over WebDAV with progress reporting, standard-input support, `pv` integration, and Unix pipeline support.
- Uses the Nextcloud OCS Sharing API to create public share links and direct-download URLs, including optional password protection.
- Stores credentials in native operating-system keychains, with an encrypted file fallback.
- Supports multiple Nextcloud accounts and self-hosted instances across the CLI and GUI.
- Provides drag-and-drop uploads, per-file and total progress, retry handling, and browser-based Login Flow v2 in the GUI.
- Includes JSON, TSV, URL-only, and direct-URL-only output modes for automation, plus headless SSH and CI authentication.
- Generates shell completions for Bash, Zsh, Fish, PowerShell, and Elvish.
Repository: https://github.com/majinnaibu/nextcloud-upload-tool
The project is released under the MIT License.

188
README.md
View File

@@ -1,68 +1,172 @@
# Nextcloud Upload Tool (`nut`) # Nextcloud Upload Tool (`nut`)
[![CI](https://github.com/majinnaibu/nextcloud-upload-tool/actions/workflows/ci.yml/badge.svg)](https://github.com/majinnaibu/nextcloud-upload-tool/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
A fast, cross-platform CLI tool and desktop GUI application for uploading files to Nextcloud and instantly generating direct download / public share links. A fast, cross-platform CLI tool and desktop GUI application for uploading files to Nextcloud and instantly generating direct download / public share links.
--- ---
## Overview ## Key Features
**Nextcloud Upload Tool** is designed for quick terminal workflows and smooth desktop integration. It allows you to pipe or upload files directly to your Nextcloud instance, securely manage credentials via your OS keychain, and immediately copy shareable links. - ⚡ **High-Performance Uploads**: WebDAV streaming uploads with real-time progress reporting and pipe support (`stdin`, `pv`, Unix pipelines).
- 🔗 **Instant Share Links**: Direct integration with Nextcloud's OCS Sharing API to generate public share URLs and direct-download links with optional password protection.
### Key Features
- ⚡ **High-Performance Uploads**: WebDAV streaming uploads with real-time progress reporting and pipe support (`stdin`, `pv`).
- 🔗 **Instant Share Links**: Direct integration with Nextcloud's OCS Sharing API to generate public share URLs and direct-download links.
- 🔐 **Secure Credential Storage**: Native OS Keychain integration (macOS Keychain, Windows Credential Manager, Linux Secret Service) with encrypted file fallback. - 🔐 **Secure Credential Storage**: Native OS Keychain integration (macOS Keychain, Windows Credential Manager, Linux Secret Service) with encrypted file fallback.
- 👥 **Multi-Account Support**: Configure and switch between multiple Nextcloud accounts or self-hosted instances seamlessly. - 👥 **Multi-Account Support**: Configure and switch between multiple Nextcloud accounts or self-hosted instances seamlessly across CLI and GUI.
- 🖥️ **CLI & GUI Frontends**: A lightweight terminal binary (`nut`) and a modern desktop application powered by Tauri. - 🖥️ **Modern Desktop GUI**: Drag-and-drop queue management, per-file & total progress bars, retry handling, and browser-based Login Flow v2.
- 🤖 **Automation Ready**: Output formatting options (`--json`, `--tsv`, `--url-only`, `--direct-url-only`) and headless SSH / CI authentication.
- 🐚 **Shell Completions**: First-class completion scripts for `bash`, `zsh`, `fish`, `powershell`, and `elvish`.
---
## Documentation
- 📖 **[CLI Reference](docs/cli-reference.md)**: Full syntax, subcommands (`login`, `upload`, `accounts`, `completions`), and options reference.
- 💡 **[Automation & Scripting Recipes](docs/automation-recipes.md)**: Real-world examples for pipelines (`stdin`, `pv`, `mysqldump`, `curl`), `jq` parsing, and CI/CD workflows.
- 📦 **[Installation Guide](docs/installation.md)**: Pre-built binaries, packages (`.dmg`, `.deb`, `.rpm`, `.msi`, `.AppImage`), and source compilation instructions.
- 🖥️ **[GUI User Guide](docs/gui-guide.md)**: Walkthrough of the desktop app, drag-and-drop queue, retry mechanics, and account management.
- 👥 **[Multi-Account Guide](docs/multi-account.md)**: Managing multiple servers, switching active defaults, and CLI/GUI token sharing.
- 🏗️ **[Architecture & Developer Guide](docs/architecture.md)**: Workspace layout, WebDAV/OCS protocol implementation, and Tauri IPC bridge.
---
## Build and Install
### Prerequisites
- Rust toolchain
- Node.js 20+ and npm
- macOS Xcode Command Line Tools: `xcode-select --install`
### Build the CLI
Build the optimized release binary:
```bash
cargo build --release -p nut
```
The binary is created at `target/release/nut`. To create a distributable archive, run:
```bash
./scripts/build-cli.sh
```
The archive is written to `dist-packages/`.
To install the CLI locally on macOS or Linux:
```bash
mkdir -p ~/.local/bin
install -m 755 target/release/nut ~/.local/bin/nut
```
Make sure `~/.local/bin` is included in your `PATH`.
### Build the GUI
Build the frontend and package the native desktop application:
```bash
cd gui
npm install
npm run tauri build
```
Alternatively, run the packaging helper from the repository root:
```bash
./scripts/build-gui.sh
```
Installers and application bundles are created in `gui/src-tauri/target/release/bundle/`. On macOS, open the generated `.dmg` and drag **Nextcloud Upload Tool.app** into `/Applications`:
```bash
open gui/src-tauri/target/release/bundle/dmg/*.dmg
```
See the [Installation Guide](docs/installation.md) for platform-specific prerequisites, installers, and source-build details.
---
## Quick Start
### 1. CLI Usage
#### Connect your Nextcloud account:
```bash
# Interactive Login Flow v2 (opens browser):
nut login https://cloud.example.com --label "Work"
# Headless SSH authorization (prints URL in terminal):
nut login https://cloud.example.com --no-browser --label "Server"
# Or connect interactively via terminal prompt:
nut login https://cloud.example.com --manual
# Or non-interactive / CI automated script:
nut login https://cloud.example.com -u alice -p "app-password"
```
#### Upload files and generate share links:
```bash
# Upload a single file and generate a public share link:
nut upload -s document.pdf
# Upload multiple files into a remote folder:
nut upload -s -d "Projects/2026" file1.png file2.png
# Upload recursively:
nut upload -s -r assets/
# Stream from standard input (e.g. backup pipes):
tar -czf - data/ | nut upload -s --stdin --filename "backup.tar.gz"
# Output only the share link (ideal for scripts and clipboard pipes):
nut upload -s --url-only report.pdf | pbcopy
```
#### Shell Completions:
```bash
# Generate shell completions (e.g. Zsh)
nut completions zsh > ~/.zfunc/_nut
```
#### Manage multiple accounts:
```bash
# List all accounts:
nut accounts
# Switch default active account:
nut account default "Work"
# Upload to a specific account:
nut upload --account "Personal" photo.jpg
```
---
### 2. GUI Usage
Launch the desktop app via `nut-gui` or from your system applications menu. Drag and drop any files into the window, configure your remote destination folder, toggle public share link creation, and click **Upload All**.
--- ---
## Project Structure ## Project Structure
This repository is structured as a Cargo workspace:
``` ```
. .
├── nextcloud_client/ # Core Rust backend library (WebDAV, OCS API, Keyring, Config) ├── nextcloud_client/ # Core Rust backend library (WebDAV, OCS API, Keyring, Config)
├── cli/ # Terminal CLI executable (`nut`) ├── cli/ # Terminal CLI executable (`nut`)
├── gui/ # Desktop GUI application (Tauri + Web frontend) ├── gui/ # Desktop GUI application (Tauri v2 + React frontend)
├── docs/ # Detailed guides and developer documentation
├── scripts/ # Packaging and build helper scripts
├── Tasks.md # Canonical project roadmap and task tracking ├── Tasks.md # Canonical project roadmap and task tracking
└── README.md └── README.md
``` ```
--- ---
## Getting Started
### Prerequisites
- [Rust](https://www.rust-lang.org/) (1.75+ recommended)
- A running [Nextcloud](https://nextcloud.com/) instance with WebDAV and sharing enabled
### Building the Workspace
Clone the repository and build the workspace crates:
```bash
# Build all workspace members
cargo build
# Run the CLI tool
cargo run -p nut -- --help
# Run tests
cargo test --workspace
```
---
## Roadmap & Tasks
Project tasks, feature specifications, and current progress are tracked in [Tasks.md](Tasks.md).
---
## License ## License
This project is licensed under the [MIT License](LICENSE). This project is licensed under the [MIT License](LICENSE).

112
Tasks.md
View File

@@ -66,19 +66,11 @@ This document defines the complete project roadmap and task tracking system for
--- ---
<a id="tasks-summary"></a> <a id="tasks-summary"></a>
## Tasks Summary (Rendered from task details) ## Tasks Summary (Rendered from task-details)
| ID | Title | Status | Type | | ID | Title | Status | Type |
|---|---|---|---| |---|---|---|---|
| [NUT-014](#nut-014) | Implement GUI Credential Management UI | Triage | Feature |
| [NUT-015](#nut-015) | Implement GUI Upload Progress Bars | Triage | Feature |
| [NUT-016](#nut-016) | Implement Shared Auth Token Reuse | Triage | Integration |
| [NUT-017](#nut-017) | Implement Multi-Account Switching (GUI + CLI) | Triage | Integration |
| [NUT-018](#nut-018) | Implement Packaging for macOS, Windows, Linux | Triage | Chore |
| [NUT-019](#nut-019) | Implement Homebrew/Winget/Chocolatey Manifests | Triage | Chore | | [NUT-019](#nut-019) | Implement Homebrew/Winget/Chocolatey Manifests | Triage | Chore |
| [NUT-020](#nut-020) | Write Documentation + Examples | Triage | Chore |
| [NUT-022](#nut-022) | Implement CLI Shell Completions Generation | Triage | Feature |
| [NUT-023](#nut-023) | Write Comprehensive CLI Documentation and Automation Guides | Triage | Chore |
| [NUT-024](#nut-024) | Create Docker/Podman Nextcloud Integration Test Harness | Triage | Foundation | | [NUT-024](#nut-024) | Create Docker/Podman Nextcloud Integration Test Harness | Triage | Foundation |
| [NUT-001](#nut-001) | Establish Repository Structure | Fixed | Foundation | | [NUT-001](#nut-001) | Establish Repository Structure | Fixed | Foundation |
| [NUT-002](#nut-002) | Implement Shared Rust Backend Library | Fixed | Foundation | | [NUT-002](#nut-002) | Implement Shared Rust Backend Library | Fixed | Foundation |
@@ -93,7 +85,15 @@ This document defines the complete project roadmap and task tracking system for
| [NUT-011](#nut-011) | Implement CLI Progress Reporting + pv Support | Fixed | Feature | | [NUT-011](#nut-011) | Implement CLI Progress Reporting + pv Support | Fixed | Feature |
| [NUT-012](#nut-012) | Implement GUI (Tauri) Frontend | Fixed | Feature | | [NUT-012](#nut-012) | Implement GUI (Tauri) Frontend | Fixed | Feature |
| [NUT-013](#nut-013) | Implement GUI File Queue + Drag-and-Drop | Fixed | Feature | | [NUT-013](#nut-013) | Implement GUI File Queue + Drag-and-Drop | Fixed | Feature |
| [NUT-014](#nut-014) | Implement GUI Credential Management UI | Fixed | Feature |
| [NUT-015](#nut-015) | Implement GUI Upload Progress Bars | Fixed | Feature |
| [NUT-016](#nut-016) | Implement Shared Auth Token Reuse | Fixed | Integration |
| [NUT-017](#nut-017) | Implement Multi-Account Switching (GUI + CLI) | Fixed | Integration |
| [NUT-018](#nut-018) | Implement Packaging for macOS, Windows, Linux | Fixed | Chore |
| [NUT-020](#nut-020) | Write Documentation + Examples | Fixed | Chore |
| [NUT-021](#nut-021) | Support Headless & SSH Remote Authentication Modes | Fixed | Feature | | [NUT-021](#nut-021) | Support Headless & SSH Remote Authentication Modes | Fixed | Feature |
| [NUT-022](#nut-022) | Implement CLI Shell Completions Generation | Fixed | Feature |
| [NUT-023](#nut-023) | Write Comprehensive CLI Documentation and Automation Guides | Fixed | Chore |
--- ---
@@ -361,40 +361,40 @@ Add drag-and-drop file support and a queue system for multiple uploads.
- NUT-012 - NUT-012
<a id="nut-014" class="task" data-status="triage" data-task-type="feature"></a> <a id="nut-014" class="task" data-status="done" data-task-type="feature"></a>
### Implement GUI Credential Management UI ### Implement GUI Credential Management UI
**ID:** NUT-014 **ID:** NUT-014
**Status:** Triage **Status:** Fixed
**Type:** Feature **Type:** Feature
**Description:** **Description:**
Add UI for managing accounts, logging in (including one-click browser authorization via Login Flow v2), logging out, and switching accounts. Add UI for managing accounts, logging in (including one-click browser authorization via Login Flow v2), logging out, and switching accounts.
**Requirements:** **Requirements:**
- [ ] Account list UI - [x] Account list UI
- [ ] Browser-based login button (Login Flow v2) - [x] Browser-based login button (Login Flow v2)
- [ ] Manual login form (server/user/token) - [x] Manual login form (server/user/token)
- [ ] Logout button - [x] Logout button
- [ ] Switch account dropdown - [x] Switch account dropdown
**Dependencies:** **Dependencies:**
- NUT-007 - NUT-007
- NUT-012 - NUT-012
<a id="nut-015" class="task" data-status="triage" data-task-type="feature"></a> <a id="nut-015" class="task" data-status="done" data-task-type="feature"></a>
### Implement GUI Upload Progress Bars ### Implement GUI Upload Progress Bars
**ID:** NUT-015 **ID:** NUT-015
**Status:** Triage **Status:** Fixed
**Type:** Feature **Type:** Feature
**Description:** **Description:**
Add per-file and total progress bars to the GUI. Add per-file and total progress bars to the GUI.
**Requirements:** **Requirements:**
- [ ] Per-file progress - [x] Per-file progress
- [ ] Total progress - [x] Total progress
- [ ] Error display - [x] Error display
**Dependencies:** **Dependencies:**
- NUT-003 - NUT-003
@@ -402,19 +402,19 @@ Add per-file and total progress bars to the GUI.
- NUT-013 - NUT-013
<a id="nut-016" class="task" data-status="triage" data-task-type="integration"></a> <a id="nut-016" class="task" data-status="done" data-task-type="integration"></a>
### Implement Shared Auth Token Reuse ### Implement Shared Auth Token Reuse
**ID:** NUT-016 **ID:** NUT-016
**Status:** Triage **Status:** Fixed
**Type:** Integration **Type:** Integration
**Description:** **Description:**
Ensure both CLI and GUI reuse the same credential store and cached tokens. Ensure both CLI and GUI reuse the same credential store and cached tokens.
**Requirements:** **Requirements:**
- [ ] Shared credential backend - [x] Shared credential backend
- [ ] Shared token cache - [x] Shared token cache
- [ ] Unified config format - [x] Unified config format
**Dependencies:** **Dependencies:**
- NUT-006 - NUT-006
@@ -422,19 +422,19 @@ Ensure both CLI and GUI reuse the same credential store and cached tokens.
- NUT-012 - NUT-012
<a id="nut-017" class="task" data-status="triage" data-task-type="integration"></a> <a id="nut-017" class="task" data-status="done" data-task-type="integration"></a>
### Implement Multi-Account Switching (GUI + CLI) ### Implement Multi-Account Switching (GUI + CLI)
**ID:** NUT-017 **ID:** NUT-017
**Status:** Triage **Status:** Fixed
**Type:** Integration **Type:** Integration
**Description:** **Description:**
Add multi-account switching to both CLI and GUI. Add multi-account switching to both CLI and GUI.
**Requirements:** **Requirements:**
- [ ] CLI `--account` flag - [x] CLI `--account` flag
- [ ] GUI dropdown - [x] GUI dropdown
- [ ] Shared backend logic - [x] Shared backend logic
**Dependencies:** **Dependencies:**
- NUT-007 - NUT-007
@@ -442,20 +442,20 @@ Add multi-account switching to both CLI and GUI.
- NUT-014 - NUT-014
<a id="nut-018" class="task" data-status="triage" data-task-type="chore"></a> <a id="nut-018" class="task" data-status="done" data-task-type="chore"></a>
### Implement Packaging for macOS, Windows, Linux ### Implement Packaging for macOS, Windows, Linux
**ID:** NUT-018 **ID:** NUT-018
**Status:** Triage **Status:** Fixed
**Type:** Chore **Type:** Chore
**Description:** **Description:**
Package the CLI and GUI for distribution. Package the CLI and GUI for distribution across macOS, Windows, and Linux.
**Requirements:** **Requirements:**
- [ ] macOS `.app` + `.dmg` - [x] macOS `.app` + `.dmg`
- [ ] Windows `.exe` + installer - [x] Windows `.exe` + installer
- [ ] Linux `.deb` + `.rpm` - [x] Linux `.deb` + `.rpm`
- [ ] Static CLI binaries - [x] Static CLI binaries
**Dependencies:** **Dependencies:**
- NUT-008 - NUT-008
@@ -481,20 +481,20 @@ Add package manager manifests for easy installation.
- NUT-018 - NUT-018
<a id="nut-020" class="task" data-status="triage" data-task-type="chore"></a> <a id="nut-020" class="task" data-status="done" data-task-type="chore"></a>
### Write Documentation + Examples ### Write Documentation + Examples
**ID:** NUT-020 **ID:** NUT-020
**Status:** Triage **Status:** Fixed
**Type:** Chore **Type:** Chore
**Description:** **Description:**
Write comprehensive project documentation covering installation, GUI usage, multi-account setup, and overall project architecture. Write comprehensive project documentation covering installation, GUI usage, multi-account setup, and overall project architecture.
**Requirements:** **Requirements:**
- [ ] GUI overview and visual walkthrough - [x] GUI overview and visual walkthrough
- [ ] Cross-platform installation instructions - [x] Cross-platform installation instructions
- [ ] Multi-account management guide - [x] Multi-account management guide
- [ ] Architecture and developer setup documentation - [x] Architecture and developer setup documentation
**Dependencies:** **Dependencies:**
- NUT-012 - NUT-012
@@ -522,39 +522,39 @@ Ensure smooth authentication experiences when running the CLI over SSH or in hea
- NUT-008 - NUT-008
<a id="nut-022" class="task" data-status="triage" data-task-type="feature"></a> <a id="nut-022" class="task" data-status="done" data-task-type="feature"></a>
### Implement CLI Shell Completions Generation ### Implement CLI Shell Completions Generation
**ID:** NUT-022 **ID:** NUT-022
**Status:** Triage **Status:** Fixed
**Type:** Feature **Type:** Feature
**Description:** **Description:**
Add automated shell completion script generation using `clap_complete` for major shells (`bash`, `zsh`, `fish`, `powershell`, `elvish`). Add automated shell completion script generation using `clap_complete` for major shells (`bash`, `zsh`, `fish`, `powershell`, `elvish`).
**Requirements:** **Requirements:**
- [ ] Add `clap_complete` crate dependency - [x] Add `clap_complete` crate dependency
- [ ] Implement `nut completions <SHELL>` subcommand - [x] Implement `nut completions <SHELL>` subcommand
- [ ] Support `bash`, `zsh`, `fish`, `powershell`, and `elvish` output to stdout - [x] Support `bash`, `zsh`, `fish`, `powershell`, and `elvish` output to stdout
- [ ] Include quick installation instructions in command help - [x] Include quick installation instructions in command help
**Dependencies:** **Dependencies:**
- NUT-008 - NUT-008
<a id="nut-023" class="task" data-status="triage" data-task-type="chore"></a> <a id="nut-023" class="task" data-status="done" data-task-type="chore"></a>
### Write Comprehensive CLI Documentation and Automation Guides ### Write Comprehensive CLI Documentation and Automation Guides
**ID:** NUT-023 **ID:** NUT-023
**Status:** Triage **Status:** Fixed
**Type:** Chore **Type:** Chore
**Description:** **Description:**
Write dedicated CLI reference documentation and practical automation guides for scripting, CI/CD, and Unix pipeline workflows. Write dedicated CLI reference documentation and practical automation guides for scripting, CI/CD, and Unix pipeline workflows.
**Requirements:** **Requirements:**
- [ ] Document all CLI subcommands (`login`, `upload`, `accounts`, `completions`) and flags in `README.md` - [x] Document all CLI subcommands (`login`, `upload`, `accounts`, `completions`) and flags in `README.md`
- [ ] Provide practical recipes for piping data (`stdin`, `pv`, `curl`, `mysqldump`) - [x] Provide practical recipes for piping data (`stdin`, `pv`, `curl`, `mysqldump`)
- [ ] Provide scripting examples parsing `--json`, `--tsv`, `--url-only`, and `--direct-url-only` with `jq` and `xargs` - [x] Provide scripting examples parsing `--json`, `--tsv`, `--url-only`, and `--direct-url-only` with `jq` and `xargs`
- [ ] Document headless SSH and CI/CD automated provisioning with `--username` and `--app-password` - [x] Document headless SSH and CI/CD automated provisioning with `--username` and `--app-password`
**Dependencies:** **Dependencies:**
- NUT-008 - NUT-008

View File

@@ -6,6 +6,7 @@ edition = "2024"
[dependencies] [dependencies]
nextcloud_client = { path = "../nextcloud_client" } nextcloud_client = { path = "../nextcloud_client" }
clap = { version = "4.5", features = ["derive", "cargo"] } clap = { version = "4.5", features = ["derive", "cargo"] }
clap_complete = "4.5"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "default-tls"] } reqwest = { version = "0.12", default-features = false, features = ["json", "default-tls"] }
open = "5.3" open = "5.3"

View File

@@ -1,4 +1,5 @@
use clap::{Args, Parser, Subcommand}; use clap::{Args, CommandFactory, Parser, Subcommand};
use clap_complete::{generate, Shell};
use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle}; use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle};
use serde::Serialize; use serde::Serialize;
use std::fs; use std::fs;
@@ -12,7 +13,7 @@ use nextcloud_client::{
ProgressCallback, ProgressEvent, Result, UploadOptions, ProgressCallback, ProgressEvent, Result, UploadOptions,
}; };
#[derive(Parser, Debug)] #[derive(Parser, Debug, PartialEq)]
#[command( #[command(
name = "nut", name = "nut",
author = "Tom Hicks <headhunter3@gmail.com>", author = "Tom Hicks <headhunter3@gmail.com>",
@@ -25,7 +26,7 @@ struct Cli {
command: Commands, command: Commands,
} }
#[derive(Subcommand, Debug)] #[derive(Subcommand, Debug, PartialEq)]
enum Commands { enum Commands {
/// Log in to a Nextcloud server using browser authorization (Login Flow v2) or credentials /// Log in to a Nextcloud server using browser authorization (Login Flow v2) or credentials
Login(LoginArgs), Login(LoginArgs),
@@ -39,9 +40,16 @@ enum Commands {
/// Quick alias to list all configured accounts /// Quick alias to list all configured accounts
Accounts(AccountListArgs), Accounts(AccountListArgs),
/// Generate shell completion scripts for bash, zsh, fish, powershell, or elvish
Completions {
/// Target shell to generate completions for
#[arg(value_enum)]
shell: Shell,
},
} }
#[derive(Args, Debug)] #[derive(Args, Debug, PartialEq)]
struct LoginArgs { struct LoginArgs {
/// Base URL of the Nextcloud instance (e.g. https://cloud.example.com) /// Base URL of the Nextcloud instance (e.g. https://cloud.example.com)
server_url: String, server_url: String,
@@ -71,7 +79,7 @@ struct LoginArgs {
app_password: Option<String>, app_password: Option<String>,
} }
#[derive(Args, Debug, Clone, Default)] #[derive(Args, Debug, Clone, Default, PartialEq)]
struct OutputFormatArgs { struct OutputFormatArgs {
/// Format output as JSON /// Format output as JSON
#[arg(long, conflicts_with_all = ["tsv", "url_only", "direct_url_only"])] #[arg(long, conflicts_with_all = ["tsv", "url_only", "direct_url_only"])]
@@ -100,7 +108,7 @@ impl OutputFormatArgs {
} }
} }
#[derive(Args, Debug)] #[derive(Args, Debug, PartialEq)]
struct UploadArgs { struct UploadArgs {
/// Path to local file(s) or directories to upload /// Path to local file(s) or directories to upload
#[arg(value_name = "FILE")] #[arg(value_name = "FILE")]
@@ -150,7 +158,7 @@ struct UploadArgs {
format: OutputFormatArgs, format: OutputFormatArgs,
} }
#[derive(Subcommand, Debug)] #[derive(Subcommand, Debug, PartialEq)]
enum AccountCommands { enum AccountCommands {
/// List all configured Nextcloud accounts /// List all configured Nextcloud accounts
List(AccountListArgs), List(AccountListArgs),
@@ -168,7 +176,7 @@ enum AccountCommands {
}, },
} }
#[derive(Args, Debug, Clone, Default)] #[derive(Args, Debug, Clone, Default, PartialEq)]
struct AccountListArgs { struct AccountListArgs {
/// Format output as JSON /// Format output as JSON
#[arg(long, conflicts_with = "tsv")] #[arg(long, conflicts_with = "tsv")]
@@ -207,6 +215,10 @@ async fn main() {
handle_account_set_default(&account) handle_account_set_default(&account)
} }
Commands::Account(AccountCommands::Delete { account }) => handle_account_delete(&account), Commands::Account(AccountCommands::Delete { account }) => handle_account_delete(&account),
Commands::Completions { shell } => {
handle_completions(shell);
Ok(())
}
}; };
if let Err(err) = result { if let Err(err) = result {
@@ -215,6 +227,12 @@ async fn main() {
} }
} }
/// Generate shell completion scripts for supported shells.
fn handle_completions(shell: Shell) {
let mut cmd = Cli::command();
generate(shell, &mut cmd, "nut", &mut io::stdout());
}
/// Handle interactive or non-interactive login flows. /// Handle interactive or non-interactive login flows.
async fn handle_login(args: LoginArgs) -> Result<()> { async fn handle_login(args: LoginArgs) -> Result<()> {
let server_url = ClientConfig::normalize_url(&args.server_url)?; let server_url = ClientConfig::normalize_url(&args.server_url)?;
@@ -771,3 +789,86 @@ fn handle_account_delete(account: &str) -> Result<()> {
println!("\x1b[1;32m✓\x1b[0m Account '\x1b[1m{}\x1b[0m' deleted.", account); println!("\x1b[1;32m✓\x1b[0m Account '\x1b[1m{}\x1b[0m' deleted.", account);
Ok(()) Ok(())
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cli_account_flag_parsing() {
let parsed = Cli::try_parse_from(&[
"nut",
"upload",
"--account",
"alice@cloud.example.com",
"document.pdf",
])
.unwrap();
match parsed.command {
Commands::Upload(args) => {
assert_eq!(args.account.as_deref(), Some("alice@cloud.example.com"));
assert_eq!(args.files, vec![PathBuf::from("document.pdf")]);
}
_ => panic!("Expected Upload command"),
}
}
#[test]
fn test_cli_account_subcommands_parsing() {
let parsed = Cli::try_parse_from(&["nut", "account", "default", "Work"]).unwrap();
match parsed.command {
Commands::Account(AccountCommands::Default { account }) => {
assert_eq!(account, "Work");
}
_ => panic!("Expected Account Default command"),
}
let parsed_del = Cli::try_parse_from(&["nut", "account", "delete", "bob@cloud.com"]).unwrap();
match parsed_del.command {
Commands::Account(AccountCommands::Delete { account }) => {
assert_eq!(account, "bob@cloud.com");
}
_ => panic!("Expected Account Delete command"),
}
let parsed_list = Cli::try_parse_from(&["nut", "accounts", "--json"]).unwrap();
match parsed_list.command {
Commands::Accounts(args) => {
assert!(args.json);
}
_ => panic!("Expected Accounts list command"),
}
}
#[test]
fn test_cli_completions_parsing() {
let shells = [
("bash", Shell::Bash),
("zsh", Shell::Zsh),
("fish", Shell::Fish),
("powershell", Shell::PowerShell),
("elvish", Shell::Elvish),
];
for (name, expected_shell) in shells {
let parsed = Cli::try_parse_from(&["nut", "completions", name]).unwrap();
match parsed.command {
Commands::Completions { shell } => {
assert_eq!(shell, expected_shell);
}
_ => panic!("Expected Completions command for shell {name}"),
}
let mut buf = Vec::new();
let mut cmd = Cli::command();
generate(expected_shell, &mut cmd, "nut", &mut buf);
assert!(!buf.is_empty(), "Generated completion for {name} should not be empty");
let output_str = String::from_utf8(buf).expect("Completions should be valid UTF-8");
assert!(
output_str.contains("nut"),
"Completion output should contain 'nut'"
);
}
}
}

92
docs/architecture.md Normal file
View File

@@ -0,0 +1,92 @@
# Project Architecture & Developer Setup
This document describes the architectural layout, internal protocols, security model, and developer setup for the **Nextcloud Upload Tool**.
---
## 1. Workspace Layout
The repository is organized as a Cargo workspace with three primary crates:
```
nextcloud-upload-tool/
├── nextcloud_client/ # Shared Rust core library
│ ├── src/
│ │ ├── auth.rs # Nextcloud Login Flow v2 client & polling
│ │ ├── client.rs # NextcloudClient WebDAV client & status checks
│ │ ├── config.rs # ClientConfig and URL normalization
│ │ ├── credentials.rs # CredentialStore (OS Keychain + accounts.json)
│ │ ├── download.rs # Direct-download URL generator
│ │ ├── progress.rs # Async ProgressStream & progress callbacks
│ │ └── sharing.rs # OCS Sharing API (public share link creation)
├── cli/ # CLI binary (`nut`)
│ └── src/
│ └── main.rs # Clap CLI parser, upload handlers, terminal output
├── gui/ # Desktop application (Tauri v2 + React)
│ ├── src/ # React + TypeScript frontend
│ │ ├── App.tsx # UI tabs, drag-and-drop, state management
│ │ └── App.css # Styling and responsive layout
│ └── src-tauri/ # Tauri Rust backend
│ ├── src/commands.rs # IPC command handlers forwarding to nextcloud_client
│ └── tauri.conf.json # Multi-platform bundle configuration
└── docs/ # Documentation & user guides
```
---
## 2. Shared Core Architecture (`nextcloud_client`)
Both the CLI and GUI frontends share the `nextcloud_client` crate to ensure consistent behavior:
- **WebDAV Upload Pipeline**:
- Implements HTTP PUT requests against Nextcloud's WebDAV endpoint (`/remote.php/dav/files/<user>/<path>`).
- Employs streaming request bodies wrapped in a custom `ProgressStream` to capture transferred byte counts in real time without buffering large files in RAM.
- Automatically handles intermediate remote directory resolution.
- **OCS Sharing API**:
- Interacts with `/ocs/v2.php/apps/files_sharing/api/v1/shares` using OCS headers (`OCS-APIRequest: true`).
- Creates public read-only shares (`shareType=3`, `permissions=1`) with optional password protection.
- Generates canonical public share links (`/index.php/s/<token>`) and direct download links (`/index.php/s/<token>/download`).
- **Unified Credential Storage**:
- **Keyring Service**: Keyring service identifier `me.majinnaibu.nut`.
- **Account Metadata**: Stored in `~/.config/nut/accounts.json` containing account IDs, server URLs, usernames, labels, and default account flags.
- **Secrets**: App passwords and tokens are stored in the OS native keychain (Apple Keychain on macOS, Windows Credential Manager on Windows, and Secret Service on Linux).
---
## 3. GUI Architecture & IPC Bridge
The GUI is built with **Tauri v2** using a lightweight React frontend:
- **Tauri Commands**: Defined in `gui/src-tauri/src/commands.rs` exposing async Rust functions (`upload_file`, `get_accounts`, `login_v2_start`, `login_v2_poll`, `set_default_account`, etc.) to TypeScript.
- **Event Streaming**: As WebDAV uploads progress, the Rust backend emits `upload-progress` events to the Tauri webview containing `file_id`, `bytes_transferred`, `total_bytes`, and `percent`.
- **Drag-and-Drop Integration**: The native OS drag-and-drop listener intercepts dropped files and invokes `get_file_info` to populate the upload queue.
---
## 4. Developer Setup & Testing
### Running Tests
```bash
# Run all unit and integration tests across the workspace
cargo test --workspace
# Run CLI tests only
cargo test -p nut
# Run core library tests only
cargo test -p nextcloud_client
```
### Running GUI in Development Mode
```bash
# In terminal 1: start Vite dev server
cd gui
npm install
npm run dev
# In terminal 2: run Tauri dev desktop app
cargo run -p gui
# Or use Tauri CLI
npm run tauri dev
```

131
docs/automation-recipes.md Normal file
View File

@@ -0,0 +1,131 @@
# CLI Automation & Scripting Recipes
This guide contains practical recipes for integrating `nut` into bash scripts, Unix pipelines, CI/CD runners, and headless server environments.
---
## 1. Piping Streams & Backups
### Piping Database Backups Directly to Nextcloud
Stream database dumps straight to Nextcloud without creating intermediate local files:
```bash
# MySQL / MariaDB Dump
mysqldump -u root -p mydb | gzip -9 | \
nut upload -d "Backups/MySQL" --stdin --filename "mydb-$(date +%Y%m%d).sql.gz"
# PostgreSQL Dump
pg_dump mydb | zstd | \
nut upload -d "Backups/Postgres" --stdin --filename "mydb-$(date +%Y%m%d).sql.zst"
```
### Piping Compressed Archives with `pv` Rate Metering
```bash
# Compress and stream with live throughput monitoring
tar -czf - /var/log/nginx | pv | \
nut upload -d "Backups/Logs" --stdin --filename "nginx-logs-$(date +%F).tar.gz"
```
### Piping from `curl` / `wget`
Download a remote file and forward it to Nextcloud in one pipeline:
```bash
curl -fsSL https://example.com/large-dataset.csv.gz | \
nut upload -s -d "Datasets" --stdin --filename "large-dataset.csv.gz"
```
---
## 2. Scripting with `jq`, `xargs`, and Clipboard
### Uploading and Instant Clipboard Copy (macOS, Linux, Windows)
```bash
# macOS
nut upload -s --url-only screenshot.png | pbcopy
# Linux (X11 / Wayland)
nut upload -s --url-only screenshot.png | xclip -selection clipboard
# or wl-copy
nut upload -s --url-only screenshot.png | wl-copy
# Windows PowerShell
nut upload -s --url-only screenshot.png | Set-Clipboard
```
### Batch Processing with `xargs`
Upload all modified markdown files found with `find`:
```bash
find ./notes -name "*.md" -mtime -1 -print0 | \
xargs -0 nut upload -s -d "Notes/Daily" --json
```
### Extracting Links and Metadata with `jq`
```bash
# Extract all public share URLs into a text list
nut upload -s -r assets/ --json | jq -r '.[].share_url // empty' > links.txt
# Query direct download links
nut upload -s build/*.pkg --json | jq -r '.[] | select(.success == true) | "\(.file): \(.direct_download_url)"'
```
### Scripting with TSV
Parse tabular output in bash loops:
```bash
nut upload -s -r photos/ --tsv | while IFS=$'\t' read -r file remote bytes success share direct; do
if [ "$success" = "true" ]; then
echo "Uploaded $file ($bytes bytes) -> $share"
else
echo "Failed to upload $file"
fi
done
```
---
## 3. CI/CD & Headless Automation
### Non-Interactive CI / GitHub Actions Setup
Authenticate non-interactively using environment secrets in CI pipelines:
```yaml
name: Deploy Build Artifacts
on: [push]
jobs:
upload:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install nut
run: |
curl -fsSL https://github.com/majinnaibu/nextcloud-upload-tool/releases/latest/download/nut-x86_64-unknown-linux-musl.tar.gz | tar -xz
sudo mv nut /usr/local/bin/
- name: Authenticate Nextcloud
env:
NC_SERVER: ${{ secrets.NEXTCLOUD_SERVER_URL }}
NC_USER: ${{ secrets.NEXTCLOUD_USERNAME }}
NC_PASS: ${{ secrets.NEXTCLOUD_APP_PASSWORD }}
run: |
nut login "$NC_SERVER" -u "$NC_USER" -p "$NC_PASS" --label "CI"
- name: Build and Upload Artifact
run: |
tar -czf release-build.tar.gz dist/
SHARE_URL=$(nut upload -s --account "CI" --url-only release-build.tar.gz)
echo "Download link: $SHARE_URL"
```
### Headless SSH Remote Server Authorization
When working over an SSH connection without X11 or desktop forwarding:
```bash
# Run login in headless mode
nut login https://cloud.example.com --no-browser --label "ProductionServer"
# nut prints:
# ==> Please authorize access in your browser:
# https://cloud.example.com/index.php/login/v2/flow/abc123xyz
# ==> Waiting for browser authorization...
# Open that link on your local computer, authorize, and nut immediately captures the token!
```

165
docs/cli-reference.md Normal file
View File

@@ -0,0 +1,165 @@
# Nextcloud Upload Tool (`nut`) — CLI Reference
The `nut` CLI provides a fast, pipe-friendly command-line interface for interacting with Nextcloud instances.
---
## Command Syntax
```bash
nut <COMMAND> [OPTIONS]
```
### Global Flags
- `-h`, `--help`: Print help documentation.
- `-V`, `--version`: Print version information.
---
## 1. `nut login`
Authenticates and saves a Nextcloud instance account into your operating system's native keychain.
```bash
nut login <SERVER_URL> [OPTIONS]
```
### Arguments
- `<SERVER_URL>`: The base URL of the Nextcloud instance (e.g. `https://cloud.example.com` or `http://localhost:8080/nextcloud`).
### Options
- `--label <LABEL>`: Optional friendly name for this account (e.g. `Work`, `Personal`, `Staging`).
- `--default <BOOL>`: Mark this account as the default active account (default: `true`).
- `--no-browser`: Do not attempt to launch a local browser. Prints the Login Flow v2 URL to stdout for copy-pasting (ideal for SSH sessions).
- `-m`, `--manual`: Interactively prompts for username and app password/token via terminal inputs.
- `-u`, `--username <USER>`: Username for non-interactive / automated login (requires `-p` / `--app-password`).
- `-p`, `--app-password <PASS>`: App password or token for non-interactive login (requires `-u` / `--username`).
### Examples
```bash
# Interactive browser authorization (default)
nut login https://cloud.example.com --label "Personal"
# Headless SSH authorization
nut login https://cloud.example.com --no-browser --label "Server-Backup"
# Interactive terminal credential entry
nut login https://cloud.example.com --manual
# Automated non-interactive CI / container provisioning
nut login https://cloud.example.com -u ci-bot -p "xxxx-xxxx-xxxx-xxxx" --label "CI"
```
---
## 2. `nut upload`
Uploads one or more files, folders, or standard input streams to Nextcloud with optional public share generation.
```bash
nut upload [FILE]... [OPTIONS]
```
### Arguments
- `[FILE]...`: One or more local file or directory paths to upload.
### Options
- `-a`, `--account <ACCOUNT>`: Override default account with a specific account ID or label.
- `-d`, `--remote-dir <DIR>`: Destination directory on Nextcloud (default: `Uploads`).
- `-s`, `--share`: Automatically create a public share link after upload.
- `--password <PASSWORD>`: Set a password for the generated public share link.
- `-r`, `--recursive`: Recursively upload directories and nested directory hierarchies.
- `-c`, `--continue-on-error`: Continue processing remaining files if one fails.
- `--no-progress`: Disable progress bars even when running in an interactive TTY.
- `--stdin`: Upload standard input data stream.
- `--filename <NAME>`: Remote filename to use when uploading via `--stdin` (default: `stdin_upload.txt`).
- `--size <BYTES>`: Expected size in bytes for stdin streams (enables ETA and progress bar).
### Formatting Options
- `--json`: Output machine-readable JSON (array for batches, object for single file).
- `--tsv`: Output Tab-Separated Values (file, remote path, bytes, success, share URL, direct URL).
- `--url-only`: Output only the public share URL (requires `--share`).
- `--direct-url-only`: Output only the direct download URL (requires `--share`).
- `-q`, `--quiet`: Suppress progress output and print only share links or uploaded paths.
### Examples
```bash
# Upload a single file with public share link
nut upload -s report.pdf
# Upload multiple files into a remote folder
nut upload -s -d "Documents/2026" sheet1.xlsx sheet2.xlsx
# Recursive directory upload
nut upload -s -r assets/
# Upload standard input stream with custom filename
cat database.sql | nut upload -s --stdin --filename "database.sql"
# Upload and copy public share URL directly to clipboard
nut upload -s --url-only image.png | pbcopy
```
---
## 3. `nut accounts` / `nut account`
Manage stored Nextcloud accounts and configure default active accounts.
### `nut accounts` / `nut account list`
List all stored accounts and their status.
```bash
nut accounts
nut accounts --json
nut accounts --tsv
```
### `nut account default <ACCOUNT>`
Set the active default account by ID or friendly label.
```bash
nut account default Work
nut account default alice@cloud.example.com
```
### `nut account delete <ACCOUNT>`
Remove an account and wipe its token from the OS keychain.
```bash
nut account delete "Personal"
```
---
## 4. `nut completions`
Generates shell completion scripts for your preferred shell environment.
```bash
nut completions <SHELL>
```
### Supported Shells
- `bash`
- `zsh`
- `fish`
- `powershell`
- `elvish`
### Installation Examples
```bash
# Zsh
nut completions zsh > ~/.zfunc/_nut
# Bash
nut completions bash > /etc/bash_completion.d/nut
# Or load in ~/.bashrc:
source <(nut completions bash)
# Fish
nut completions fish > ~/.config/fish/completions/nut.fish
# PowerShell
nut completions powershell >> $PROFILE
# Elvish
nut completions elvish > ~/.elvish/lib/nut.elv
```

51
docs/gui-guide.md Normal file
View File

@@ -0,0 +1,51 @@
# GUI Desktop Application Guide
The **Nextcloud Upload Tool GUI** is a fast desktop application designed for streamlined file queuing, drag-and-drop uploads, public share creation, and multi-account management.
---
## 1. Interface Overview
The interface is structured into three primary sections:
1. **Header Bar**:
- Displays the application branding.
- **Account Switcher**: Quick-switch dropdown to select the target Nextcloud account for uploads.
- **Navigation Tabs**: Toggle between **Upload Queue** and **Accounts & Auth**.
2. **Upload Queue Tab**:
- **Target Settings**: Configure remote directory on Nextcloud (default: `Uploads`), toggle public share generation, and set optional link passwords.
- **Drag & Drop Zone**: Visual target supporting OS file drops or manual system file browser selection (`Browse Files`).
- **Queue List**: Per-file upload queue showing status badges (`queued`, `uploading`, `completed`, `error`), live percentage, byte counters, and action buttons (`▲`, `▼`, `✕`, inline `↻ Retry`).
- **Global Actions**: `Upload All (N)` button and `Clear Completed` / `Clear All`.
3. **Accounts & Auth Tab**:
- **Connected Accounts**: Interactive cards displaying connected usernames, server endpoints, custom labels, and active default status.
- **Browser Login (Login Flow v2)**: One-click interactive authorization opening your web browser with support for 2FA / SSO.
- **Manual App Password Entry**: Form for connecting with custom server URLs, usernames, and generated application tokens.
---
## 2. Step-by-Step Walkthrough
### Connecting Your First Account
1. Open the application and switch to the **Accounts & Auth** tab.
2. Enter your Nextcloud server URL (e.g. `https://cloud.example.com`).
3. Click **Authorize in Browser (Login Flow v2)**.
4. Your default browser opens the authorization page. Click **Grant Access**.
5. The GUI automatically polls and securely stores your token in your operating system's native keychain.
### Uploading Files & Creating Share Links
1. Navigate to the **Upload Queue** tab.
2. Drag and drop one or more files from Finder / Explorer / File Manager into the dropzone.
3. (Optional) Check **Generate public share link** and specify a **Share password** if needed.
4. Set the **Remote Directory** (e.g. `Projects/Assets` or `Uploads`).
5. Click **Upload All**.
6. Real-time progress bars update for both the total batch and each individual file.
7. Once finished:
- Click **Copy Link** to copy the public share URL to clipboard.
- Click **Copy Direct Download** to copy the raw download URL.
### Managing Failed Uploads
- If any file fails (e.g. due to network glitch or server quota), an error badge is displayed with details.
- Click the inline **↻ Retry** button next to that item to re-attempt the upload immediately without resetting the queue.

77
docs/installation.md Normal file
View File

@@ -0,0 +1,77 @@
# Installation Guide
**Nextcloud Upload Tool (`nut`)** is available as a standalone command-line tool and a desktop GUI application across macOS, Windows, and Linux.
---
## 1. Pre-built Binaries & Installers
Download pre-built releases for your operating system from the [Releases](https://github.com/majinnaibu/nextcloud-upload-tool/releases) page.
### macOS (Apple Silicon & Intel)
- **GUI Desktop App**: Download the `.dmg` installer, open it, and drag `Nextcloud Upload Tool.app` into `/Applications`.
- **CLI Binary**:
```bash
# Apple Silicon (M1/M2/M3/M4)
curl -fsSL https://github.com/majinnaibu/nextcloud-upload-tool/releases/latest/download/nut-aarch64-apple-darwin.tar.gz | tar -xz
sudo mv nut /usr/local/bin/
# Intel x86_64
curl -fsSL https://github.com/majinnaibu/nextcloud-upload-tool/releases/latest/download/nut-x86_64-apple-darwin.tar.gz | tar -xz
sudo mv nut /usr/local/bin/
```
### Linux (Debian, Ubuntu, Fedora, Arch, Generic)
- **Debian / Ubuntu (`.deb`)**:
```bash
sudo dpkg -i nextcloud-upload-tool_0.1.0_amd64.deb
sudo apt-get install -f
```
- **Fedora / RHEL (`.rpm`)**:
```bash
sudo rpm -i nextcloud-upload-tool-0.1.0-1.x86_64.rpm
```
- **Universal AppImage**:
```bash
chmod +x Nextcloud_Upload_Tool_0.1.0_amd64.AppImage
./Nextcloud_Upload_Tool_0.1.0_amd64.AppImage
```
- **Static CLI Binary (musl libc - zero external dependencies)**:
```bash
curl -fsSL https://github.com/majinnaibu/nextcloud-upload-tool/releases/latest/download/nut-x86_64-unknown-linux-musl.tar.gz | tar -xz
sudo mv nut /usr/local/bin/
```
### Windows
- **GUI Installer (`.msi` / NSIS `.exe`)**: Download and run `Nextcloud-Upload-Tool-Setup.exe` or `Nextcloud-Upload-Tool.msi`. Follow the installer wizard.
- **Standalone CLI**: Download `nut-x86_64-pc-windows-msvc.zip`, extract `nut.exe`, and add it to your system `%PATH%`.
---
## 2. Building from Source
### Prerequisites
- **Rust toolchain** (1.75+): Install via `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh`
- **Node.js** (v20+) and **npm** (for GUI frontend build)
- **Platform Development Libraries**:
- **Linux**: `sudo apt-get install libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libssl-dev patchelf`
- **macOS**: Xcode Command Line Tools (`xcode-select --install`)
- **Windows**: Visual Studio C++ Build Tools
### Build the CLI (`nut`)
```bash
git clone https://github.com/majinnaibu/nextcloud-upload-tool.git
cd nextcloud-upload-tool
cargo build --release -p nut
# The binary is placed at target/release/nut
```
### Build the Desktop GUI
```bash
cd gui
npm install
npm run build
npm run tauri build
# The packaged bundle is located in gui/src-tauri/target/release/bundle/
```

92
docs/multi-account.md Normal file
View File

@@ -0,0 +1,92 @@
# Multi-Account Management Guide
**Nextcloud Upload Tool** supports managing multiple Nextcloud instances and user accounts simultaneously. Credentials, account metadata, and active default states are shared seamlessly between the CLI and GUI frontends.
---
## 1. How Accounts Are Identified
Every configured account has a canonical ID in the format:
```
username@host
```
*(e.g. `alice@cloud.example.com` or `dev@nextcloud.local`)*
You can also assign an optional friendly **label** (e.g. `"Work"`, `"Personal"`, `"Backup"`).
---
## 2. Managing Accounts via CLI (`nut`)
### Listing Configured Accounts
```bash
nut accounts
# or
nut account list
```
Outputs:
```text
Configured Nextcloud Accounts:
• alice@cloud.example.com * (active default)
Username: alice
Server: https://cloud.example.com
Label: Work
• bob@nextcloud.org
Username: bob
Server: https://nextcloud.org
Label: Personal
```
To output as JSON or TSV for scripting:
```bash
nut accounts --json
nut accounts --tsv
```
### Adding an Account
- **Interactive Browser Flow**:
```bash
nut login https://cloud.example.com --label "Work"
```
- **Interactive Terminal Password Prompt**:
```bash
nut login https://cloud.example.com --manual --label "Work"
```
- **Non-Interactive / Headless / Scripting**:
```bash
nut login https://cloud.example.com -u alice -p "app-password-token" --label "Work"
```
### Switching the Active Default Account
```bash
# Set default by label
nut account default Work
# Or set default by full account ID
nut account default alice@cloud.example.com
```
### Uploading to a Specific Account
Use the `--account` / `-a` flag on any upload command to override the active default for that invocation:
```bash
nut upload --account "Work" report.pdf
nut upload --account "bob@nextcloud.org" archive.zip
```
### Removing an Account
```bash
nut account delete "Work"
```
This removes the account from `~/.config/nut/accounts.json` and deletes the associated app token from your OS keychain.
---
## 3. Managing Accounts in the GUI
1. Open the **Accounts & Auth** tab.
2. The list of connected accounts displays each account's server URL, username, label, and whether it is marked as default.
3. Click **Set as Default** on any account card to switch the primary default.
4. Click **Select as Active** to switch the current session's upload destination.
5. Click **Logout / Remove** to purge credentials from the OS keychain.

View File

@@ -1,11 +1,13 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc;
use tauri::Emitter;
use nextcloud_client::{ use nextcloud_client::{
initiate_login_flow as api_initiate_login_flow, initiate_login_flow as api_initiate_login_flow,
poll_login_flow as api_poll_login_flow, poll_login_flow as api_poll_login_flow,
ClientConfig, CredentialStore, NextcloudClient, StoredAccount, UploadOptions, ClientConfig, CredentialStore, NextcloudClient, ProgressEvent, StoredAccount, UploadOptions,
}; };
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -32,6 +34,13 @@ pub struct GuiUploadResult {
pub direct_download_url: Option<String>, pub direct_download_url: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct GuiUploadProgressPayload {
pub file_path: String,
pub bytes_transferred: u64,
pub total_bytes: Option<u64>,
}
#[tauri::command] #[tauri::command]
pub fn list_accounts() -> Result<Vec<StoredAccount>, String> { pub fn list_accounts() -> Result<Vec<StoredAccount>, String> {
CredentialStore::list_accounts().map_err(|e| e.to_string()) CredentialStore::list_accounts().map_err(|e| e.to_string())
@@ -184,6 +193,7 @@ pub fn get_file_info(file_path: String) -> Result<FileInfo, String> {
#[tauri::command] #[tauri::command]
pub async fn upload_file( pub async fn upload_file(
app: tauri::AppHandle,
file_path: String, file_path: String,
remote_dir: String, remote_dir: String,
create_share: bool, create_share: bool,
@@ -222,8 +232,23 @@ pub async fn upload_file(
overwrite: true, overwrite: true,
}; };
let app_handle = app.clone();
let fp = file_path.clone();
let progress_cb: nextcloud_client::ProgressCallback = Arc::new(move |event| {
if let ProgressEvent::Progress { bytes_transferred, total_bytes } = event {
let _ = app_handle.emit(
"upload-progress",
GuiUploadProgressPayload {
file_path: fp.clone(),
bytes_transferred,
total_bytes,
},
);
}
});
let res = client let res = client
.upload_and_share(&local_path, &options, None) .upload_and_share(&local_path, &options, Some(progress_cb))
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
@@ -239,7 +264,7 @@ pub async fn upload_file(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{FileInfo, GuiUploadResult, LoginFlowInitPayload}; use super::{FileInfo, GuiUploadProgressPayload, GuiUploadResult, LoginFlowInitPayload};
#[test] #[test]
fn test_login_flow_payload_serialization() { fn test_login_flow_payload_serialization() {
@@ -267,6 +292,19 @@ mod tests {
assert_eq!(file_info, deserialized); assert_eq!(file_info, deserialized);
} }
#[test]
fn test_upload_progress_payload_serialization() {
let payload = GuiUploadProgressPayload {
file_path: "/tmp/sample.iso".to_string(),
bytes_transferred: 5242880,
total_bytes: Some(10485760),
};
let json = serde_json::to_string(&payload).unwrap();
let deserialized: GuiUploadProgressPayload = serde_json::from_str(&json).unwrap();
assert_eq!(payload, deserialized);
}
#[test] #[test]
fn test_gui_upload_result_serialization() { fn test_gui_upload_result_serialization() {
let res = GuiUploadResult { let res = GuiUploadResult {

View File

@@ -31,6 +31,38 @@
"icons/128x128@2x.png", "icons/128x128@2x.png",
"icons/icon.icns", "icons/icon.icns",
"icons/icon.ico" "icons/icon.ico"
],
"category": "Utility",
"shortDescription": "Fast, seamless Nextcloud file uploader and share link generator",
"longDescription": "Upload files to Nextcloud with ease, automatically generate share links, and manage multiple accounts with secure OS keychain storage.",
"linux": {
"deb": {
"depends": [
"libwebkit2gtk-4.1-0 | libwebkit2gtk-4.0-37",
"libayatana-appindicator3-1 | libappindicator3-1",
"librsvg2-common"
]
},
"rpm": {
"depends": [
"webkit2gtk4.1",
"libappindicator-gtk3"
] ]
} }
},
"macOS": {
"minimumSystemVersion": "10.15",
"dmg": {
"windowSize": {
"width": 600,
"height": 400
}
}
},
"windows": {
"nsis": {
"installMode": "currentUser"
}
}
}
} }

View File

@@ -102,6 +102,47 @@ body {
cursor: pointer; cursor: pointer;
} }
/* Navigation Tabs */
.nav-tabs {
display: flex;
gap: 8px;
margin-top: 14px;
}
.tab-button {
background: var(--surface-color);
color: var(--text-muted);
border: 1px solid var(--border-color);
border-radius: var(--radius);
padding: 8px 16px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
transition: all 0.15s ease;
}
.tab-button:hover {
color: var(--text-main);
background: var(--surface-card);
}
.tab-button.active {
background: var(--primary);
color: white;
border-color: var(--primary);
}
.tab-badge {
background: rgba(0, 0, 0, 0.25);
font-size: 11px;
font-weight: 700;
padding: 2px 6px;
border-radius: 10px;
}
/* Content Area */ /* Content Area */
.main-content { .main-content {
flex: 1; flex: 1;
@@ -169,6 +210,71 @@ body {
margin-top: 4px; margin-top: 4px;
} }
/* Overall Progress */
.overall-progress-container {
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: var(--radius);
padding: 12px;
margin-bottom: 14px;
}
.overall-progress-header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
margin-bottom: 8px;
}
.overall-progress-title {
font-weight: 600;
color: var(--accent);
}
.overall-progress-stats {
font-weight: 700;
color: var(--text-main);
}
.progress-bar-track {
background: var(--surface-card);
border-radius: 4px;
height: 8px;
overflow: hidden;
}
.progress-bar-fill {
background: linear-gradient(90deg, var(--primary), var(--accent));
height: 100%;
border-radius: 4px;
transition: width 0.2s ease;
}
.progress-bar-animated {
background-image: linear-gradient(
45deg,
rgba(255, 255, 255, 0.15) 25%,
transparent 25%,
transparent 50%,
rgba(255, 255, 255, 0.15) 50%,
rgba(255, 255, 255, 0.15) 75%,
transparent 75%,
transparent
);
background-size: 24px 24px;
animation: move-stripes 1s linear infinite;
}
@keyframes move-stripes {
0% {
background-position: 0 0;
}
100% {
background-position: 24px 0;
}
}
/* Queue List */ /* Queue List */
.queue-title-meta { .queue-title-meta {
display: flex; display: flex;
@@ -204,7 +310,7 @@ body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
max-height: 280px; max-height: 320px;
overflow-y: auto; overflow-y: auto;
padding-right: 4px; padding-right: 4px;
} }
@@ -216,7 +322,7 @@ body {
background: var(--bg-color); background: var(--bg-color);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: var(--radius); border-radius: var(--radius);
padding: 8px 12px; padding: 10px 12px;
transition: background-color 0.15s, border-color 0.15s; transition: background-color 0.15s, border-color 0.15s;
} }
@@ -265,10 +371,74 @@ body {
text-overflow: ellipsis; text-overflow: ellipsis;
} }
/* Item Progress Bars */
.item-progress-wrap {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
}
.item-progress-track {
flex: 1;
height: 6px;
background: var(--surface-card);
border-radius: 3px;
overflow: hidden;
}
.item-progress-fill {
background: var(--accent);
height: 100%;
border-radius: 3px;
transition: width 0.15s ease;
}
.item-progress-animated {
background: linear-gradient(90deg, var(--primary), var(--accent));
animation: pulse-glow 1.5s ease-in-out infinite alternate;
}
@keyframes pulse-glow {
0% {
opacity: 0.7;
}
100% {
opacity: 1;
}
}
.item-progress-text {
font-size: 10px;
font-weight: 600;
color: var(--text-muted);
white-space: nowrap;
}
.queue-item-error { .queue-item-error {
font-size: 11px; font-size: 11px;
color: var(--danger); color: var(--danger);
margin-top: 2px; margin-top: 4px;
display: flex;
align-items: center;
gap: 8px;
}
.btn-retry-inline {
background: rgba(239, 68, 68, 0.2);
color: #fca5a5;
border: 1px solid var(--danger);
border-radius: 4px;
padding: 2px 6px;
font-size: 10px;
font-weight: 700;
cursor: pointer;
transition: all 0.15s;
}
.btn-retry-inline:hover {
background: var(--danger);
color: white;
} }
.queue-item-size { .queue-item-size {
@@ -354,6 +524,154 @@ body {
border-color: var(--danger); border-color: var(--danger);
} }
/* Account Cards */
.account-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.account-card {
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: var(--radius);
padding: 12px 14px;
transition: border-color 0.15s;
}
.account-card.active {
border-color: var(--primary);
box-shadow: 0 0 0 1px var(--primary);
}
.account-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.account-card-title {
display: flex;
align-items: center;
gap: 8px;
}
.account-name {
font-size: 15px;
font-weight: 700;
color: var(--text-main);
}
.badge {
font-size: 10px;
font-weight: 700;
padding: 2px 6px;
border-radius: 4px;
}
.badge-default {
background: rgba(16, 185, 129, 0.2);
color: var(--success);
border: 1px solid var(--success);
}
.badge-active {
background: rgba(2, 132, 199, 0.2);
color: var(--accent);
border: 1px solid var(--accent);
}
.account-card-actions {
display: flex;
gap: 6px;
}
.account-card-meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 6px;
font-size: 12px;
color: var(--text-muted);
}
/* Auth Mode Toggle */
.auth-mode-toggle {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.btn-mode {
flex: 1;
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: var(--radius);
padding: 8px 12px;
color: var(--text-muted);
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
}
.btn-mode:hover:not(:disabled) {
color: var(--text-main);
border-color: var(--accent);
}
.btn-mode.active {
background: var(--surface-card);
border-color: var(--primary);
color: var(--accent);
}
.auth-form-content {
display: flex;
flex-direction: column;
}
/* Polling State */
.polling-box {
background: var(--bg-color);
border: 1px dashed var(--accent);
border-radius: var(--radius);
padding: 16px;
margin-top: 10px;
text-align: center;
}
.polling-header {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
font-weight: 600;
font-size: 14px;
color: var(--accent);
}
.polling-text {
font-size: 12px;
color: var(--text-muted);
margin-top: 6px;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid var(--accent);
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Form Controls */ /* Form Controls */
.form-group { .form-group {
margin-bottom: 12px; margin-bottom: 12px;

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { openUrl } from "@tauri-apps/plugin-opener"; import { openUrl } from "@tauri-apps/plugin-opener";
import "./App.css"; import "./App.css";
@@ -12,6 +13,12 @@ interface StoredAccount {
is_default: boolean; is_default: boolean;
} }
interface LoginFlowInitPayload {
login_url: string;
poll_endpoint: string;
poll_token: string;
}
interface FileInfo { interface FileInfo {
path: string; path: string;
name: string; name: string;
@@ -23,6 +30,7 @@ interface QueueItem {
path: string; path: string;
name: string; name: string;
size: number; size: number;
bytesTransferred: number;
status: "pending" | "uploading" | "done" | "error"; status: "pending" | "uploading" | "done" | "error";
errorMessage?: string; errorMessage?: string;
result?: GuiUploadResult; result?: GuiUploadResult;
@@ -37,7 +45,17 @@ interface GuiUploadResult {
direct_download_url?: string; direct_download_url?: string;
} }
interface GuiUploadProgressPayload {
file_path: string;
bytes_transferred: number;
total_bytes?: number;
}
export function App() { export function App() {
// Navigation
const [activeTab, setActiveTab] = useState<"upload" | "accounts">("upload");
// Account State
const [accounts, setAccounts] = useState<StoredAccount[]>([]); const [accounts, setAccounts] = useState<StoredAccount[]>([]);
const [selectedAccount, setSelectedAccount] = useState<string>(""); const [selectedAccount, setSelectedAccount] = useState<string>("");
const [toastMessage, setToastMessage] = useState<string | null>(null); const [toastMessage, setToastMessage] = useState<string | null>(null);
@@ -52,6 +70,22 @@ export function App() {
const [createShare, setCreateShare] = useState<boolean>(true); const [createShare, setCreateShare] = useState<boolean>(true);
const [sharePassword, setSharePassword] = useState<string>(""); const [sharePassword, setSharePassword] = useState<string>("");
const [isUploading, setIsUploading] = useState<boolean>(false); const [isUploading, setIsUploading] = useState<boolean>(false);
const [currentUploadingIndex, setCurrentUploadingIndex] = useState<number>(0);
// Add Account State
const [authMode, setAuthMode] = useState<"browser" | "manual">("browser");
const [serverUrl, setServerUrl] = useState<string>("https://");
const [accountLabel, setAccountLabel] = useState<string>("");
const [setAsDefault, setSetAsDefault] = useState<boolean>(true);
// Manual Auth Fields
const [manualUsername, setManualUsername] = useState<string>("");
const [manualPassword, setManualPassword] = useState<string>("");
const [isAuthenticating, setIsAuthenticating] = useState<boolean>(false);
// Browser Flow State
const [loginFlowPayload, setLoginFlowPayload] = useState<LoginFlowInitPayload | null>(null);
const pollingIntervalRef = useRef<number | null>(null);
const fileQueueRef = useRef(fileQueue); const fileQueueRef = useRef(fileQueue);
fileQueueRef.current = fileQueue; fileQueueRef.current = fileQueue;
@@ -69,9 +103,9 @@ export function App() {
setAccounts(list); setAccounts(list);
const def = list.find((a) => a.is_default); const def = list.find((a) => a.is_default);
if (def) { if (def) {
setSelectedAccount(def.id); setSelectedAccount((prev) => (list.some((a) => a.id === prev) ? prev : def.id));
} else if (list.length > 0) { } else if (list.length > 0) {
setSelectedAccount(list[0].id); setSelectedAccount((prev) => (list.some((a) => a.id === prev) ? prev : list[0].id));
} else { } else {
setSelectedAccount(""); setSelectedAccount("");
} }
@@ -97,6 +131,7 @@ export function App() {
path: info.path, path: info.path,
name: info.name, name: info.name,
size: info.size, size: info.size,
bytesTransferred: 0,
status: "pending", status: "pending",
}); });
} catch { } catch {
@@ -106,6 +141,7 @@ export function App() {
path: p, path: p,
name, name,
size: 0, size: 0,
bytesTransferred: 0,
status: "pending", status: "pending",
}); });
} }
@@ -121,10 +157,11 @@ export function App() {
loadAccounts(); loadAccounts();
// Listen to Tauri Drag-and-Drop events from OS // Listen to Tauri Drag-and-Drop events from OS
let unlisten: (() => void) | undefined; let unlistenDrag: (() => void) | undefined;
try { try {
const appWindow = getCurrentWebviewWindow(); const appWindow = getCurrentWebviewWindow();
appWindow.onDragDropEvent((event) => { appWindow
.onDragDropEvent((event) => {
if (event.payload.type === "enter" || event.payload.type === "over") { if (event.payload.type === "enter" || event.payload.type === "over") {
setIsDragOver(true); setIsDragOver(true);
} else if (event.payload.type === "drop") { } else if (event.payload.type === "drop") {
@@ -135,17 +172,45 @@ export function App() {
} else if (event.payload.type === "leave") { } else if (event.payload.type === "leave") {
setIsDragOver(false); setIsDragOver(false);
} }
}).then((fn) => { })
unlisten = fn; .then((fn) => {
}).catch((e) => { unlistenDrag = fn;
})
.catch((e) => {
console.warn("Tauri drag-drop listener not active in current mode:", e); console.warn("Tauri drag-drop listener not active in current mode:", e);
}); });
} catch (e) { } catch (e) {
console.warn("Tauri getCurrentWebviewWindow not available:", e); console.warn("Tauri getCurrentWebviewWindow not available:", e);
} }
// Listen to upload-progress events from Tauri backend
let unlistenProgress: (() => void) | undefined;
listen<GuiUploadProgressPayload>("upload-progress", (event) => {
const { file_path, bytes_transferred, total_bytes } = event.payload;
setFileQueue((prev) =>
prev.map((item) => {
if (item.path === file_path) {
return {
...item,
bytesTransferred: bytes_transferred,
size: total_bytes && total_bytes > 0 ? total_bytes : item.size,
};
}
return item;
})
);
}).then((fn) => {
unlistenProgress = fn;
}).catch((e) => {
console.warn("Progress listener setup warning:", e);
});
return () => { return () => {
if (unlisten) unlisten(); if (unlistenDrag) unlistenDrag();
if (unlistenProgress) unlistenProgress();
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
}
}; };
}, []); }, []);
@@ -209,24 +274,41 @@ export function App() {
setFileQueue([]); setFileQueue([]);
}; };
const handleRetryItem = (id: string) => {
setFileQueue((prev) =>
prev.map((item) =>
item.id === id ? { ...item, status: "pending", errorMessage: undefined, bytesTransferred: 0 } : item
)
);
};
const handleUploadQueue = async () => { const handleUploadQueue = async () => {
const pendingItems = fileQueue.filter((item) => item.status === "pending" || item.status === "error"); const pendingItems = fileQueue.filter(
(item) => item.status === "pending" || item.status === "error"
);
if (pendingItems.length === 0) { if (pendingItems.length === 0) {
showToast("No pending files in the queue to upload."); showToast("No pending files in the queue to upload.");
return; return;
} }
if (accounts.length === 0) { if (accounts.length === 0) {
showToast("No account configured. Please configure an account in your credentials store."); showToast("No account configured. Please add an account in the Accounts tab.");
setActiveTab("accounts");
return; return;
} }
setIsUploading(true); setIsUploading(true);
for (const item of pendingItems) { for (let i = 0; i < pendingItems.length; i++) {
// Mark as uploading const item = pendingItems[i];
setCurrentUploadingIndex(i + 1);
setFileQueue((prev) => setFileQueue((prev) =>
prev.map((q) => (q.id === item.id ? { ...q, status: "uploading", errorMessage: undefined } : q)) prev.map((q) =>
q.id === item.id
? { ...q, status: "uploading", bytesTransferred: 0, errorMessage: undefined }
: q
)
); );
try { try {
@@ -239,7 +321,17 @@ export function App() {
}); });
setFileQueue((prev) => setFileQueue((prev) =>
prev.map((q) => (q.id === item.id ? { ...q, status: "done", result: res } : q)) prev.map((q) =>
q.id === item.id
? {
...q,
status: "done",
bytesTransferred: res.bytes_uploaded,
size: res.bytes_uploaded > 0 ? res.bytes_uploaded : q.size,
result: res,
}
: q
)
); );
} catch (err: unknown) { } catch (err: unknown) {
const errStr = typeof err === "string" ? err : String(err); const errStr = typeof err === "string" ? err : String(err);
@@ -252,9 +344,120 @@ export function App() {
} }
setIsUploading(false); setIsUploading(false);
setCurrentUploadingIndex(0);
showToast("Queue processing completed."); showToast("Queue processing completed.");
}; };
// --- Account Management Actions ---
const handleSetDefaultAccount = async (accountId: string) => {
try {
await invoke("set_default_account", { accountId });
showToast("Default account updated.");
await loadAccounts();
} catch (e) {
showToast(`Failed to set default account: ${e}`);
}
};
const handleDeleteAccount = async (accountId: string) => {
if (!confirm(`Are you sure you want to remove account '${accountId}'?`)) {
return;
}
try {
await invoke("delete_account", { accountId });
showToast(`Account '${accountId}' removed.`);
await loadAccounts();
} catch (e) {
showToast(`Failed to remove account: ${e}`);
}
};
const handleStartBrowserLogin = async () => {
if (!serverUrl.trim() || serverUrl.trim() === "https://") {
showToast("Please enter a valid Nextcloud server URL.");
return;
}
setIsAuthenticating(true);
try {
const initPayload = await invoke<LoginFlowInitPayload>("initiate_login_flow", {
serverUrl: serverUrl.trim(),
});
setLoginFlowPayload(initPayload);
showToast("Browser opened for authorization.");
// Start Polling
if (pollingIntervalRef.current) clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = window.setInterval(async () => {
try {
const account = await invoke<StoredAccount | null>("poll_login_flow", {
endpoint: initPayload.poll_endpoint,
token: initPayload.poll_token,
isDefault: setAsDefault,
label: accountLabel.trim() ? accountLabel.trim() : null,
});
if (account) {
if (pollingIntervalRef.current) clearInterval(pollingIntervalRef.current);
setIsAuthenticating(false);
setLoginFlowPayload(null);
showToast(`Successfully connected account: ${account.id}`);
await loadAccounts();
setSelectedAccount(account.id);
setServerUrl("https://");
setAccountLabel("");
}
} catch (pollErr) {
console.error("Polling error:", pollErr);
}
}, 1500);
} catch (e) {
setIsAuthenticating(false);
setLoginFlowPayload(null);
showToast(`Failed to initiate browser login: ${e}`);
}
};
const handleCancelBrowserLogin = () => {
if (pollingIntervalRef.current) clearInterval(pollingIntervalRef.current);
setIsAuthenticating(false);
setLoginFlowPayload(null);
showToast("Login cancelled.");
};
const handleManualLogin = async (e: React.FormEvent) => {
e.preventDefault();
if (!serverUrl.trim() || !manualUsername.trim() || !manualPassword.trim()) {
showToast("Please enter server URL, username, and password.");
return;
}
setIsAuthenticating(true);
try {
const account = await invoke<StoredAccount>("manual_login", {
serverUrl: serverUrl.trim(),
username: manualUsername.trim(),
appPassword: manualPassword.trim(),
isDefault: setAsDefault,
label: accountLabel.trim() ? accountLabel.trim() : null,
});
setIsAuthenticating(false);
showToast(`Successfully connected account: ${account.id}`);
await loadAccounts();
setSelectedAccount(account.id);
setManualUsername("");
setManualPassword("");
setAccountLabel("");
setServerUrl("https://");
} catch (e) {
setIsAuthenticating(false);
showToast(`Failed to connect: ${e}`);
}
};
const handleCopy = (text: string, label: string) => { const handleCopy = (text: string, label: string) => {
navigator.clipboard.writeText(text); navigator.clipboard.writeText(text);
showToast(`Copied ${label} to clipboard!`); showToast(`Copied ${label} to clipboard!`);
@@ -276,7 +479,19 @@ export function App() {
const pendingCount = fileQueue.filter((q) => q.status === "pending").length; const pendingCount = fileQueue.filter((q) => q.status === "pending").length;
const doneCount = fileQueue.filter((q) => q.status === "done").length; const doneCount = fileQueue.filter((q) => q.status === "done").length;
const errorCount = fileQueue.filter((q) => q.status === "error").length;
const totalQueueBytes = fileQueue.reduce((acc, q) => acc + (q.size || 0), 0); const totalQueueBytes = fileQueue.reduce((acc, q) => acc + (q.size || 0), 0);
const totalTransferredBytes = fileQueue.reduce((acc, q) => {
if (q.status === "done") return acc + (q.size || 0);
if (q.status === "uploading") return acc + (q.bytesTransferred || 0);
return acc;
}, 0);
const overallPercent =
totalQueueBytes > 0
? Math.min(100, Math.round((totalTransferredBytes / totalQueueBytes) * 100))
: 0;
return ( return (
<div className="app-container"> <div className="app-container">
@@ -304,15 +519,45 @@ export function App() {
</select> </select>
</div> </div>
) : ( ) : (
<span style={{ fontSize: 13, color: "var(--danger)" }}> <span
No Account Connected style={{
fontSize: 13,
color: "var(--danger)",
cursor: "pointer",
textDecoration: "underline",
}}
onClick={() => setActiveTab("accounts")}
>
+ Connect Account
</span> </span>
)} )}
</div> </div>
</header> </header>
{/* Navigation Tabs */}
<nav className="nav-tabs">
<button
className={`tab-button ${activeTab === "upload" ? "active" : ""}`}
onClick={() => setActiveTab("upload")}
>
Upload & Queue
{fileQueue.length > 0 && (
<span className="tab-badge">{fileQueue.length}</span>
)}
</button>
<button
className={`tab-button ${activeTab === "accounts" ? "active" : ""}`}
onClick={() => setActiveTab("accounts")}
>
Accounts & Auth
<span className="tab-badge">{accounts.length}</span>
</button>
</nav>
{/* Main Content */} {/* Main Content */}
<main className="main-content"> <main className="main-content">
{activeTab === "upload" && (
<>
{/* File Dropzone */} {/* File Dropzone */}
<div <div
className={`file-dropzone ${isDragOver ? "drag-over" : ""}`} className={`file-dropzone ${isDragOver ? "drag-over" : ""}`}
@@ -329,7 +574,6 @@ export function App() {
const paths: string[] = []; const paths: string[] = [];
for (let i = 0; i < e.dataTransfer.files.length; i++) { for (let i = 0; i < e.dataTransfer.files.length; i++) {
const f = e.dataTransfer.files[i]; const f = e.dataTransfer.files[i];
// In desktop webview with path property
if ("path" in f && typeof (f as { path: string }).path === "string") { if ("path" in f && typeof (f as { path: string }).path === "string") {
paths.push((f as { path: string }).path); paths.push((f as { path: string }).path);
} }
@@ -342,7 +586,9 @@ export function App() {
> >
<div className="dropzone-icon">📥</div> <div className="dropzone-icon">📥</div>
<div className="dropzone-title"> <div className="dropzone-title">
{isDragOver ? "Drop files now to add to queue" : "Drag and drop files here, or click to browse"} {isDragOver
? "Drop files now to add to queue"
: "Drag and drop files here, or click to browse"}
</div> </div>
<div className="dropzone-subtitle"> <div className="dropzone-subtitle">
Supports any files: documents, media, archives, and directories Supports any files: documents, media, archives, and directories
@@ -357,6 +603,7 @@ export function App() {
{fileQueue.length > 0 && ( {fileQueue.length > 0 && (
<span className="queue-summary-pill"> <span className="queue-summary-pill">
{formatBytes(totalQueueBytes)} • {pendingCount} Pending • {doneCount} Done {formatBytes(totalQueueBytes)} • {pendingCount} Pending • {doneCount} Done
{errorCount > 0 && ` • ${errorCount} Failed`}
</span> </span>
)} )}
</div> </div>
@@ -382,16 +629,50 @@ export function App() {
</div> </div>
</div> </div>
{/* Overall Total Progress Bar */}
{(isUploading || doneCount > 0) && fileQueue.length > 0 && (
<div className="overall-progress-container">
<div className="overall-progress-header">
<span className="overall-progress-title">
{isUploading
? `Uploading File ${currentUploadingIndex} of ${fileQueue.length}...`
: doneCount === fileQueue.length
? "All uploads complete!"
: "Upload batch progress"}
</span>
<span className="overall-progress-stats">
{formatBytes(totalTransferredBytes)} / {formatBytes(totalQueueBytes)} ({overallPercent}%)
</span>
</div>
<div className="progress-bar-track">
<div
className={`progress-bar-fill ${isUploading ? "progress-bar-animated" : ""}`}
style={{ width: `${overallPercent}%` }}
/>
</div>
</div>
)}
{fileQueue.length === 0 ? ( {fileQueue.length === 0 ? (
<div className="empty-queue-hint"> <div className="empty-queue-hint">
Queue is empty. Drop files above or click to select files for uploading. Queue is empty. Drop files above or click to select files for uploading.
</div> </div>
) : ( ) : (
<div className="queue-item-list"> <div className="queue-item-list">
{fileQueue.map((item, index) => ( {fileQueue.map((item, index) => {
const itemPercent =
item.size > 0
? Math.min(100, Math.round((item.bytesTransferred / item.size) * 100))
: item.status === "done"
? 100
: 0;
return (
<div <div
key={item.id} key={item.id}
className={`queue-item-row status-${item.status} ${draggedIndex === index ? "dragging" : ""}`} className={`queue-item-row status-${item.status} ${
draggedIndex === index ? "dragging" : ""
}`}
draggable={!isUploading} draggable={!isUploading}
onDragStart={() => handleDragStart(index)} onDragStart={() => handleDragStart(index)}
onDragOver={(e) => handleDragOver(e, index)} onDragOver={(e) => handleDragOver(e, index)}
@@ -405,8 +686,39 @@ export function App() {
<div className="queue-item-info"> <div className="queue-item-info">
<div className="queue-item-name">{item.name}</div> <div className="queue-item-name">{item.name}</div>
<div className="queue-item-path">{item.path}</div> <div className="queue-item-path">{item.path}</div>
{/* Per-item progress bar when active or finished */}
{(item.status === "uploading" || item.status === "done") && (
<div className="item-progress-wrap">
<div className="item-progress-track">
<div
className={`item-progress-fill ${
item.status === "uploading" ? "item-progress-animated" : ""
}`}
style={{ width: `${item.status === "done" ? 100 : itemPercent}%` }}
/>
</div>
<span className="item-progress-text">
{item.status === "done"
? "100%"
: `${itemPercent}% (${formatBytes(item.bytesTransferred)} / ${formatBytes(
item.size
)})`}
</span>
</div>
)}
{item.errorMessage && ( {item.errorMessage && (
<div className="queue-item-error">Error: {item.errorMessage}</div> <div className="queue-item-error">
<span>Error: {item.errorMessage}</span>
<button
className="btn-retry-inline"
onClick={() => handleRetryItem(item.id)}
disabled={isUploading}
>
↻ Retry
</button>
</div>
)} )}
</div> </div>
@@ -445,7 +757,8 @@ export function App() {
</button> </button>
</div> </div>
</div> </div>
))} );
})}
</div> </div>
)} )}
</div> </div>
@@ -492,12 +805,12 @@ export function App() {
className="btn btn-primary" className="btn btn-primary"
style={{ width: "100%", marginTop: 8 }} style={{ width: "100%", marginTop: 8 }}
onClick={handleUploadQueue} onClick={handleUploadQueue}
disabled={isUploading || pendingCount === 0} disabled={isUploading || (pendingCount === 0 && errorCount === 0)}
> >
{isUploading {isUploading
? "Uploading Queue..." ? `Uploading Queue (${overallPercent}%)...`
: pendingCount > 0 : pendingCount > 0 || errorCount > 0
? `Upload Queue (${pendingCount} pending files)` ? `Upload Queue (${pendingCount + errorCount} files)`
: "All Files in Queue Uploaded"} : "All Files in Queue Uploaded"}
</button> </button>
</div> </div>
@@ -518,7 +831,13 @@ export function App() {
<span className="result-file-name">{r.file_name}</span> <span className="result-file-name">{r.file_name}</span>
<span className="result-size">{formatBytes(r.bytes_uploaded)}</span> <span className="result-size">{formatBytes(r.bytes_uploaded)}</span>
</div> </div>
<div style={{ fontSize: 13, color: "var(--text-muted)", marginBottom: 8 }}> <div
style={{
fontSize: 13,
color: "var(--text-muted)",
marginBottom: 8,
}}
>
Destination: {r.remote_path} Destination: {r.remote_path}
</div> </div>
@@ -545,7 +864,9 @@ export function App() {
<input className="link-input" readOnly value={r.direct_download_url} /> <input className="link-input" readOnly value={r.direct_download_url} />
<button <button
className="btn btn-secondary btn-sm" className="btn btn-secondary btn-sm"
onClick={() => handleCopy(r.direct_download_url!, "Direct Download Link")} onClick={() =>
handleCopy(r.direct_download_url!, "Direct Download Link")
}
> >
Copy Direct Copy Direct
</button> </button>
@@ -556,6 +877,265 @@ export function App() {
})} })}
</div> </div>
)} )}
</>
)}
{activeTab === "accounts" && (
<>
{/* Account List */}
<div className="card">
<div className="card-title">
<span>Connected Accounts ({accounts.length})</span>
</div>
{accounts.length === 0 ? (
<div className="empty-queue-hint">
No Nextcloud accounts stored yet. Add your account below to begin uploading.
</div>
) : (
<div className="account-list">
{accounts.map((acc) => {
const isSelected = selectedAccount === acc.id;
return (
<div
key={acc.id}
className={`account-card ${isSelected ? "active" : ""}`}
>
<div className="account-card-header">
<div className="account-card-title">
<span className="account-name">
{acc.label ? acc.label : acc.username}
</span>
{acc.is_default && (
<span className="badge badge-default">DEFAULT</span>
)}
{isSelected && (
<span className="badge badge-active">ACTIVE</span>
)}
</div>
<div className="account-card-actions">
{!isSelected && (
<button
className="btn btn-secondary btn-sm"
onClick={() => setSelectedAccount(acc.id)}
>
Select
</button>
)}
{!acc.is_default && (
<button
className="btn btn-secondary btn-sm"
onClick={() => handleSetDefaultAccount(acc.id)}
>
Set Default
</button>
)}
<button
className="btn btn-danger btn-sm"
onClick={() => handleDeleteAccount(acc.id)}
>
Logout
</button>
</div>
</div>
<div className="account-card-meta">
<div>
<strong>User:</strong> {acc.username}
</div>
<div>
<strong>Server:</strong> {acc.server_url}
</div>
<div>
<strong>ID:</strong> {acc.id}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Add New Account Card */}
<div className="card">
<div className="card-title">Add Nextcloud Account</div>
<div className="auth-mode-toggle">
<button
className={`btn-mode ${authMode === "browser" ? "active" : ""}`}
onClick={() => setAuthMode("browser")}
disabled={isAuthenticating}
>
🌐 Browser Login (Flow v2)
</button>
<button
className={`btn-mode ${authMode === "manual" ? "active" : ""}`}
onClick={() => setAuthMode("manual")}
disabled={isAuthenticating}
>
🔑 Manual App Password
</button>
</div>
{authMode === "browser" ? (
<div className="auth-form-content">
<div className="form-group">
<label className="form-label">Nextcloud Server URL</label>
<input
className="form-input"
type="url"
value={serverUrl}
onChange={(e) => setServerUrl(e.target.value)}
placeholder="https://cloud.example.com"
disabled={isAuthenticating}
/>
</div>
<div className="form-group">
<label className="form-label">Account Label (Optional)</label>
<input
className="form-input"
type="text"
value={accountLabel}
onChange={(e) => setAccountLabel(e.target.value)}
placeholder="e.g. Work, Personal, Team Cloud"
disabled={isAuthenticating}
/>
</div>
<div className="form-group">
<label className="form-checkbox">
<input
type="checkbox"
checked={setAsDefault}
onChange={(e) => setSetAsDefault(e.target.checked)}
disabled={isAuthenticating}
/>
Set as default account
</label>
</div>
{loginFlowPayload ? (
<div className="polling-box">
<div className="polling-header">
<span className="spinner" />
<span>Waiting for browser authorization...</span>
</div>
<p className="polling-text">
A browser tab has been opened. Please log in and grant access to complete connection.
</p>
<div className="link-row" style={{ marginTop: 8 }}>
<input
className="link-input"
readOnly
value={loginFlowPayload.login_url}
/>
<button
className="btn btn-secondary btn-sm"
onClick={() => handleOpenBrowser(loginFlowPayload.login_url)}
>
Reopen Browser
</button>
</div>
<button
className="btn btn-secondary btn-sm"
style={{ marginTop: 12 }}
onClick={handleCancelBrowserLogin}
>
Cancel Login
</button>
</div>
) : (
<button
className="btn btn-primary"
style={{ width: "100%", marginTop: 8 }}
onClick={handleStartBrowserLogin}
disabled={isAuthenticating}
>
Authenticate in Browser (Login Flow v2)
</button>
)}
</div>
) : (
<form className="auth-form-content" onSubmit={handleManualLogin}>
<div className="form-group">
<label className="form-label">Nextcloud Server URL</label>
<input
className="form-input"
type="url"
value={serverUrl}
onChange={(e) => setServerUrl(e.target.value)}
placeholder="https://cloud.example.com"
disabled={isAuthenticating}
required
/>
</div>
<div className="form-group">
<label className="form-label">Username</label>
<input
className="form-input"
type="text"
value={manualUsername}
onChange={(e) => setManualUsername(e.target.value)}
placeholder="admin or user@domain.com"
disabled={isAuthenticating}
required
/>
</div>
<div className="form-group">
<label className="form-label">App Password / Token</label>
<input
className="form-input"
type="password"
value={manualPassword}
onChange={(e) => setManualPassword(e.target.value)}
placeholder="Generated app password from Nextcloud security settings"
disabled={isAuthenticating}
required
/>
</div>
<div className="form-group">
<label className="form-label">Account Label (Optional)</label>
<input
className="form-input"
type="text"
value={accountLabel}
onChange={(e) => setAccountLabel(e.target.value)}
placeholder="e.g. Work, Personal"
disabled={isAuthenticating}
/>
</div>
<div className="form-group">
<label className="form-checkbox">
<input
type="checkbox"
checked={setAsDefault}
onChange={(e) => setSetAsDefault(e.target.checked)}
disabled={isAuthenticating}
/>
Set as default account
</label>
</div>
<button
className="btn btn-primary"
type="submit"
style={{ width: "100%", marginTop: 8 }}
disabled={isAuthenticating}
>
{isAuthenticating ? "Verifying Credentials..." : "Connect Account"}
</button>
</form>
)}
</div>
</>
)}
</main> </main>
{/* Floating Toast */} {/* Floating Toast */}

View File

@@ -345,4 +345,42 @@ mod tests {
let deserialized: StoredAccount = serde_json::from_str(&json).unwrap(); let deserialized: StoredAccount = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized, account); assert_eq!(deserialized, account);
} }
#[test]
fn test_unified_config_accounts_parsing() {
let json_data = r#"[
{
"id": "alice@cloud.example.com",
"label": "Work",
"username": "alice",
"server_url": "https://cloud.example.com",
"is_default": true,
"fallback_password": "app-password-token"
},
{
"id": "bob@personal.nextcloud.com",
"label": "Personal",
"username": "bob",
"server_url": "https://personal.nextcloud.com",
"is_default": false
}
]"#;
let accounts: Vec<StoredAccount> = serde_json::from_str(json_data).unwrap();
assert_eq!(accounts.len(), 2);
assert_eq!(accounts[0].id, "alice@cloud.example.com");
assert_eq!(accounts[0].label.as_deref(), Some("Work"));
assert!(accounts[0].is_default);
assert_eq!(accounts[0].fallback_password.as_deref(), Some("app-password-token"));
assert_eq!(accounts[1].id, "bob@personal.nextcloud.com");
assert_eq!(accounts[1].label.as_deref(), Some("Personal"));
assert!(!accounts[1].is_default);
assert_eq!(accounts[1].fallback_password, None);
}
#[test]
fn test_keyring_constants() {
assert_eq!(KEYRING_SERVICE_NAME, "me.majinnaibu.nut");
}
} }

32
scripts/build-cli.sh Executable file
View File

@@ -0,0 +1,32 @@
#!/usr/bin/env bash
set -euo pipefail
# Nextcloud Upload Tool — CLI Packaging Helper Script
# Builds release binaries and packages archives for distribution
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
DIST_DIR="${ROOT_DIR}/dist-packages"
echo "==> Building static/optimized release CLI binary..."
cd "${ROOT_DIR}"
cargo build --release -p nut
TARGET_BIN="${ROOT_DIR}/target/release/nut"
VERSION=$(cargo pkgid -p nut | cut -d# -f2 | cut -d: -f2 || echo "0.1.0")
ARCH=$(uname -m)
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
mkdir -p "${DIST_DIR}"
ARCHIVE_NAME="nut-v${VERSION}-${OS}-${ARCH}.tar.gz"
echo "==> Creating distribution archive: ${ARCHIVE_NAME}"
TMP_STAGE=$(mktemp -d)
cp "${TARGET_BIN}" "${TMP_STAGE}/nut"
cp "${ROOT_DIR}/README.md" "${TMP_STAGE}/"
cp "${ROOT_DIR}/LICENSE" "${TMP_STAGE}/"
tar -czf "${DIST_DIR}/${ARCHIVE_NAME}" -C "${TMP_STAGE}" .
rm -rf "${TMP_STAGE}"
echo "✓ CLI package created successfully at: ${DIST_DIR}/${ARCHIVE_NAME}"

18
scripts/build-gui.sh Executable file
View File

@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
# Nextcloud Upload Tool — Tauri GUI Packaging Helper Script
# Builds production frontend and packages native desktop installer/bundles
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
GUI_DIR="${ROOT_DIR}/gui"
echo "==> Building frontend assets..."
cd "${GUI_DIR}"
npm run build
echo "==> Packaging native desktop bundle with Tauri..."
npm run tauri build
echo "✓ Native desktop bundle packaged in: ${GUI_DIR}/src-tauri/target/release/bundle/"