diff --git a/.github/rclone-mobile.png b/.github/rclone-mobile.png new file mode 100644 index 0000000..f962116 Binary files /dev/null and b/.github/rclone-mobile.png differ diff --git a/.github/rclone-video.png b/.github/rclone-video.png new file mode 100644 index 0000000..62a5702 Binary files /dev/null and b/.github/rclone-video.png differ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d183765..5917880 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,19 +24,12 @@ jobs: - platform: 'ubuntu-22.04' args: '' assetPattern: '[name]_[arch][ext]' - variant: 'default' - - platform: 'ubuntu-22.04' - args: "--config '{\"bundle\":{\"createUpdaterArtifacts\":false}}'" - assetPattern: '[name]_[arch]_LEGACY[ext]' - variant: 'legacy' - platform: 'ubuntu-24.04' args: "--config '{\"bundle\":{\"createUpdaterArtifacts\":false}}'" assetPattern: '[name]_[arch]_ALTERNATE[ext]' - variant: 'alternate' - platform: 'ubuntu-22.04-arm' args: '' assetPattern: '[name]_[arch][ext]' - variant: 'default' - platform: 'windows-11-arm' args: '--target x86_64-pc-windows-msvc' arch: 'arm64' @@ -81,16 +74,16 @@ jobs: uses: swatinem/rust-cache@v2 with: workspaces: './src-tauri -> target' - key: ${{ runner.os }}-${{ runner.arch }}${{ matrix.variant && format('-{0}', matrix.variant) || '' }} + key: ${{ matrix.platform }} - name: install dependencies (linux only) - if: matrix.variant == 'default' || matrix.variant == 'alternate' + if: matrix.platform == 'ubuntu-22.04-arm' || matrix.platform == 'ubuntu-24.04' run: | sudo apt-get update sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf - - name: install dependencies (linux legacy only) - if: matrix.variant == 'legacy' + - name: install dependencies (llinux only) + if: matrix.platform == 'ubuntu-22.04' run: | sudo apt-get update @@ -133,7 +126,7 @@ jobs: cargo install trusted-signing-cli - name: Override linuxdeploy (linux legacy only) - if: matrix.variant == 'legacy' + if: matrix.platform == 'ubuntu-22.04' run: | TAURI_TOOLKIT_PATH="${XDG_CACHE_HOME:-$HOME/.cache}/tauri" mkdir -p "$TAURI_TOOLKIT_PATH" @@ -192,7 +185,7 @@ jobs: with: tagName: v__VERSION__ releaseName: 'v__VERSION__' - releaseBody: "\"Rclone\n\n# Rclone UI v__VERSION__\n- Bug squashing & improvements.\n- **See the assets to download this version and install.**\n\n# Downloads\nBelow you can download the main releases. \n\n**If you already have Rclone UI installed, you can update simply by opening the Settings menu.**\n\n| | Linux | Mac | Windows |\n|--------------|---------|--------|-----|\n| 64 bit | [deb](https://github.com/rclone-ui/rclone-ui/releases/download/v__VERSION__/Rclone.UI_amd64.deb)   [AppImage](https://github.com/rclone-ui/rclone-ui/releases/download/v__VERSION__/Rclone.UI_amd64.AppImage)   [RPM](https://github.com/rclone-ui/rclone-ui/releases/download/v__VERSION__/Rclone.UI_x86_64.rpm) | [Intel](https://github.com/rclone-ui/rclone-ui/releases/download/v__VERSION__/Rclone.UI_x64.dmg) | [x64](https://github.com/rclone-ui/rclone-ui/releases/download/v__VERSION__/Rclone.UI_x64.exe) |\n| ARM | [deb](https://github.com/rclone-ui/rclone-ui/releases/download/v__VERSION__/Rclone.UI_arm64.deb)   [AppImage](https://github.com/rclone-ui/rclone-ui/releases/download/v__VERSION__/Rclone.UI_aarch64.AppImage)   [RPM](https://github.com/rclone-ui/rclone-ui/releases/download/v__VERSION__/Rclone.UI_aarch64.rpm) | [Apple Silicon](https://github.com/rclone-ui/rclone-ui/releases/download/v__VERSION__/Rclone.UI_aarch64.dmg) | [Arm64](https://github.com/rclone-ui/rclone-ui/releases/download/v__VERSION__/Rclone.UI_arm64.exe) |\n\n\n# Linux (LEGACY)\nIf you have an older system or distro and the main build fails to run, you can try the ones below.\n\n| | deb | AppImage | RPM |\n|--------------|---------|--------|-----|\n| 64 bit | [Download](https://github.com/rclone-ui/rclone-ui/releases/download/v__VERSION__/Rclone.UI_amd64_LEGACY.deb) | [Download](https://github.com/rclone-ui/rclone-ui/releases/download/v__VERSION__/Rclone.UI_amd64_LEGACY.AppImage) | [Download](https://github.com/rclone-ui/rclone-ui/releases/download/v__VERSION__/Rclone.UI_x86_64_LEGACY.rpm) |\n\n\n# Linux (ALTERNATE)\nMainly for testing or if you're running a cutting edge distro.\n\n| | deb | AppImage | RPM |\n|--------------|---------|--------|-----|\n| 64 bit | [Download](https://github.com/rclone-ui/rclone-ui/releases/download/v__VERSION__/Rclone.UI_amd64_ALTERNATE.deb) | [Download](https://github.com/rclone-ui/rclone-ui/releases/download/v__VERSION__/Rclone.UI_amd64_ALTERNATE.AppImage) | [Download](https://github.com/rclone-ui/rclone-ui/releases/download/v__VERSION__/Rclone.UI_x86_64_ALTERNATE.rpm) |" + releaseBody: "\"Rclone\n\n# Rclone UI v__VERSION__\n- Bug squashing & improvements.\n- **See the assets to download this version and install.**\n\n# Downloads\nBelow you can download the main releases. \n\n**If you already have Rclone UI installed, you can update simply by opening the Settings menu.**\n\n| | Linux | Mac | Windows |\n|--------------|---------|--------|-----|\n| 64 bit | [deb](https://github.com/rclone-ui/rclone-ui/releases/download/v__VERSION__/Rclone.UI_amd64.deb)   [AppImage](https://github.com/rclone-ui/rclone-ui/releases/download/v__VERSION__/Rclone.UI_amd64.AppImage)   [RPM](https://github.com/rclone-ui/rclone-ui/releases/download/v__VERSION__/Rclone.UI_x86_64.rpm) | [Intel](https://github.com/rclone-ui/rclone-ui/releases/download/v__VERSION__/Rclone.UI_x64.dmg) | [x64](https://github.com/rclone-ui/rclone-ui/releases/download/v__VERSION__/Rclone.UI_x64.exe) |\n| ARM | [deb](https://github.com/rclone-ui/rclone-ui/releases/download/v__VERSION__/Rclone.UI_arm64.deb)   [AppImage](https://github.com/rclone-ui/rclone-ui/releases/download/v__VERSION__/Rclone.UI_aarch64.AppImage)   [RPM](https://github.com/rclone-ui/rclone-ui/releases/download/v__VERSION__/Rclone.UI_aarch64.rpm) | [Apple Silicon](https://github.com/rclone-ui/rclone-ui/releases/download/v__VERSION__/Rclone.UI_aarch64.dmg) | [Arm64](https://github.com/rclone-ui/rclone-ui/releases/download/v__VERSION__/Rclone.UI_arm64.exe) |\n\n\n# Linux (ALTERNATE)\nMainly for testing or if you're running a cutting edge distro.\n\n| | deb | AppImage | RPM |\n|--------------|---------|--------|-----|\n| 64 bit | [Download](https://github.com/rclone-ui/rclone-ui/releases/download/v__VERSION__/Rclone.UI_amd64_ALTERNATE.deb) | [Download](https://github.com/rclone-ui/rclone-ui/releases/download/v__VERSION__/Rclone.UI_amd64_ALTERNATE.AppImage) | [Download](https://github.com/rclone-ui/rclone-ui/releases/download/v__VERSION__/Rclone.UI_x86_64_ALTERNATE.rpm) |" assetNamePattern: ${{ matrix.assetPattern }} releaseDraft: true prerelease: false diff --git a/README.md b/README.md index 6f4244e..5e7c80a 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,8 @@ + Rclone Mobile + +
+
+

Rclone UI @@ -50,28 +55,50 @@
-## Features -### 🖥️ Cross platform running on Windows, Linux, macOS (x64, Arm and Apple Silicon) -### ✅ Works with your existing rclone installation (or can install rclone for you) and keeps it updated (with your approval) -### 🔗 Add/Edit/Remove Remotes -### ⚡️ Sync, BiSync, Bisync, Copy, Move, Delete, Purge, Serve, Mount, etc -### ⭐️ Save time by setting default flags for each Remote separately, for each operation (Mount, Sync, Copy, etc) -### 🎛️ Customize the flags (parameters) when running an operation, on a case by case basis -### 📚 Embedded documentation for each flag, no need to google what everything does! -### 📂 File Browser -### 📡 Proxy +
+ The GUI for Rclone + -Rclone UI +## Docker/Homelab/Server Usage +Control your server, homelab, or mom's PC with **the easiest solution to manage remote **`rclone`** instances.** -### ⏰ Scheduled Tasks (cron) -### 📊 Job Logs (visualize all background operations) -### 🚀 Start on boot (autostart) and Mount on start (automount) -### 🔒 Set a password for the Settings panel -### 👁️ Hide commands or Remotes from the tray menu -### ⚙️ Import/Export Configs (encrypted with pass or command) -### ✍️ Signed by Microsoft and Notarized on macOS +#### Docker Compose +```yaml +services: + rclone: + image: rclone/rclone + container_name: rclone + command: rcd --rc-addr=0.0.0.0:5572 --rc-no-auth + ports: + - 5572:5572 + volumes: + - ./config:/config/rclone + - /path/to/data:/data +``` + +#### Docker CLI +```bash +docker run -d \ + --name rclone \ + -p 5572:5572 \ + -v ./config:/config/rclone \ + -v /path/to/data:/data \ + rclone/rclone rcd --rc-addr=0.0.0.0:5572 --rc-no-auth +``` + +#### Without Docker +Just start the `rcd` daemon directly: + +```bash +rclone rcd --rc-addr=0.0.0.0:5572 --rc-no-auth +``` + +#### Notes +- After starting up **`rclone`** using your preferred method, simply open Rclone UI and navigate to Settings > Hosts. +- Make sure to allow traffic to port **`5572`** in your firewall and/or reverse proxy (nginx/caddy/traefik). +- Rclone UI can connect to any RCD port, so you can customize the default **`5572`** port. +- Use **`--rc-user`** and **`--rc-pass`** instead of **`--rc-no-auth`** in production. -Rclone UI ## Package Managers - Flathub **`flatpak install com.rcloneui.RcloneUI`** or **[from the store](https://flathub.org/en/apps/com.rcloneui.RcloneUI)** @@ -79,6 +106,7 @@ - Scoop **`scoop bucket add extras`** & **`scoop install rclone-ui`** - Chocolatey **`choco install rclone-ui`** - WinGet **`winget install --id=RcloneUI.RcloneUI -e`** +- NPM **`npx rclone-ui`** ## Downloads - **Windows** (**[Arm](https://get.rcloneui.com/win-arm)**, **[x64](https://get.rcloneui.com/win)**) @@ -100,7 +128,15 @@ ## Contributing -We welcome your contributions! Please open an [issue](https://github.com/FTCHD/rclone-ui/issues/new?template=Blank+issue) or a [PR](https://github.com/FTCHD/rclone-ui/compare), and let's make this better together 🎉🎉 +Welcome, anon. We’ve been expecting you. + +Here are some good problems to tackle: +- Fix an open [**Issue**](https://github.com/rclone-ui/rclone-ui/issues) +- Upgrade repository to Vite 7 & React 19 +- Introduce React Compiler +- Move Cron logic to Rust + +🎁 **Merged PRs receive a Lifetime License!**
diff --git a/README_CHINESE.md b/README_CHINESE.md index 285bad0..0cd79ad 100644 --- a/README_CHINESE.md +++ b/README_CHINESE.md @@ -50,28 +50,49 @@
-## 功能 -### 🖥️ 跨平台:Windows、Linux、macOS(x64、Arm、Apple Silicon) -### ✅ 可配合你现有的 rclone 安装使用(也可为你安装),并在你确认后保持 up-to-date -### 🔗 添加 / 编辑 / 移除 Remotes -### ⚡️ Sync、Copy、Move、Delete、Purge、Serve、Mount 等 -### ⭐️ 为每个 Remote、每种操作(Mount、Sync、Copy 等)分别设置默认参数(flags),节省时间 -### 🎛️ 按需为每次操作自定义参数(flags) -### 📚 内置每个参数的说明,无需再 Google 查询! -### 📂 文件浏览器 -### 📡 代理 + + The GUI for Rclone + -Rclone UI +## Docker/Homelab/服务器使用 +使用 **最简单的方式** 管理远程 **`rclone`** 实例,控制你的服务器、Homelab 或家人的电脑。 -### ⏰ 计划任务(cron) -### 📊 任务日志(可视化所有后台操作) -### 🚀 开机自启(autostart)与启动自动挂载(automount) -### 🔒 为设置面板设置密码 -### 👁️ 可在托盘菜单中隐藏命令或 Remotes -### ⚙️ 导入 / 导出配置(可用 pass 或 command 加密) -### ✍️ 由 Microsoft 签名,macOS 已完成公证(Notarized) +#### Docker Compose +```yaml +services: + rclone: + image: rclone/rclone + container_name: rclone + command: rcd --rc-addr=0.0.0.0:5572 --rc-no-auth + ports: + - 5572:5572 + volumes: + - ./config:/config/rclone + - /path/to/data:/data +``` -Rclone UI +#### Docker CLI +```bash +docker run -d \ + --name rclone \ + -p 5572:5572 \ + -v ./config:/config/rclone \ + -v /path/to/data:/data \ + rclone/rclone rcd --rc-addr=0.0.0.0:5572 --rc-no-auth +``` + +#### 不使用 Docker +直接启动 `rcd` 守护进程: + +```bash +rclone rcd --rc-addr=0.0.0.0:5572 --rc-no-auth +``` + +#### 注意事项 +- 使用你喜欢的方式启动 **`rclone`** 后,打开 Rclone UI 并导航到 Settings > Hosts。 +- 确保在防火墙和/或反向代理(nginx/caddy/traefik)中允许端口 **`5572`** 的流量。 +- Rclone UI 可以连接到任意 RCD 端口,你可以自定义默认的 **`5572`** 端口。 +- 在生产环境中使用 **`--rc-user`** 和 **`--rc-pass`** 而不是 **`--rc-no-auth`**。 ## 包管理器 - Flathub **`flatpak install com.rcloneui.RcloneUI`** 或 **[从商店获取](https://flathub.org/en/apps/com.rcloneui.RcloneUI)** @@ -79,6 +100,7 @@ - Scoop **`scoop bucket add extras`** & **`scoop install rclone-ui`** - Chocolatey **`choco install rclone-ui`** - WinGet **`winget install --id=RcloneUI.RcloneUI -e`** +- NPM **`npx rclone-ui`** ## 下载 - **Windows**(**[Arm](https://get.rcloneui.com/win-arm)**、**[x64](https://get.rcloneui.com/win)**) @@ -100,7 +122,15 @@ ## 参与贡献 -欢迎你的贡献!请提交一个 [issue](https://github.com/FTCHD/rclone-ui/issues/new?template=Blank+issue) 或 [PR](https://github.com/FTCHD/rclone-ui/compare),让我们一起把它做得更好 🎉🎉 +欢迎,anon。我们一直在等你。 + +以下是一些值得解决的问题: +- 修复一个 [**Issue**](https://github.com/rclone-ui/rclone-ui/issues) +- 将仓库升级到 Vite 7 和 React 19 +- 引入 React Compiler +- 将 Cron 逻辑迁移到 Rust + +🎁 **合并的 PR 可获终身许可证!**
diff --git a/README_GERMAN.md b/README_GERMAN.md index 2bc846c..86fc679 100644 --- a/README_GERMAN.md +++ b/README_GERMAN.md @@ -50,28 +50,49 @@
-## Features -### 🖥️ Cross-platform: Windows, Linux, macOS (x64, Arm und Apple Silicon) -### ✅ Funktioniert mit deiner bestehenden rclone-Installation (oder kann rclone für dich installieren) und hält sie auf Wunsch aktuell -### 🔗 Remotes hinzufügen/bearbeiten/entfernen -### ⚡️ Sync, Bisync, Copy, Move, Delete, Purge, Serve, Mount, etc. -### ⭐️ Zeit sparen durch Default Flags pro Remote und pro Operation (Mount, Sync, Copy, etc.) -### 🎛️ Flags (Parameter) bei Bedarf pro Run/Case anpassen -### 📚 Eingebettete Doku zu jedem Flag – kein Googeln nötig! -### 📂 File Browser -### 📡 Proxy + + The GUI for Rclone + -Rclone UI +## Docker/Homelab/Server-Nutzung +Steuere deinen Server, dein Homelab oder den PC deiner Mutter mit **der einfachsten Lösung, Remote-**`rclone`**-Instanzen zu verwalten.** -### ⏰ Scheduled Tasks (cron) -### 📊 Job Logs (alle Hintergrund-Operationen visualisieren) -### 🚀 Start on boot (Autostart) & Mount on start (Automount) -### 🔒 Passwort für das Settings Panel setzen -### 👁️ Commands oder Remotes im Tray Menu ausblenden -### ⚙️ Import/Export Configs (encrypted with pass oder command) -### ✍️ Signed by Microsoft & Notarized on macOS +#### Docker Compose +```yaml +services: + rclone: + image: rclone/rclone + container_name: rclone + command: rcd --rc-addr=0.0.0.0:5572 --rc-no-auth + ports: + - 5572:5572 + volumes: + - ./config:/config/rclone + - /path/to/data:/data +``` -Rclone UI +#### Docker CLI +```bash +docker run -d \ + --name rclone \ + -p 5572:5572 \ + -v ./config:/config/rclone \ + -v /path/to/data:/data \ + rclone/rclone rcd --rc-addr=0.0.0.0:5572 --rc-no-auth +``` + +#### Ohne Docker +Starte den `rcd`-Daemon einfach direkt: + +```bash +rclone rcd --rc-addr=0.0.0.0:5572 --rc-no-auth +``` + +#### Hinweise +- Nachdem du **`rclone`** mit deiner bevorzugten Methode gestartet hast, öffne Rclone UI und navigiere zu Settings > Hosts. +- Stelle sicher, dass der Datenverkehr auf Port **`5572`** in deiner Firewall und/oder deinem Reverse Proxy (nginx/caddy/traefik) erlaubt ist. +- Rclone UI kann sich mit jedem RCD-Port verbinden, sodass du den Standard-Port **`5572`** anpassen kannst. +- Verwende **`--rc-user`** und **`--rc-pass`** anstelle von **`--rc-no-auth`** in der Produktion. ## Package Managers - Flathub **`flatpak install com.rcloneui.RcloneUI`** oder **[aus dem Store](https://flathub.org/en/apps/com.rcloneui.RcloneUI)** @@ -79,6 +100,7 @@ - Scoop **`scoop bucket add extras`** & **`scoop install rclone-ui`** - Chocolatey **`choco install rclone-ui`** - WinGet **`winget install --id=RcloneUI.RcloneUI -e`** +- NPM **`npx rclone-ui`** ## Downloads - **Windows** (**[Arm](https://get.rcloneui.com/win-arm)**, **[x64](https://get.rcloneui.com/win)**) @@ -100,7 +122,15 @@ ## Contributing -Wir freuen uns über Beiträge! Öffne einfach ein [issue](https://github.com/FTCHD/rclone-ui/issues/new?template=Blank+issue) oder einen [PR](https://github.com/FTCHD/rclone-ui/compare) – lass uns das gemeinsam besser machen 🎉🎉 +Willkommen, anon. Wir haben dich bereits erwartet. + +Hier ein paar spannende Aufgaben: +- Behebe ein offenes [**Issue**](https://github.com/rclone-ui/rclone-ui/issues) +- Hebe das Repository auf Vite 7 & React 19 an +- Führe den React Compiler ein +- Verlege die Cron-Logik nach Rust + +🎁 **Für gemergte PRs gibt's eine Lifetime-Lizenz!**
diff --git a/README_JAPANESE.md b/README_JAPANESE.md index c9e849f..9d28738 100644 --- a/README_JAPANESE.md +++ b/README_JAPANESE.md @@ -50,28 +50,49 @@
-## 機能 -### 🖥️ Cross-platform:Windows・Linux・macOS(x64 / Arm / Apple Silicon) -### ✅ 既存の rclone 環境でそのまま動作(必要ならインストールも可能)。アップデートはあなたの承認のもとで適用 -### 🔗 Remote の追加/編集/削除 -### ⚡️ Sync・Copy・Move・Delete・Purge・Serve・Mount など -### ⭐️ 操作ごと(Mount/Sync/Copy など)に Remote 単位で default flags を設定して時短 -### 🎛️ 実行時にケースバイケースで flags(パラメータ)をカスタマイズ -### 📚 各 flag のドキュメントを内蔵。ググる手間はもう不要! -### 📂 File Browser -### 📡 Proxy + + The GUI for Rclone + -Rclone UI +## Docker/Homelab/サーバー利用 +**リモート **`rclone`** インスタンスを管理する最も簡単な方法**で、サーバー、Homelab、家族の PC を管理しましょう。 -### ⏰ スケジュール実行(cron) -### 📊 Job Logs(すべてのバックグラウンド処理を可視化) -### 🚀 OS 起動時に自動起動(autostart)・自動マウント(automount) -### 🔒 Settings パネルにパスワードを設定 -### 👁️ トレイメニューからコマンドや Remote を非表示に -### ⚙️ 設定のインポート/エクスポート(`pass` または `command` で暗号化) -### ✍️ Windows は Microsoft 署名済み、macOS は公証(Notarized)済み +#### Docker Compose +```yaml +services: + rclone: + image: rclone/rclone + container_name: rclone + command: rcd --rc-addr=0.0.0.0:5572 --rc-no-auth + ports: + - 5572:5572 + volumes: + - ./config:/config/rclone + - /path/to/data:/data +``` -Rclone UI +#### Docker CLI +```bash +docker run -d \ + --name rclone \ + -p 5572:5572 \ + -v ./config:/config/rclone \ + -v /path/to/data:/data \ + rclone/rclone rcd --rc-addr=0.0.0.0:5572 --rc-no-auth +``` + +#### Docker なしで使う +`rcd` デーモンを直接起動してください: + +```bash +rclone rcd --rc-addr=0.0.0.0:5572 --rc-no-auth +``` + +#### 注意事項 +- お好みの方法で **`rclone`** を起動したら、Rclone UI を開き Settings > Hosts に移動します。 +- ファイアウォールやリバースプロキシ(nginx/caddy/traefik)でポート **`5572`** へのトラフィックを許可してください。 +- Rclone UI は任意の RCD ポートに接続できるので、デフォルトの **`5572`** ポートをカスタマイズできます。 +- 本番環境では **`--rc-no-auth`** の代わりに **`--rc-user`** と **`--rc-pass`** を使用してください。 ## パッケージマネージャー - Flathub **`flatpak install com.rcloneui.RcloneUI`** または **[ストアから](https://flathub.org/en/apps/com.rcloneui.RcloneUI)** @@ -79,6 +100,7 @@ - Scoop **`scoop bucket add extras`** と **`scoop install rclone-ui`** - Chocolatey **`choco install rclone-ui`** - WinGet **`winget install --id=RcloneUI.RcloneUI -e`** +- NPM **`npx rclone-ui`** ## ダウンロード - **Windows**(**[Arm](https://get.rcloneui.com/win-arm)**、**[x64](https://get.rcloneui.com/win)**) @@ -100,7 +122,15 @@ ## コントリビューション -貢献は大歓迎です!気軽に [issue](https://github.com/FTCHD/rclone-ui/issues/new?template=Blank+issue) を作成するか [PR](https://github.com/FTCHD/rclone-ui/compare) を送ってください。いっしょにもっと良いプロジェクトにしていきましょう 🎉🎉 +ようこそ、anon。お待ちしていました。 + +取り組むと良い課題はこちらです: +- オープンな [**Issue**](https://github.com/rclone-ui/rclone-ui/issues) を修正する +- リポジトリを Vite 7 と React 19 にアップグレードする +- React Compiler を導入する +- Cron ロジックを Rust に移行する + +🎁 **マージされた PR にはライフタイムライセンスを贈呈!**
diff --git a/README_PIRATE.md b/README_PIRATE.md index 74b0ea4..ed88d78 100644 --- a/README_PIRATE.md +++ b/README_PIRATE.md @@ -50,28 +50,49 @@
-## Features -### 🖥️ Runs on all decks — Windows, Linux, macOS (x64, Arm, an’ Apple Silicon) -### ✅ Sails with yer existing rclone (or we’ll fetch it fer ye) an’ keeps it shipshape (with yer aye-aye) -### 🔗 Add/Edit/Remove (Scuttle) Remotes -### ⚡️ Sync, Bisync, Copy, Move, Delete, Purge, Serve, Mount—pillagin’ made easy -### ⭐️ Save precious time by settin’ default flags per Remote, per operation (Mount, Sync, Copy, an’ the like) -### 🎛️ Tweak the flags (parrrrrameters) fer each voyage as ye see fit -### 📚 Flag lore be embedded—no need t’ consult the oracle o’ Google! -### 📂 File Hold—er, Browser—fer rummagin’ through yer treasure -### 📡 Proxy—send yer packets through the fog + + The GUI for Rclone + -Rclone UI +## Docker/Homelab/Server, Ye Scallywag +Command yer server, homelab, or yer mum's vessel with **the simplest way t' manage remote **`rclone`** instances, arrr!** -### ⏰ Scheduled Raids (cron) -### 📊 Ship’s Log fer Jobs (spy all background maneuvers) -### 🚀 Weigh anchor on boot (autostart) an’ auto-mount the drives -### 🔒 Guard the Captain’s Quarters (Settings) with a secret word -### 👁️ Keep commands or Remotes hidden from the crow’s-nest tray -### ⚙️ Import/Export Maps (configs) — encrypt ’em with pass or command -### ✍️ Signed by the Crown o’ Microsoft an’ Notarized on macOS—legit as a Letter o’ Marque +#### Docker Compose +```yaml +services: + rclone: + image: rclone/rclone + container_name: rclone + command: rcd --rc-addr=0.0.0.0:5572 --rc-no-auth + ports: + - 5572:5572 + volumes: + - ./config:/config/rclone + - /path/to/data:/data +``` -Rclone UI +#### Docker CLI +```bash +docker run -d \ + --name rclone \ + -p 5572:5572 \ + -v ./config:/config/rclone \ + -v /path/to/data:/data \ + rclone/rclone rcd --rc-addr=0.0.0.0:5572 --rc-no-auth +``` + +#### Without Docker, Ye Landlubber +Just fire up th' `rcd` daemon directly: + +```bash +rclone rcd --rc-addr=0.0.0.0:5572 --rc-no-auth +``` + +#### Notes from the Captain +- After hoisting **`rclone`** using yer preferred method, open Rclone UI an' sail to Settings > Hosts. +- Make certain yer firewall an' reverse proxy (nginx/caddy/traefik) be allowin' traffic on port **`5572`**. +- Rclone UI can dock at any RCD port, so ye can customize the default **`5572`** port as ye please. +- Use **`--rc-user`** an' **`--rc-pass`** instead o' **`--rc-no-auth`** in production waters, matey. ## Package Mangarrs - Flathub **`flatpak install com.rcloneui.RcloneUI`** or **[from th' store](https://flathub.org/en/apps/com.rcloneui.RcloneUI)** @@ -79,6 +100,7 @@ - Scoop **`scoop bucket add extras`** & **`scoop install rclone-ui`** - Chocolatey **`choco install rclone-ui`** - WinGet **`winget install --id=RcloneUI.RcloneUI -e`** +- NPM **`npx rclone-ui`** ## Downloads (hoist the booty!) - **Windows** (**[Arm](https://get.rcloneui.com/win-arm)**, **[x64](https://get.rcloneui.com/win)**) @@ -99,8 +121,16 @@ -## Contribootin’ -We welcome yer code booty! Hoist an [issue](https://github.com/FTCHD/rclone-ui/issues/new?template=Blank+issue) or fire a [PR broadside](https://github.com/FTCHD/rclone-ui/compare), an’ let’s make this vessel swifter together 🎉🏴‍☠️ +## Contribootin' +Ahoy, matey. We been awaitin' yer arrival. + +Some fine plunder t' tackle: +- Patch an open [**Issue**](https://github.com/rclone-ui/rclone-ui/issues) +- Hoist th' repo t' Vite 7 & React 19 +- Bring aboard th' React Compiler +- Shift th' Cron magic over t' Rust + +🎁 **Merged PRs earn ye a Lifetime Letter o' Marque!**
diff --git a/README_POLISH.md b/README_POLISH.md index fab2963..1703a08 100644 --- a/README_POLISH.md +++ b/README_POLISH.md @@ -50,28 +50,49 @@
-## Funkcje -### 🖥️ Cross platform, działa na Windows, Linux, macOS (x64, ARM i Apple Silicon) -### ✅ Działa z istniejącą kopią rclone (lub może ją dla ciebie zainstalować). Za Twoją zgodą może równie aktualizować ją do najnowszej wersji -### 🔗 Dodawanie / edycja / usuwanie zdalnych zasobów (Remotes) -### ⚡️ Wspiera wszystkie operacje: Sync, Bisync, Copy, Move, Delete, Purge, Serve, Mount, itd. -### ⭐️ Oszczędzaj czas ustawiając domyślne flagi osobno dla każdego Remote’a i dla każdej operacji (Mount, Sync, Copy itd.) -### 🎛️ Dostosuj flagi (parametry) podczas uruchamiania operacji – indywidualnie, w zależności od przypadku -### 📚 Wbudowana dokumentacja dla każdej flagi — nie musisz już googlować, co oznacza! -### 📂 Przeglądarka plików -### 📡 Proxy + + The GUI for Rclone + -Rclone UI +## Docker/Homelab/Użycie na serwerze +Kontroluj swój serwer, homelab lub komputer mamy za pomocą **najprostszego rozwiązania do zarządzania zdalnymi instancjami **`rclone`**.** -### ⏰ Zaplanowane zadania (cron) -### 📊 Dzienniki zadań — wizualizacja wszystkich operacji w tle -### 🚀 Uruchamianie przy starcie systemu (autostart) i automatyczne montowanie (automount) -### 🔒 Ustawienie hasła do panelu ustawień -### 👁️ Ukrywanie poleceń lub zdalnych zasobów (Remotes) w menu zasobnika -### ⚙️ Import/Eksport konfiguracji (szyfrowany hasłem lub poleceniem) -### ✍️ Podpisane cyfrowo przez Microsoft i notaryzowane na macOS +#### Docker Compose +```yaml +services: + rclone: + image: rclone/rclone + container_name: rclone + command: rcd --rc-addr=0.0.0.0:5572 --rc-no-auth + ports: + - 5572:5572 + volumes: + - ./config:/config/rclone + - /path/to/data:/data +``` -Rclone UI +#### Docker CLI +```bash +docker run -d \ + --name rclone \ + -p 5572:5572 \ + -v ./config:/config/rclone \ + -v /path/to/data:/data \ + rclone/rclone rcd --rc-addr=0.0.0.0:5572 --rc-no-auth +``` + +#### Bez Dockera +Po prostu uruchom demon `rcd` bezpośrednio: + +```bash +rclone rcd --rc-addr=0.0.0.0:5572 --rc-no-auth +``` + +#### Uwagi +- Po uruchomieniu **`rclone`** wybraną metodą, otwórz Rclone UI i przejdź do Settings > Hosts. +- Upewnij się, że zezwalasz na ruch na porcie **`5572`** w swoim firewallu i/lub reverse proxy (nginx/caddy/traefik). +- Rclone UI może łączyć się z dowolnym portem RCD, więc możesz dostosować domyślny port **`5572`**. +- W środowisku produkcyjnym używaj **`--rc-user`** i **`--rc-pass`** zamiast **`--rc-no-auth`**. ## Package Managers - Flathub **`flatpak install com.rcloneui.RcloneUI`** lub **[ze sklepu](https://flathub.org/en/apps/com.rcloneui.RcloneUI)** @@ -79,6 +100,7 @@ - Scoop **`scoop bucket add extras`** & **`scoop install rclone-ui`** - Chocolatey **`choco install rclone-ui`** - WinGet **`winget install --id=RcloneUI.RcloneUI -e`** +- NPM **`npx rclone-ui`** ## Link do pobrania - **Windows** (**[Arm](https://get.rcloneui.com/win-arm)**, **[x64](https://get.rcloneui.com/win)**) @@ -100,7 +122,15 @@ ## Chcesz pomóc? -Zapraszamy do współpracy! Otwórz [zgłoszenie](https://github.com/FTCHD/rclone-ui/issues/new?template=Blank+issue) lub [PR](https://github.com/FTCHD/rclone-ui/compare), i wspólnie ulepszmy Rclone 🎉🎉 +Witaj, anon. Spodziewaliśmy się Ciebie. + +Oto kilka ciekawych wyzwań: +- Napraw otwarty [**Issue**](https://github.com/rclone-ui/rclone-ui/issues) +- Zaktualizuj repozytorium do Vite 7 i React 19 +- Wprowadź React Compiler +- Przenieś logikę Cron do Rust + +🎁 **Zmergowane PR-y otrzymują licencję dożywotnią!**
diff --git a/README_ROMANIAN.md b/README_ROMANIAN.md index ca33e07..e2b136a 100644 --- a/README_ROMANIAN.md +++ b/README_ROMANIAN.md @@ -50,28 +50,49 @@
-## Features -### 🖥️ Toate Platformele: Windows, Linux, macOS (x64, Arm și Apple Silicon) -### ✅ Funcționează cu instalarea ta existentă de rclone (sau îl poate instala pentru tine) și îl menține up-to-date (cu aprobarea ta) -### 🔗 Adaugă/Editează/Șterge Remotes -### ⚡️ Sync, Bisync, Copy, Move, Delete, Purge, Serve, Mount etc. -### ⭐️ Economisește timp setând default flags pentru fiecare Remote, separat pentru fiecare operațiune (Mount, Sync, Copy etc.) -### 🎛️ Personalizează flags când rulezi o operațiune, de la caz la caz -### 📚 Documentație integrată pentru fiecare flag — nu mai trebuie să dai search pe Google ca să afli ce face! -### 📂 File Browser -### 📡 Proxy + + The GUI for Rclone + -Rclone UI +## Docker/Homelab/Server +Controlează serverul, homelab-ul sau PC-ul mamei tale cu **cea mai simplă soluție pentru a gestiona instanțe **`rclone`** la distanță.** -### ⏰ Sarcini programate (cron) -### 📊 Job Logs (vizualizează toate operațiunile în background) -### 🚀 Start on boot (autostart) și Mount on start (automount) -### 🔒 Setează o parolă pentru panoul de Settings -### 👁️ Ascunde comenzile sau Remotes din meniul tray -### ⚙️ Import/Export Configs (criptate cu `pass` sau `command`) -### ✍️ Signed by Microsoft și Notarized pe macOS +#### Docker Compose +```yaml +services: + rclone: + image: rclone/rclone + container_name: rclone + command: rcd --rc-addr=0.0.0.0:5572 --rc-no-auth + ports: + - 5572:5572 + volumes: + - ./config:/config/rclone + - /path/to/data:/data +``` -Rclone UI +#### Docker CLI +```bash +docker run -d \ + --name rclone \ + -p 5572:5572 \ + -v ./config:/config/rclone \ + -v /path/to/data:/data \ + rclone/rclone rcd --rc-addr=0.0.0.0:5572 --rc-no-auth +``` + +#### Fără Docker +Pornește daemon-ul `rcd` direct: + +```bash +rclone rcd --rc-addr=0.0.0.0:5572 --rc-no-auth +``` + +#### Note +- După pornirea **`rclone`** folosind metoda preferată, deschide Rclone UI și navighează la Settings > Hosts. +- Asigură-te că permiți traficul pe portul **`5572`** în firewall-ul tău și/sau reverse proxy (nginx/caddy/traefik). +- Rclone UI se poate conecta la orice port RCD, astfel încât poți personaliza portul implicit **`5572`**. +- Folosește **`--rc-user`** și **`--rc-pass`** în loc de **`--rc-no-auth`** în producție. ## Package Managers - Flathub **`flatpak install com.rcloneui.RcloneUI`** sau **[din magazin](https://flathub.org/en/apps/com.rcloneui.RcloneUI)** @@ -79,6 +100,7 @@ - Scoop **`scoop bucket add extras`** & **`scoop install rclone-ui`** - Chocolatey **`choco install rclone-ui`** - WinGet **`winget install --id=RcloneUI.RcloneUI -e`** +- NPM **`npx rclone-ui`** ## Descarcă - **Windows** (**[Arm](https://get.rcloneui.com/win-arm)**, **[x64](https://get.rcloneui.com/win)**) @@ -100,7 +122,15 @@ ## Contribuții -Apreciem contribuțiile tale! Deschide un [issue](https://github.com/FTCHD/rclone-ui/issues/new?template=Blank+issue) sau un [PR](https://github.com/FTCHD/rclone-ui/compare) și hai să facem Rclone UI și mai bun împreună 🎉🎉 +Bine ai venit, anon. Te-așteptam. + +Iată câteva probleme bune de abordat: +- Rezolvă un [**Issue**](https://github.com/rclone-ui/rclone-ui/issues) deschis +- Actualizează repository-ul la Vite 7 & React 19 +- Introdu React Compiler +- Mută logica Cron în Rust + +🎁 **PR-urile acceptate primesc o licență gratis!**
diff --git a/README_SPANISH.md b/README_SPANISH.md index 1e03075..d79e038 100644 --- a/README_SPANISH.md +++ b/README_SPANISH.md @@ -50,28 +50,49 @@
-## Funciones -### 🖥️ Multiplataforma: Windows, Linux, macOS (x64, Arm y Apple Silicon) -### ✅ Funciona con tu instalación existente de rclone (o puede instalar rclone por ti) y lo mantiene actualizado (con tu aprobación) -### 🔗 Añadir/Editar/Eliminar Remotes -### ⚡️ Sync, Bisync, Copy, Move, Delete, Purge, Serve, Mount, etc. -### ⭐️ Ahorra tiempo definiendo flags predeterminados por Remote y por operación (Mount, Sync, Copy, etc.) -### 🎛️ Personaliza los flags (parámetros) al ejecutar una operación, caso por caso -### 📚 Documentación integrada para cada flag; ¡sin tener que googlear qué hace cada cosa! -### 📂 Explorador de archivos -### 📡 Proxy + + The GUI for Rclone + -Rclone UI +## Docker/Homelab/Servidor +Controla tu servidor, homelab o el PC de tu madre con **la solución más fácil para gestionar instancias remotas de **`rclone`**.** -### ⏰ Tareas programadas (cron) -### 📊 Registros de trabajos (visualiza todas las operaciones en segundo plano) -### 🚀 Inicio con el sistema (autostart) y montaje al iniciar (automount) -### 🔒 Protege el panel de Configuración con contraseña -### 👁️ Oculta comandos o Remotes del menú de la bandeja del sistema (tray) -### ⚙️ Import/Export de configuraciones (cifradas con pass o command) -### ✍️ Firmado por Microsoft y notarizado en macOS +#### Docker Compose +```yaml +services: + rclone: + image: rclone/rclone + container_name: rclone + command: rcd --rc-addr=0.0.0.0:5572 --rc-no-auth + ports: + - 5572:5572 + volumes: + - ./config:/config/rclone + - /path/to/data:/data +``` -Rclone UI +#### Docker CLI +```bash +docker run -d \ + --name rclone \ + -p 5572:5572 \ + -v ./config:/config/rclone \ + -v /path/to/data:/data \ + rclone/rclone rcd --rc-addr=0.0.0.0:5572 --rc-no-auth +``` + +#### Sin Docker +Simplemente inicia el daemon `rcd` directamente: + +```bash +rclone rcd --rc-addr=0.0.0.0:5572 --rc-no-auth +``` + +#### Notas +- Después de iniciar **`rclone`** usando tu método preferido, abre Rclone UI y navega a Settings > Hosts. +- Asegúrate de permitir el tráfico en el puerto **`5572`** en tu firewall y/o proxy inverso (nginx/caddy/traefik). +- Rclone UI puede conectarse a cualquier puerto RCD, por lo que puedes personalizar el puerto predeterminado **`5572`**. +- Usa **`--rc-user`** y **`--rc-pass`** en lugar de **`--rc-no-auth`** en producción. ## Package Managers - Flathub **`flatpak install com.rcloneui.RcloneUI`** o **[desde la tienda](https://flathub.org/en/apps/com.rcloneui.RcloneUI)** @@ -79,6 +100,7 @@ - Scoop **`scoop bucket add extras`** & **`scoop install rclone-ui`** - Chocolatey **`choco install rclone-ui`** - WinGet **`winget install --id=RcloneUI.RcloneUI -e`** +- NPM **`npx rclone-ui`** ## Descargas - **Windows** (**[Arm](https://get.rcloneui.com/win-arm)**, **[x64](https://get.rcloneui.com/win)**) @@ -100,7 +122,15 @@ ## Contribuciones -¡Tus contribuciones son bienvenidas! Abre un [issue](https://github.com/FTCHD/rclone-ui/issues/new?template=Blank+issue) o un [PR](https://github.com/FTCHD/rclone-ui/compare) y mejoremos esto juntos 🎉🎉 +Bienvenido, anon. Te estábamos esperando. + +Aquí tienes algunos retos interesantes: +- Arregla un [**Issue**](https://github.com/rclone-ui/rclone-ui/issues) abierto +- Actualiza el repositorio a Vite 7 y React 19 +- Introduce React Compiler +- Mueve la lógica de Cron a Rust + +🎁 **¡Los PR fusionados reciben una licencia de por vida!**
diff --git a/biome.json b/biome.json index e1caaac..861e0bb 100644 --- a/biome.json +++ b/biome.json @@ -14,10 +14,12 @@ "complexity": { "all": true, "noUselessTypeConstraint": "off", - "useSimplifiedLogicExpression": "off" + "useSimplifiedLogicExpression": "off", + "noExcessiveCognitiveComplexity": "off" }, "correctness": { - "all": true + "all": true, + "useImportExtensions": "off" }, "performance": { "all": true diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000..9eb22ef --- /dev/null +++ b/cli/README.md @@ -0,0 +1,42 @@ +

+ + Rclone UI + +
+ + The cross-platform GUI for Rclone + +

+ +Tnstalls and launches [**Rclone UI**](https://rcloneui.com). + +## Usage + +```bash +npx rclone-ui +``` + +That's it! + +If Rclone UI is already installed, it opens the app. If not, it downloads the latest release from GitHub and installs it. + +## Options + +```bash +npx rclone-ui # Open Rclone UI (installs if not found) +npx rclone-ui --install # Reinstall (or update) +npx rclone-ui --version # Show version +npx rclone-ui --help # Show help +``` + +## How it works + +- **macOS**: Mounts the `.dmg` and copies the app to `/Applications` +- **Windows**: Runs the NSIS installer in silent mode +- **Linux**: Installs the AppImage to `~/.local/bin` + +## Links + +- [Website](https://rcloneui.com) +- [GitHub](https://github.com/rclone-ui/rclone-ui) +- [Releases](https://github.com/rclone-ui/rclone-ui/releases) diff --git a/cli/bin/rclone-ui.js b/cli/bin/rclone-ui.js new file mode 100644 index 0000000..f06d536 --- /dev/null +++ b/cli/bin/rclone-ui.js @@ -0,0 +1,124 @@ +#!/usr/bin/env node + +import readline from 'node:readline' +import { isInstalled } from '../lib/detect.js' +import { download, install } from '../lib/install.js' +import { openApp } from '../lib/open.js' +import { getLatestVersion } from '../lib/platform.js' + +function waitForEnter(prompt) { + return new Promise((resolve) => { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }) + + rl.question(prompt, () => { + rl.close() + resolve() + }) + }) +} + +function clearLine() { + process.stdout.write('\r\x1b[K') +} + +function formatBytes(bytes) { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` +} + +async function main() { + const args = process.argv.slice(2) + + // Handle --version + if (args.includes('--version') || args.includes('-v')) { + const version = await getLatestVersion() + console.log(`rclone-ui v${version} (latest)`) + process.exit(0) + } + + // Handle --help + if (args.includes('--help') || args.includes('-h')) { + console.log(` +rclone-ui - The GUI for rclone + +Usage: + npx rclone-ui Open Rclone UI (installs if not found) + npx rclone-ui --install Reinstall (or update) + npx rclone-ui --version Show version + npx rclone-ui --help Show this help + +More info: https://rcloneui.com +`) + process.exit(0) + } + + const forceInstall = args.includes('--install') + + // Check if already installed + if (isInstalled() && !forceInstall) { + console.log('Opening Rclone UI...') + try { + await openApp() + process.exit(0) + } catch (err) { + console.error('Failed to open Rclone UI:', err.message) + process.exit(1) + } + } + + // Not installed - prompt for installation + console.log('') + await waitForEnter( + 'Rclone UI is not installed. Press Enter to install the latest release from GitHub...' + ) + console.log('') + + // Download + process.stdout.write('Downloading...') + let installerPath + try { + installerPath = await download((downloaded, total) => { + clearLine() + const percent = Math.round((downloaded / total) * 100) + const downloadedStr = formatBytes(downloaded) + const totalStr = formatBytes(total) + process.stdout.write(`Downloading... ${percent}% (${downloadedStr} / ${totalStr})`) + }) + clearLine() + console.log('Downloading... Done!') + } catch (err) { + clearLine() + console.error('Download failed:', err.message) + process.exit(1) + } + + // Install + console.log('Installing...') + try { + await install(installerPath) + console.log('Installing... Done!') + } catch (err) { + console.error('Installation failed:', err.message) + console.error('You may need to run with elevated permissions (sudo on macOS/Linux).') + process.exit(1) + } + + console.log('') + await waitForEnter('Installed! Press Enter to run Rclone UI...') + + try { + await openApp() + } catch (err) { + console.error('Failed to open Rclone UI:', err.message) + process.exit(1) + } +} + +main().catch((err) => { + console.error('Error:', err.message) + process.exit(1) +}) diff --git a/cli/lib/detect.js b/cli/lib/detect.js new file mode 100644 index 0000000..678f215 --- /dev/null +++ b/cli/lib/detect.js @@ -0,0 +1,37 @@ +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { getPlatform } from "./platform.js"; + +export function getInstallPath() { + const { isMac, isWindows, isLinux } = getPlatform(); + + if (isMac) { + return "/Applications/Rclone UI.app"; + } + + if (isWindows) { + // NSIS installs to %LOCALAPPDATA%\Rclone UI + const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local"); + return path.join(localAppData, "Rclone UI", "Rclone UI.exe"); + } + + if (isLinux) { + // AppImage installed to ~/.local/bin + return path.join(os.homedir(), ".local", "bin", "rclone-ui"); + } + + return null; +} + +export function isInstalled() { + const installPath = getInstallPath(); + if (!installPath) return false; + + try { + return fs.existsSync(installPath); + } catch { + return false; + } +} + diff --git a/cli/lib/install.js b/cli/lib/install.js new file mode 100644 index 0000000..2ff6e35 --- /dev/null +++ b/cli/lib/install.js @@ -0,0 +1,163 @@ +import { execSync, spawn } from 'node:child_process' +import fs from 'node:fs' +import https from 'node:https' +import os from 'node:os' +import path from 'node:path' +import { getDownloadUrl, getPlatform } from './platform.js' + +function followRedirects(url, callback) { + https.get(url, (response) => { + if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) { + followRedirects(response.headers.location, callback) + } else { + callback(response) + } + }) +} + +export function download(onProgress) { + return new Promise((resolve, reject) => { + const { url, filename } = getDownloadUrl() + const tempDir = os.tmpdir() + const filePath = path.join(tempDir, filename) + + followRedirects(url, (response) => { + if (response.statusCode !== 200) { + reject(new Error(`Failed to download: ${response.statusCode}`)) + return + } + + const totalSize = Number.parseInt(response.headers['content-length'], 10) + let downloadedSize = 0 + + const file = fs.createWriteStream(filePath) + + response.on('data', (chunk) => { + downloadedSize += chunk.length + if (onProgress && totalSize) { + onProgress(downloadedSize, totalSize) + } + }) + + response.pipe(file) + + file.on('finish', () => { + file.close() + resolve(filePath) + }) + + file.on('error', (err) => { + fs.unlink(filePath, () => {}) + reject(err) + }) + }) + }) +} + +export async function install(installerPath) { + const { isMac, isWindows, isLinux } = getPlatform() + const { type } = getDownloadUrl() + + if (isMac && type === 'dmg') { + return installMacDmg(installerPath) + } + + if (isWindows && type === 'exe') { + return installWindowsExe(installerPath) + } + + if (isLinux && type === 'appimage') { + return installLinuxAppImage(installerPath) + } + + throw new Error(`Unsupported installer type: ${type}`) +} + +async function installMacDmg(dmgPath) { + // Mount the DMG + const mountOutput = execSync(`hdiutil attach -nobrowse -readonly "${dmgPath}"`, { + encoding: 'utf-8', + }) + + // Parse mount point from output + const mountMatch = mountOutput.match(/\/Volumes\/[^\n]+/) + if (!mountMatch) { + throw new Error('Failed to mount DMG') + } + const mountPoint = mountMatch[0].trim() + + try { + // Find the .app in the mounted volume + const apps = fs.readdirSync(mountPoint).filter((f) => f.endsWith('.app')) + if (apps.length === 0) { + throw new Error('No .app found in DMG') + } + + const appName = apps[0] + const sourcePath = path.join(mountPoint, appName) + const destPath = path.join('/Applications', appName) + + // Remove existing installation if present + if (fs.existsSync(destPath)) { + fs.rmSync(destPath, { recursive: true, force: true }) + } + + // Copy to Applications + execSync(`cp -R "${sourcePath}" "/Applications/"`, { encoding: 'utf-8' }) + } finally { + // Unmount the DMG + try { + execSync(`hdiutil detach "${mountPoint}" -quiet`, { encoding: 'utf-8' }) + } catch { + // Ignore unmount errors + } + } +} + +async function installWindowsExe(exePath) { + return new Promise((resolve, reject) => { + // Run NSIS installer with /S for silent mode + const child = spawn(exePath, ['/S'], { + stdio: 'ignore', + shell: true, + }) + + child.on('error', reject) + child.on('close', (code) => { + if (code === 0) { + resolve() + } else { + reject(new Error(`Installer exited with code ${code}`)) + } + }) + }) +} + +async function installLinuxAppImage(appImagePath) { + const homeDir = os.homedir() + const binDir = path.join(homeDir, '.local', 'bin') + const destPath = path.join(binDir, 'rclone-ui') + + // Ensure ~/.local/bin exists + fs.mkdirSync(binDir, { recursive: true }) + + // Copy AppImage to destination + fs.copyFileSync(appImagePath, destPath) + + // Make executable + fs.chmodSync(destPath, 0o755) + + // Create .desktop file for application menu + const desktopDir = path.join(homeDir, '.local', 'share', 'applications') + fs.mkdirSync(desktopDir, { recursive: true }) + + const desktopEntry = `[Desktop Entry] +Name=Rclone UI +Exec=${destPath} +Type=Application +Categories=Utility; +Comment=The GUI for rclone +` + + fs.writeFileSync(path.join(desktopDir, 'rclone-ui.desktop'), desktopEntry) +} diff --git a/cli/lib/open.js b/cli/lib/open.js new file mode 100644 index 0000000..a38d9af --- /dev/null +++ b/cli/lib/open.js @@ -0,0 +1,40 @@ +import { spawn } from "node:child_process"; +import { getPlatform } from "./platform.js"; +import { getInstallPath } from "./detect.js"; + +export function openApp() { + const { isMac, isWindows, isLinux } = getPlatform(); + const installPath = getInstallPath(); + + return new Promise((resolve, reject) => { + let child; + + if (isMac) { + child = spawn("open", ["-a", "Rclone UI"], { + detached: true, + stdio: "ignore", + }); + } else if (isWindows) { + child = spawn(installPath, [], { + detached: true, + stdio: "ignore", + shell: true, + }); + } else if (isLinux) { + child = spawn(installPath, [], { + detached: true, + stdio: "ignore", + }); + } else { + reject(new Error("Unsupported platform")); + return; + } + + child.unref(); + child.on("error", reject); + + // Give it a moment to spawn, then resolve + setTimeout(() => resolve(), 500); + }); +} + diff --git a/cli/lib/platform.js b/cli/lib/platform.js new file mode 100644 index 0000000..1fee4c5 --- /dev/null +++ b/cli/lib/platform.js @@ -0,0 +1,89 @@ +import https from 'node:https' +import os from 'node:os' + +const GITHUB_REPO = 'rclone-ui/rclone-ui' + +export function getLatestVersion() { + return new Promise((resolve, reject) => { + const options = { + hostname: 'api.github.com', + path: `/repos/${GITHUB_REPO}/releases/latest`, + headers: { 'User-Agent': 'rclone-ui-npm' }, + } + + https + .get(options, (res) => { + let data = '' + res.on('data', (chunk) => (data += chunk)) + res.on('end', () => { + try { + const release = JSON.parse(data) + const version = release.tag_name?.replace(/^v/, '') || 'unknown' + resolve(version) + } catch { + resolve('unknown') + } + }) + }) + .on('error', () => resolve('unknown')) + }) +} + +export function getPlatform() { + const platform = os.platform() + const arch = os.arch() + + return { + os: platform, + arch: arch, + isWindows: platform === 'win32', + isMac: platform === 'darwin', + isLinux: platform === 'linux', + isArm: arch === 'arm64', + isX64: arch === 'x64', + } +} + +export function getDownloadUrl(version = 'latest') { + const { os: platform, arch } = getPlatform() + const tag = version === 'latest' ? 'latest/download' : `download/v${version}` + const base = `https://github.com/${GITHUB_REPO}/releases/${tag}` + + // Map platform + arch to release filename + if (platform === 'darwin') { + // macOS + const archSuffix = arch === 'arm64' ? 'aarch64' : 'x64' + return { + url: `${base}/Rclone.UI_${archSuffix}.dmg`, + filename: `Rclone.UI_${archSuffix}.dmg`, + type: 'dmg', + } + } + + if (platform === 'win32') { + // Windows + const archSuffix = arch === 'arm64' ? 'arm64' : 'x64' + return { + url: `${base}/Rclone.UI_${archSuffix}.exe`, + filename: `Rclone.UI_${archSuffix}.exe`, + type: 'exe', + } + } + + if (platform === 'linux') { + // Linux - prefer AppImage for portability + const archSuffix = arch === 'arm64' ? 'aarch64' : 'amd64' + return { + url: `${base}/Rclone.UI_${archSuffix}.AppImage`, + filename: `Rclone.UI_${archSuffix}.AppImage`, + type: 'appimage', + } + } + + throw new Error(`Unsupported platform: ${platform} ${arch}`) +} + +export function getAppName() { + const { isMac } = getPlatform() + return isMac ? 'Rclone UI.app' : 'Rclone UI' +} diff --git a/cli/package.json b/cli/package.json new file mode 100644 index 0000000..63b5dbf --- /dev/null +++ b/cli/package.json @@ -0,0 +1,23 @@ +{ + "name": "rclone-ui", + "version": "3.0.1", + "description": "Installs and launches Rclone UI", + "bin": { + "rclone-ui": "bin/rclone-ui.js" + }, + "type": "module", + "engines": { + "node": ">=18" + }, + "repository": { + "type": "git", + "url": "https://github.com/rclone-ui/rclone-ui" + }, + "homepage": "https://rcloneui.com", + "keywords": ["rclone", "gui", "drive", "s3", "cloud", "storage", "sync", "backup"], + "license": "MIT", + "author": { + "name": "Rclone UI", + "url": "https://github.com/rclone-ui/rclone-ui" + } +} diff --git a/index.html b/index.html index e837a1c..a76a74f 100644 --- a/index.html +++ b/index.html @@ -1,10 +1,26 @@ - + Rclone UI + + diff --git a/lib/deep.ts b/lib/deep.ts new file mode 100644 index 0000000..0ea313a --- /dev/null +++ b/lib/deep.ts @@ -0,0 +1,19 @@ +import { openWindow } from './window' + +export function getDeepLinkUrl(url: string) { + let cleanedUrl = url.replace('rclone:', '') + while (cleanedUrl.startsWith('/')) { + cleanedUrl = cleanedUrl.slice(1) + } + return cleanedUrl +} + +export function handleDeepLinkUrl(url: string) { + console.log('deep link url', url) + + const domain = url.split('/')[0] + + if (domain === 'add-template') { + return openWindow({ name: 'Templates', url: '/templates?action=add' }) + } +} diff --git a/lib/flags.ts b/lib/flags.ts new file mode 100644 index 0000000..6e40166 --- /dev/null +++ b/lib/flags.ts @@ -0,0 +1,127 @@ +import type { FlagValue } from '../types/rclone' +import { SERVE_TYPES } from './rclone/constants' + +export const FLAG_CATEGORIES = [ + 'copy', + 'sync', + 'config', + 'vfs', + 'filter', + 'mount', + ...SERVE_TYPES.map((type) => `serve.${type}` as const), +] as const + +function returnFlag( + category: (typeof FLAG_CATEGORIES)[number], + flag: string +): { category: (typeof FLAG_CATEGORIES)[number]; flag: string } { + return { + category, + flag, + } +} + +export function getFlagCategory( + flag: string, + flags: Record +) { + console.log('[getFlagCategory] flag', flag) + const normalizedFlag = (flag.startsWith('--') ? flag.slice(2) : flag).replace(/-/g, '_') + + console.log('[getFlagCategory] normalized flag', normalizedFlag) + let foundFlag = null + + foundFlag = flags.main.find((f) => f.Name === normalizedFlag) + + if (foundFlag) { + if (foundFlag.Groups?.includes('Copy')) { + return returnFlag('copy', normalizedFlag) + } + if (foundFlag.Groups?.includes('Sync')) { + return returnFlag('sync', normalizedFlag) + } + return returnFlag('config', normalizedFlag) + } + + foundFlag = flags.vfs.find((f) => f.Name === normalizedFlag) + if (foundFlag) { + return returnFlag('vfs', normalizedFlag) + } + + foundFlag = flags.filter.find((f) => f.Name === normalizedFlag) + if (foundFlag) { + return returnFlag('filter', normalizedFlag) + } + + foundFlag = flags.mount.find((f) => f.Name === normalizedFlag) + if (foundFlag) { + return returnFlag('mount', normalizedFlag) + } + + for (const serveType of SERVE_TYPES) { + foundFlag = flags[serveType].find((f) => f.Name === normalizedFlag) + if (foundFlag) { + return returnFlag(`serve.${serveType}`, normalizedFlag) + } + } + + return null +} + +export function sortByName(flag1: { Name: string }, flag2: { Name: string }) { + return flag1.Name.localeCompare(flag2.Name) +} + +export const getOptionsSubtitle = (count: number) => + count > 0 ? `${count} option${count !== 1 ? 's' : ''} set` : undefined + +export const getJsonKeyCount = (json: string) => { + try { + const parsed = JSON.parse(json) as Record + return Object.keys(parsed).length + } catch { + return 0 + } +} + +export function groupByCategory( + flags: Record, + allFlags: Record +) { + const collectedFlags = { + mount: {} as Record, + config: {} as Record, + vfs: {} as Record, + filter: {} as Record, + copy: {} as Record, + sync: {} as Record, + serve: { + ...SERVE_TYPES.reduce( + (acc, type) => { + acc[type] = {} + return acc + }, + {} as Record<(typeof SERVE_TYPES)[number], Record> + ), + }, + } + + for (const [k, v] of Object.entries(flags)) { + const category = getFlagCategory(k, allFlags) + if (!category) continue + if (category.category.startsWith('serve.')) { + const serveType = category.category.slice(5) as (typeof SERVE_TYPES)[number] + collectedFlags.serve[serveType] = { + ...collectedFlags.serve[serveType], + [k]: v, + } + continue + } + collectedFlags[category.category as keyof Omit] = { + ...collectedFlags[category.category as keyof Omit], + [k]: v, + } + } + + return collectedFlags +} diff --git a/lib/format.ts b/lib/format.ts index 9606db4..9218c2f 100644 --- a/lib/format.ts +++ b/lib/format.ts @@ -1,19 +1,24 @@ import { sep } from '@tauri-apps/api/path' +const RE_WINDOWS_DRIVE = /^[a-zA-Z]:[/\\]/ +const RE_WINDOWS_DRIVE_WITH_SLASH = /^([a-zA-Z]:)\/?/ +const RE_LOCAL_WINDOWS_PATH = /^:local:([a-zA-Z]:\/?.*)$/ +const RE_PATH_SEPARATOR = /[/\\]/ + export function formatBytes(bytes: number) { if (bytes < 1024) { return `${bytes} B` } if (bytes < 1024 * 1024) { - return `${(bytes / 1024).toFixed(2)} KB` + return `${Number.parseFloat((bytes / 1024).toFixed(2))} KB` } if (bytes < 1024 * 1024 * 1024) { - return `${(bytes / 1024 / 1024).toFixed(2)} MB` + return `${Number.parseFloat((bytes / 1024 / 1024).toFixed(2))} MB` } - return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB` + return `${Number.parseFloat((bytes / 1024 / 1024 / 1024).toFixed(2))} GB` } export function replaceSmartQuotes(value: string) { @@ -30,15 +35,26 @@ export function replaceSmartQuotes(value: string) { export function getRemoteName(path?: string) { if (!path?.includes(':')) return null // Return null for local paths + if (path.startsWith(':local:')) { + return ':local' + } + // Windows drive letter check (C:\, D:/, etc.) + if (RE_WINDOWS_DRIVE.test(path)) { + return null + } return path.split(':')[0] } export function buildReadablePath(path: string, type: 'short' | 'long' = 'long') { + console.log('[buildReadablePath] path', path) + console.log('[buildReadablePath] sep()', sep()) + if (!path) { return '' } - const lastSegment = path.split(sep()).slice(-1).join('') + const lastSegment = path.split(RE_PATH_SEPARATOR).filter(Boolean).pop() + console.log('[buildReadablePath] lastSegment', lastSegment) if (type === 'short') { return lastSegment @@ -47,6 +63,34 @@ export function buildReadablePath(path: string, type: 'short' | 'long' = 'long') return `${path.split(':')[0]}:/.../${lastSegment}` } +export function buildReadablePathMultiple( + paths: string[], + type: 'short' | 'long', + truncate: boolean = false +) { + console.log('[buildReadablePathMultiple] paths', paths) + + if (paths.length < 2) { + return buildReadablePath(paths[0], type) + } + + const readablePath = `${buildReadablePath(paths[0], type)} + ${paths.length - 1} more` + + if (truncate) { + let [start, end] = readablePath.split(' + ') + + if (start.length <= 30) { + return readablePath + } + + start = start.slice(0, 47) + '...' + + return `${start} + ${end}` + } + + return readablePath +} + export function getConfigParentFolder(path: string) { console.log('[getConfigParentFolder] path', path) if (path.endsWith('\\rclone.conf')) { @@ -62,3 +106,77 @@ export function getConfigParentFolder(path: string) { console.log('[getConfigParentFolder] path does not end with \\rclone.conf or /rclone.conf') return path } + +export function getFsInfo(fs: string, sep = '/') { + let normalizedFs = fs.replace(/\\/g, '/') + + const localWindowsMatch = normalizedFs.match(RE_LOCAL_WINDOWS_PATH) + if (localWindowsMatch) { + normalizedFs = localWindowsMatch[1] + } + + console.log('[getFsInfo] ', normalizedFs) + + const fsRemote = getRemoteName(normalizedFs) + console.log('[getFsInfo] ', normalizedFs, 'fsRemote', fsRemote) + + let root: string + let path: string + + if (fsRemote) { + root = `${fsRemote}:` + path = normalizedFs.replace(fsRemote, '').split(':')[1] + } else { + const windowsDriveMatch = normalizedFs.match(RE_WINDOWS_DRIVE_WITH_SLASH) + if (windowsDriveMatch) { + root = `:local:${windowsDriveMatch[1]}/` + path = normalizedFs.slice(windowsDriveMatch[0].length) + } else { + root = ':local:' + path = normalizedFs + } + } + + console.log('[getFsInfo] ', normalizedFs, 'root', root) + console.log('[getFsInfo] ', normalizedFs, 'path', path) + + let type = 'file' + + if (normalizedFs.endsWith('/') || normalizedFs.endsWith('\\')) { + type = 'folder' + } + + while (path.startsWith('/')) { + path = path.slice(1) + } + + while (path.endsWith('/') || path.endsWith('\\')) { + path = path.slice(0, -1) + } + + const dirPath = `${path}${sep}` + + const name = path.split('/').pop()! + + console.log('[getFsInfo] ', normalizedFs, 'name', name) + console.log('[getFsInfo] ', normalizedFs, 'fullFilePath', `${root}${path}`) + console.log('[getFsInfo] ', normalizedFs, 'fullDirPath', `${root}${dirPath}`) + console.log('[getFsInfo] ', normalizedFs, 'type', type) + console.log('[getFsInfo] ', normalizedFs, 'remoteName', fsRemote || ':local') + console.log('[getFsInfo] ', normalizedFs, 'isRemote', !!fsRemote) + console.log('[getFsInfo] ', normalizedFs, 'root', root || '/') + console.log('[getFsInfo] ', normalizedFs, 'filePath', path) + console.log('[getFsInfo] ', normalizedFs, 'dirPath', dirPath) + + return { + isRemote: !!fsRemote, + root, + filePath: path, + dirPath, + fullFilePath: `${root}${path}`, + fullDirPath: `${root}${dirPath}`, + name, + type, + remoteName: fsRemote || ':local', + } +} diff --git a/lib/hooks.ts b/lib/hooks.ts new file mode 100644 index 0000000..7fb3097 --- /dev/null +++ b/lib/hooks.ts @@ -0,0 +1,81 @@ +import { useQuery } from '@tanstack/react-query' +import { sortByName } from './flags' +import rclone from './rclone/client' +import { SERVE_TYPES } from './rclone/constants' + +export function useFlags() { + const allFlagsQuery = useQuery({ + queryKey: ['options', 'all'], + queryFn: async () => await rclone('/options/info'), + }) + + const globalFlagsQuery = useQuery({ + queryKey: ['options', 'global'], + queryFn: async () => await rclone('/options/get'), + }) + + const globalFlags = globalFlagsQuery.data + const allFlags = allFlagsQuery.data + + const filterFlags = allFlags?.filter + .filter((flag) => !flag.Groups?.includes('Metadata')) + .sort(sortByName) + + const configFlags = allFlags?.main + .filter( + (flag) => + flag.Groups?.includes('Performance') || + flag.Groups?.includes('Listing') || + flag.Groups?.includes('Networking') || + flag.Groups?.includes('Check') || + flag.Name === 'use_server_modtime' + ) + .sort(sortByName) + + const mountFlags = allFlags?.mount + .filter((flag) => !flag.Groups?.includes('Metadata')) + .sort(sortByName) + + const vfsFlags = allFlags?.vfs + .filter((flag) => !flag.Groups?.includes('Metadata')) + .sort(sortByName) + + const copyFlags = allFlags?.main + .filter((flag) => flag.Groups?.includes('Copy')) + .sort(sortByName) + + const syncFlags = allFlags?.main + .filter((flag) => flag.Groups?.includes('Copy') || flag.Groups?.includes('Sync')) + .sort(sortByName) + + const serveFlags = SERVE_TYPES.reduce( + (acc, type) => { + acc[type] = + allFlags?.[type] || + [] + .map((flag: any) => ({ + ...flag, + FieldName: flag.Name, + DefaultStr: + flag.Name === 'addr' + ? flag.DefaultStr.replace('[', '').replace(']', '') + : flag.DefaultStr, + })) + .sort((a: any, b: any) => a.Name.localeCompare(b.Name)) + return acc + }, + {} as Record<(typeof SERVE_TYPES)[number], any[]> + ) + + return { + allFlags, + globalFlags, + filterFlags, + configFlags, + mountFlags, + vfsFlags, + copyFlags, + syncFlags, + serveFlags, + } +} diff --git a/lib/hosts.ts b/lib/hosts.ts new file mode 100644 index 0000000..526760d --- /dev/null +++ b/lib/hosts.ts @@ -0,0 +1,87 @@ +import { fetch as tauriFetch } from '@tauri-apps/plugin-http' +import pRetry from 'p-retry' +import createRCDClient from 'rclone-sdk' + +export interface Host { + id: 'local' | string + name: string + cliVersion: string + os: 'windows' | 'macos' | 'linux' + url: string + authUser?: string + authPassword?: string +} + +export const LOCAL_HOST_ID = 'local' as const + +export const LABEL_FOR_OS = { + windows: 'Windows', + macos: 'macOS', + linux: 'Linux', +} as const + +export async function getHostInfo({ + url, + authUser, + authPassword, +}: { + url: string + authUser?: string + authPassword?: string +}) { + try { + const parsedUrl = new URL(url) + if (!parsedUrl.hostname) { + return null + } + } catch { + return null + } + + let authHeader = '' + if (authUser && authPassword) { + authHeader = `Basic ${btoa(`${authUser}:${authPassword}`)}` + } + + console.log('[getHostInfo] authHeader', authHeader) + + const rcloneClient = createRCDClient({ + baseUrl: url, + headers: authHeader + ? { + 'Authorization': authHeader, + } + : undefined, + fetch: (request: Request) => tauriFetch(request), + }) + + console.log('[getHostInfo] rcloneClient', rcloneClient) + + const infoResponse = await pRetry( + () => + rcloneClient.POST('/core/version').then((res) => { + if (!res || !res.data || res.error) { + throw new Error('Failed to get rclone version') + } + return res.data + }), + { + retries: 3, + factor: 2, + minTimeout: 1000, + maxTimeout: 10000, + } + ) + + console.log('[getHostInfo] infoResponse', infoResponse) + + const { os, version } = infoResponse + + const cleanedVersion = version.replace(/^v/, '') + const parsedOs = os === 'windows' ? 'windows' : os === 'darwin' ? 'macos' : 'linux' + + return { + cliVersion: cleanedVersion, + os: parsedOs, + } as const +} diff --git a/lib/license.ts b/lib/license.ts index 55a2a2a..88d1d19 100644 --- a/lib/license.ts +++ b/lib/license.ts @@ -1,7 +1,7 @@ import { invoke } from '@tauri-apps/api/core' import { fetch } from '@tauri-apps/plugin-http' import { platform } from '@tauri-apps/plugin-os' -import { usePersistedStore } from './store' +import { usePersistedStore } from '../store/persisted' export async function validateLicense(licenseKey: string) { console.log('[validateLicense]') @@ -29,7 +29,7 @@ export async function validateLicense(licenseKey: string) { platform: platform(), }), }) - .then((r) => r.json()) + .then((r) => r.json() as Promise<{ error: string; valid: boolean }>) .catch((e) => { console.error('[validateLicense] failed to validate license') console.error(JSON.stringify(e)) @@ -76,7 +76,7 @@ export async function revokeMachineLicense(licenseKey: string) { id, }), }) - .then((r) => r.json()) + .then((r) => r.json() as Promise<{ error: string; revoked: boolean }>) .catch((e) => { console.error('[revokeMachineLicense] failed to revoke license, fetch failed') console.error(JSON.stringify(e)) diff --git a/lib/menu.ts b/lib/menu.ts deleted file mode 100644 index c5a8130..0000000 --- a/lib/menu.ts +++ /dev/null @@ -1,707 +0,0 @@ -import * as Sentry from '@sentry/browser' -import { Menu, MenuItem, PredefinedMenuItem, Submenu } from '@tauri-apps/api/menu' -import { resolveResource, sep } from '@tauri-apps/api/path' -import { getCurrentWindow } from '@tauri-apps/api/window' -import { ask, message, open } from '@tauri-apps/plugin-dialog' -import { exists, mkdir, remove } from '@tauri-apps/plugin-fs' -import { openPath, openUrl } from '@tauri-apps/plugin-opener' -import { platform } from '@tauri-apps/plugin-os' -import { isDirectoryEmpty } from './fs' -import notify from './notify' -import { - cleanupRemote, - deleteRemote, - getRemote, - listMounts, - listServes, - mountRemote, - stopServe, - unmountRemote, -} from './rclone/api' -import { compareVersions, getRcloneVersion } from './rclone/common' -import { dialogGetMountPlugin, needsMountPlugin } from './rclone/mount' -import { usePersistedStore, useStore } from './store' -import { showDefaultTray, showLoadingTray } from './tray' -import { lockWindows, openFullWindow, openWindow, unlockWindows } from './window' - -const SUPPORTS_CLEANUP = [ - 's3', - 'b2', - 'box', - 'drive', - 'onedrive', - 'internetarchive', - 'jottacloud', - 'mailru', - 'mega', - 'oos', - 'pcloud', - 'pikpak', - 'putio', - 'qingstor', - 'protondrive', - 'seafile', - 'yandex', -] - -async function parseRemotes(remotes: string[]) { - console.log('[parseRemotes] remotes', remotes) - - const persistedStoreState = usePersistedStore.getState() - - console.log('[parseRemotes] listing mounts') - const currentMounts = await listMounts() - console.log('[parseRemotes] currentMounts', currentMounts) - const currentServes = await listServes() - console.log('[parseRemotes] currentServes', currentServes) - - const parsedRemotes: Record< - string, - { icon: string; items: (MenuItem | Submenu | PredefinedMenuItem)[] } - > = {} - - for (const remote of remotes) { - console.log('[parseRemotes] remote', remote) - - const remoteConfig = persistedStoreState.remoteConfigList?.[remote] - if (remoteConfig?.disabledActions?.includes('tray')) { - continue - } - - const remoteInfo = await getRemote(remote).catch(null) - console.log('[parseRemotes] remoteInfo', remoteInfo?.provider, remoteInfo?.type) - - const submenuItems: (MenuItem | Submenu | PredefinedMenuItem)[] = [] - - if (!remoteConfig?.disabledActions?.includes('tray-mount')) { - const mountMenuItem = await MenuItem.new({ - id: `mount-${remote}`, - text: 'Quick Mount', - action: async () => { - await showLoadingTray() - - try { - const needsPlugin = await needsMountPlugin() - if (needsPlugin) { - console.log('Mount plugin not installed') - await dialogGetMountPlugin() - return - } - console.log('Mount plugin installed') - - await lockWindows() - - console.log('remoteConfig', remoteConfig) - - let selectedPath = remoteConfig?.defaultMountPoint || null - - if (!selectedPath) { - await getCurrentWindow().setFocus() - selectedPath = await open({ - title: `Select a directory to mount "${remote}"`, - multiple: false, - directory: true, - }) - } - - if (!selectedPath) { - // await resetMainWindow() - return - } - - console.log('selectedPath', selectedPath) - - let directoryExists: boolean | undefined - - try { - directoryExists = await exists(selectedPath) - } catch (error) { - console.error('Error checking if directory exists:', error) - } - console.log('directoryExists', directoryExists) - - const isPlatformWindows = platform() === 'windows' - - if (directoryExists) { - const isEmpty = await isDirectoryEmpty(selectedPath) - if (!isEmpty) { - // await resetMainWindow() - - await message( - 'The selected directory must be empty to mount a remote.', - { - title: 'Mount Error', - kind: 'error', - } - ) - - return - } - - if (isPlatformWindows) { - await remove(selectedPath) - } - } else if (!isPlatformWindows) { - try { - await mkdir(selectedPath) - } catch (error) { - Sentry.captureException(error) - console.error('Error creating directory:', error) - await message( - 'Failed to create mount directory. Try creating it manually first.', - { - title: 'Mount Error', - kind: 'error', - } - ) - return - } - } - - // Mount the remote - await mountRemote({ - remotePath: `${remote}:${remoteConfig?.defaultRemotePath || ''}`, - mountPoint: selectedPath, - mountOptions: remoteConfig?.mountDefaults, - vfsOptions: remoteConfig?.vfsDefaults, - }) - - await notify({ - title: 'Mounted', - body: `Successfully mounted ${remote} to ${selectedPath}`, - }) - - if (!remoteConfig?.defaultMountPoint) { - const answer = await ask( - `Mount successful! Do you want to set ${selectedPath} as the default mount point for ${remote}? You can always change it later in Remote settings.`, - { - title: 'Set Default?', - okLabel: 'Set', - cancelLabel: 'Cancel', - } - ) - if (answer) { - usePersistedStore.setState((state) => ({ - remoteConfigList: { - ...state.remoteConfigList, - [remote]: { - ...state.remoteConfigList[remote], - defaultMountPoint: selectedPath, - }, - }, - })) - } - } - } catch (error) { - // await resetMainWindow() - Sentry.captureException(error) - console.error('Mount operation failed:', error) - await message(`Failed to mount ${remote}: ${error}`, { - title: 'Mount Error', - }) - } finally { - await unlockWindows() - await showDefaultTray() - } - }, - }) - - submenuItems.push(mountMenuItem) - } - - if (!remoteConfig?.disabledActions?.includes('tray-browse')) { - const browseMenuItem = await MenuItem.new({ - id: `browse-${remote}`, - text: 'Browse', - action: async () => { - // await openBrowser(`http://localhost:5572/[${remote}:]/`) - try { - await openFullWindow({ - name: 'Browse', - url: - 'browse.html?url=' + - encodeURIComponent(`http://localhost:5572/[${remote}:]/`), - }) - } catch (error) { - Sentry.captureException(error) - await message('Could not open browse window. Please try again.', { - title: 'Error', - kind: 'error', - okLabel: 'OK', - }) - } - }, - }) - submenuItems.push(browseMenuItem) - } - - if (!remoteConfig?.disabledActions?.includes('tray-cleanup')) { - if (remoteInfo?.provider && SUPPORTS_CLEANUP.includes(remoteInfo.provider)) { - const cleanupMenuItem = await MenuItem.new({ - id: `cleanup-${remote}`, - text: 'Cleanup', - action: async () => { - try { - const confirmed = await ask(`Start cleanup on ${remote}?`, { - title: `Cleaning up ${remote}`, - kind: 'warning', - okLabel: 'Start', - cancelLabel: 'Cancel', - }) - - if (!confirmed) { - return - } - - await cleanupRemote(remote) - await message(`Cleanup started for ${remote}`, { - title: 'Cleanup Started', - okLabel: 'OK', - }) - } catch (error) { - await message(`Failed to start cleanup job, ${error}`, { - title: 'Cleanup Error', - kind: 'error', - okLabel: 'OK', - }) - } - }, - }) - submenuItems.push(cleanupMenuItem) - } - } - - if (!remoteConfig?.disabledActions?.includes('tray-remove')) { - const removeMenuItem = await MenuItem.new({ - id: `remove-${remote}`, - text: 'Remove', - action: async () => { - await showLoadingTray() - const answer = await ask( - `Are you sure you want to remove ${remote}? This action cannot be reverted.`, - { title: `Removing ${remote}`, kind: 'warning' } - ) - - if (!answer) { - return - } - - await deleteRemote(remote) - await showDefaultTray() - }, - }) - submenuItems.push(removeMenuItem) - } - - const currentRemoteMounts = currentMounts.filter( - (mount) => mount.Fs.split(':')[0] === remote - ) - - console.log('[parseRemotes] currentRemoteMounts', currentRemoteMounts) - - for (const currentMount of currentRemoteMounts) { - console.log( - '[parseRemotes] Adding Unmount (' + - currentMount.MountPoint.split(sep()).pop() + - ') for ', - remote - ) - const unmountMenuItem = await MenuItem.new({ - id: `unmount-${remote}-${currentMount.MountPoint}`, - text: 'Unmount (' + currentMount.MountPoint.split(sep()).pop() + ')', - action: async () => { - try { - await showLoadingTray() - await unmountRemote({ mountPoint: currentMount.MountPoint }) - await message( - `Successfully unmounted ${remote} from ${currentMount.MountPoint.split('/').pop()}`, - { - title: 'Success', - } - ) - } catch (error) { - Sentry.captureException(error) - console.error('Unmount operation failed:', error) - await message(`Failed to unmount ${remote}: ${error}`, { - kind: 'error', - title: 'Unmount Error', - }) - } finally { - await showDefaultTray() - } - }, - }) - submenuItems.push(unmountMenuItem) - - console.log( - '[parseRemotes] Adding Open (' + - currentMount.MountPoint.split('/').pop() + - ') for ', - remote - ) - const showLocationItem = await MenuItem.new({ - id: `open-${remote}-${currentMount.MountPoint}`, - - text: 'Open (' + currentMount.MountPoint.split('/').pop() + ')', - action: async () => { - console.log( - '[parseRemotes] Opening (' + currentMount.MountPoint.split('/').pop() + ')' - ) - try { - await openPath(currentMount.MountPoint) - } catch (error) { - Sentry.captureException(error) - console.error('Error opening path:', error) - await message(`Failed to open ${currentMount.MountPoint} (${error})`, { - title: 'Open Error', - kind: 'error', - }) - } - }, - }) - submenuItems.push(showLocationItem) - } - - for (const currentServe of currentServes) { - // console.log('[parseRemotes] Adding Stop Serve (' + currentServe.Addr.split(':')[0] + ') for ', remote) - const stopServeMenuItem = await MenuItem.new({ - id: `stop-serve-${currentServe.id}`, - text: 'Stop Serve (' + currentServe.id.split('-')[0]?.toUpperCase() + ')', - action: async () => { - try { - await showLoadingTray() - await stopServe(currentServe.id) - - await message(`Successfully stopped serve ${currentServe.id}`, { - title: 'Success', - }) - } catch (error) { - Sentry.captureException(error) - console.error('Stop serve operation failed:', error) - await message(`Failed to stop serve ${currentServe.id}: ${error}`, { - kind: 'error', - title: 'Stop Serve Error', - }) - } finally { - await showDefaultTray() - } - }, - }) - submenuItems.push(stopServeMenuItem) - } - - let iconPath = await resolveResource('icons/favicon/icon.png') - - try { - if (remoteInfo?.provider && !remoteInfo.type) { - console.log('[parseRemotes] resolving provider icon', remoteInfo.provider) - iconPath = await resolveResource(`icons/small/providers/${remoteInfo.provider}.png`) - } else if (remoteInfo?.type) { - console.log('[parseRemotes] resolving backend icon', remoteInfo.type) - iconPath = await resolveResource(`icons/small/backends/${remoteInfo?.type}.png`) - } - } catch (error) { - Sentry.captureException(new Error(`Error resolving icon path for ${remote}: ${error}`)) - Sentry.captureException(error) - console.error('Error resolving icon path for', remote, error) - } - - console.log('[parseRemotes] iconPath', iconPath) - - parsedRemotes[remote] = { - icon: iconPath, - items: submenuItems, - } - } - - return parsedRemotes -} - -export async function buildMenu() { - console.log('[buildMenu]') - - const storeState = useStore.getState() - - const persistedStoreState = usePersistedStore.getState() - - const remotes = storeState.remotes - - const menuItems: (MenuItem | Submenu | PredefinedMenuItem)[] = [] - - if (remotes.length === 0) { - const noRemotesMenuItem = await MenuItem.new({ - id: 'no-remotes', - text: 'Add Remotes in Settings!', - enabled: false, - }) - menuItems.push(noRemotesMenuItem) - } else if (remotes.length > 5) { - const parsedRemotes = await parseRemotes(remotes) - const submenuItems: (MenuItem | Submenu | PredefinedMenuItem)[] = [] - - for (const r in parsedRemotes) { - const sub = await Submenu.new({ - items: parsedRemotes[r].items, - icon: parsedRemotes[r].icon, - text: r, - }) - submenuItems.push(sub) - } - - const sub = await Submenu.new({ - items: submenuItems, - text: 'Remotes', - }) - - menuItems.push(sub) - } else { - const parsedRemotes = await parseRemotes(remotes) - - for (const r in parsedRemotes) { - const sub = await Submenu.new({ - items: parsedRemotes[r].items, - icon: parsedRemotes[r].icon, - text: r, - }) - menuItems.push(sub) - } - } - - await PredefinedMenuItem.new({ - item: 'Separator', - }).then((item) => { - menuItems.push(item) - }) - - if (!persistedStoreState.disabledActions?.includes('tray-mount')) { - const mountToMenuItem = await MenuItem.new({ - id: 'mount', - text: 'Mount', - action: async () => { - await openWindow({ - name: 'Mount', - url: '/mount', - }) - }, - }) - menuItems.push(mountToMenuItem) - } - - if (!persistedStoreState.disabledActions?.includes('tray-copy')) { - const copyMenuItem = await MenuItem.new({ - id: 'copy', - text: 'Copy', - action: async () => { - await openWindow({ - name: 'Copy', - url: '/copy', - }) - }, - }) - menuItems.push(copyMenuItem) - } - - if (!persistedStoreState.disabledActions?.includes('tray-download')) { - const downloadMenuItem = await MenuItem.new({ - id: 'download', - text: 'Download', - action: async () => { - await openWindow({ - name: 'Download', - url: '/download', - }) - }, - }) - menuItems.push(downloadMenuItem) - } - - const allowsAdditional = - !persistedStoreState.disabledActions?.includes('tray-sync') || - !persistedStoreState.disabledActions?.includes('tray-move') || - !persistedStoreState.disabledActions?.includes('tray-bisync') || - !persistedStoreState.disabledActions?.includes('tray-serve') || - !persistedStoreState.disabledActions?.includes('tray-purge') || - !persistedStoreState.disabledActions?.includes('tray-delete') - - if (allowsAdditional) { - const commandsSubmenuItems: (MenuItem | Submenu | PredefinedMenuItem)[] = [] - - if (!persistedStoreState.disabledActions?.includes('tray-sync')) { - const syncMenuItem = await MenuItem.new({ - id: 'sync', - text: 'Sync', - action: async () => { - await openWindow({ - name: 'Sync', - url: '/sync', - }) - }, - }) - commandsSubmenuItems.push(syncMenuItem) - } - - if (!persistedStoreState.disabledActions?.includes('tray-move')) { - const moveMenuItem = await MenuItem.new({ - id: 'move', - text: 'Move', - action: async () => { - await openWindow({ - name: 'Move', - url: '/move', - }) - }, - }) - commandsSubmenuItems.push(moveMenuItem) - } - - if (!persistedStoreState.disabledActions?.includes('tray-bisync')) { - const bisyncMenuItem = await MenuItem.new({ - id: 'bisync', - text: 'Bisync', - action: async () => { - await openWindow({ - name: 'Bisync', - url: '/bisync', - }) - }, - }) - commandsSubmenuItems.push(bisyncMenuItem) - } - - const rcloneVersion = await getRcloneVersion() - - if ( - rcloneVersion?.yours && - compareVersions(rcloneVersion.yours, '1.70.0') === 1 && - !persistedStoreState.disabledActions?.includes('tray-serve') - ) { - const serveToMenuItem = await MenuItem.new({ - id: 'serve', - text: 'Serve', - action: async () => { - await openWindow({ - name: 'Serve', - url: '/serve', - }) - }, - }) - commandsSubmenuItems.push(serveToMenuItem) - } - - if (!persistedStoreState.disabledActions?.includes('tray-purge')) { - const purgeMenuItem = await MenuItem.new({ - id: 'purge', - text: 'Purge', - action: async () => { - await openWindow({ - name: 'Purge', - url: '/purge', - }) - }, - }) - commandsSubmenuItems.push(purgeMenuItem) - } - - if (!persistedStoreState.disabledActions?.includes('tray-delete')) { - const deleteMenuItem = await MenuItem.new({ - id: 'delete', - text: 'Delete', - action: async () => { - await openWindow({ - name: 'Delete', - url: '/delete', - }) - }, - }) - commandsSubmenuItems.push(deleteMenuItem) - } - - const commandsSubmenu = await Submenu.new({ - items: commandsSubmenuItems, - text: 'Commands', - }) - menuItems.push(commandsSubmenu) - } - - await PredefinedMenuItem.new({ - item: 'Separator', - }).then((item) => { - menuItems.push(item) - }) - - const jobsMenuItem = await MenuItem.new({ - id: 'jobs', - text: 'Jobs', - action: async () => { - await openWindow({ - name: 'Jobs', - url: '/jobs', - }) - }, - }) - menuItems.push(jobsMenuItem) - - const cronMenuItem = await MenuItem.new({ - id: 'cron', - text: 'Cron', - action: async () => { - await openWindow({ - name: 'Cron', - url: '/cron', - }) - }, - }) - menuItems.push(cronMenuItem) - - await PredefinedMenuItem.new({ - item: 'Separator', - }).then((item) => { - menuItems.push(item) - }) - - const settingsItem = await MenuItem.new({ - id: 'settings', - text: 'Settings', - action: async () => { - await openWindow({ - name: 'Settings', - url: '/settings', - }) - }, - }) - menuItems.push(settingsItem) - - const issuesItem = await MenuItem.new({ - id: 'issues', - text: 'Issues?', - action: async () => { - const confirmed = await ask( - 'Please open an issue on Github and we will get it sorted in less than 48 hours, no matter if you have a license or not.', - { - title: 'Sorry ):', - kind: 'info', - okLabel: 'Open Github', - cancelLabel: 'Cancel', - } - ) - if (confirmed) { - await openUrl('https://github.com/rclone-ui/rclone-ui/issues') - } - }, - }) - menuItems.push(issuesItem) - - const quitItem = await MenuItem.new({ - id: 'quit', - text: 'Quit', - action: async () => { - await getCurrentWindow().emit('close-app') - }, - }) - menuItems.push(quitItem) - - return await Menu.new({ - id: 'main-menu', - items: menuItems, - }) -} diff --git a/lib/notify.ts b/lib/notify.ts index efd8ba2..b047fc6 100644 --- a/lib/notify.ts +++ b/lib/notify.ts @@ -1,3 +1,4 @@ +import { message } from '@tauri-apps/plugin-dialog' import { isPermissionGranted, requestPermission, @@ -17,5 +18,10 @@ export default async function notify({ title, body }: { title: string; body: str title, body, }) + } else { + await message(body, { + title, + kind: 'info', + }) } } diff --git a/lib/query.ts b/lib/query.ts new file mode 100644 index 0000000..8735ec9 --- /dev/null +++ b/lib/query.ts @@ -0,0 +1,27 @@ +'use no memo' +import { persistQueryClient } from '@tanstack/query-persist-client-core' +import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister' +import { QueryClient } from '@tanstack/react-query' + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + // staleTime: 60_000, + // gcTime: 3_600_000, + }, + }, +}) + +const persister = createSyncStoragePersister({ + storage: window.localStorage, + key: 'rclone-ui-persisted-query-cache', + throttleTime: 1000, +}) + +persistQueryClient({ + queryClient, + persister, + maxAge: 1000 * 60 * 60 * 24 * 30, // 30 days +}) + +export default queryClient diff --git a/lib/rclone/api.ts b/lib/rclone/api.ts index fc71d12..efe011e 100644 --- a/lib/rclone/api.ts +++ b/lib/rclone/api.ts @@ -1,1091 +1,1252 @@ import * as Sentry from '@sentry/browser' -import { fetch } from '@tauri-apps/plugin-http' +import { message } from '@tauri-apps/plugin-dialog' import { platform } from '@tauri-apps/plugin-os' +import pRetry from 'p-retry' +import { useHostStore } from '../../store/host' +import type { JobItem } from '../../types/jobs' import type { FlagValue } from '../../types/rclone' -import { useStore } from '../store' +import { getFsInfo } from '../format' +import { restartActiveRclone, runRcloneCli } from './cli' +import rclone from './client' import { parseRcloneOptions } from './common' -import { SUPPORTED_BACKENDS } from './constants' -/* UTILS */ +const RE_BACKSLASH = /\\/g +const RE_PATH_SEPARATOR = /[/\\]/ +const RE_WINDOWS_EXTENDED_PATH = /(\/\/\?\/|\\\\\?\\)/ +const RE_WINDOWS_DRIVE_ROOT = /^:local:[a-zA-Z]:\/$/ -function getAuthHeader() { - return - - // biome-ignore lint/correctness/noUnreachable: - if (platform() === 'macos') { - return +function serializeOptions( + remotePath: string, + options: { + remote?: Record + global?: Record } - - const state = useStore.getState() - - if (!state.rcloneAuthHeader) { - throw new Error('Rclone auth header is not set') - } - - return { Authorization: state.rcloneAuthHeader } -} - -/* DATA */ -export async function getVersion() { - console.log('[getVersion]') - - const r = await fetch('http://localhost:5572/core/version', { - method: 'POST', - headers: getAuthHeader(), - }) - .catch((e) => { - Sentry.captureException(e) - console.log('error', e) - throw e - }) - .then( - (res) => - res.json() as Promise<{ - version: `v${string}` - decomposed: [number, number, number] - isGit: boolean - isBeta: boolean - os: string - arch: string - goVersion: string - linking: string - goTags: string - }> - ) - - console.log('[getVersion] r', r) - - return r -} - -export async function listRemotes() { - console.log('[listRemotes]') - - const r = await fetch('http://localhost:5572/config/listremotes', { - method: 'POST', - headers: getAuthHeader(), - }) - .catch((e) => { - Sentry.captureException(e) - console.log('error', e) - throw e - }) - .then((res) => res.json() as Promise<{ remotes: string[] }>) - - if (typeof r?.remotes === 'undefined') { - throw new Error('Failed to fetch remotes') - } - - return r.remotes || [] -} - -export async function getRemote(remote: string) { - console.log('[getRemote]', remote) - - const r = await fetch(`http://localhost:5572/config/get?name=${remote}`, { - method: 'POST', - headers: getAuthHeader(), - }).then( - (res) => - res.json() as Promise< - { type: string; provider?: string } & Record - > - ) - - // console.log(JSON.stringify(r, null, 2)) - - return r -} - -export async function updateRemote( - remote: string, - parameters: Record ) { - console.log('[updateRemote]', remote, parameters) + console.log('[serializeRemoteOptions] ', remotePath) - const options = new URLSearchParams() - options.set('name', remote) - options.set('parameters', JSON.stringify(parameters)) + const { remoteName, filePath, dirPath, type, root } = getFsInfo(remotePath) - await fetch(`http://localhost:5572/config/update?${options.toString()}`, { - method: 'POST', - headers: getAuthHeader(), - }) + console.log('[serializeRemoteOptions] ', remotePath, 'remoteName', remoteName) + console.log('[serializeRemoteOptions] ', remotePath, 'filePath', filePath) + console.log('[serializeRemoteOptions] ', remotePath, 'dirPath', dirPath) + console.log('[serializeRemoteOptions] ', remotePath, 'type', type) + console.log('[serializeRemoteOptions] ', remotePath, 'root', root) - // console.log(JSON.stringify(r, null, 2)) -} + let serialized = `${remoteName}` -export async function createRemote( - name: string, - type: string, - parameters: Record -) { - console.log('[createRemote]', name, type, parameters) - - const options = new URLSearchParams() - options.set('name', name) - options.set('type', type) - options.set('parameters', JSON.stringify(parameters)) - - console.log('[createRemote] params', options.toString()) - - await fetch(`http://localhost:5572/config/create?${options.toString()}`, { - method: 'POST', - headers: getAuthHeader(), - }) - - // console.log(JSON.stringify(r, null, 2)) -} - -export async function deleteRemote(remote: string) { - console.log('[deleteRemote]', remote) - - await fetch(`http://localhost:5572/config/delete?name=${remote}`, { - method: 'POST', - headers: getAuthHeader(), - }) - - // console.log(JSON.stringify(r, null, 2)) -} - -export async function cleanupRemote(remote: string) { - console.log('[cleanupRemote]', remote) - - const r = await fetch(`http://localhost:5572/operations/cleanup?fs=${remote}&_async=true`, { - method: 'POST', - headers: getAuthHeader(), - }) - - if (!r.ok) { - throw new Error('Failed to start cleanup job') + if ( + Object.keys(options.remote || {}).length > 0 || + Object.keys(options.global || {}).length > 0 + ) { + serialized += ',' } - // console.log(JSON.stringify(r, null, 2)) -} - -export async function listMounts() { - console.log('[listMounts]') - - const r = await fetch('http://localhost:5572/mount/listmounts', { - method: 'POST', - headers: getAuthHeader(), - }).then( - (res) => - res.json() as Promise<{ - mountPoints: { - Fs: string - MountPoint: string - MountedOn: string - }[] - }> - ) - - if (!Array.isArray(r?.mountPoints)) { - throw new Error('Failed to get mount points') + if (options.remote && Object.keys(options.remote).length > 0) { + serialized += Object.entries(options.remote) + .map(([key, value]) => `${key}="${value}"`) + .join(',') } - return r.mountPoints -} + if (options.global && Object.keys(options.global).length > 0) { + serialized += Object.entries(options.global) + .map(([key, value]) => `global.${key}="${value}"`) + .join(',') + } -export async function getBackends() { - console.log('[getBackends]') + serialized += ':' - const providers = await fetch('http://localhost:5572/config/providers', { - method: 'POST', - headers: getAuthHeader(), - }) - .then((res) => res.json() as Promise) - .then((r) => r.providers) - - return providers.filter((b: any) => SUPPORTED_BACKENDS.includes(b.Prefix)) -} - -export interface ListOptions { - recurse?: boolean - noModTime?: boolean - showEncrypted?: boolean - showOrigIDs?: boolean - showHash?: boolean - noMimeType?: boolean - dirsOnly?: boolean - filesOnly?: boolean - metadata?: boolean - hashTypes?: string[] -} - -export async function listPath(remote: string, path: string = '', options: ListOptions = {}) { - console.log('[listPath]', remote, path, options) - - const params = new URLSearchParams() - params.set('fs', `${remote}:`) - params.set('remote', path) - - // Add optional parameters - for (const [key, value] of Object.entries(options)) { - if (Array.isArray(value)) { - for (const v of value) { - params.append(key, v) - } - } else if (value !== undefined) { - params.set(key, value.toString()) + if (remoteName === ':local') { + if (RE_WINDOWS_DRIVE_ROOT.test(root)) { + const driveLetter = root.slice(7) + console.log( + '[serializeRemoteOptions] ', + remotePath, + 'adding Windows drive', + driveLetter + ) + serialized += driveLetter + } else { + console.log('[serializeRemoteOptions] ', remotePath, 'adding / for Unix local') + serialized += '/' } } - const response = await fetch(`http://localhost:5572/operations/list?${params.toString()}`, { - method: 'POST', - headers: getAuthHeader(), - }).then( - (res) => - res.json() as Promise<{ - list: { - Hashes?: Record - ID?: string - OrigID?: string - IsBucket?: boolean - IsDir: boolean - MimeType?: string - ModTime?: string - Name: string - Encrypted?: string - EncryptedPath?: string - Path: string - Size?: number - Tier?: string - }[] - }> - ) + if (type === 'folder') { + serialized += dirPath + } else { + serialized += filePath + } - return response?.list || [] + console.log('[serializeRemoteOptions] ', remotePath, 'serialized', serialized) + + return serialized +} + +async function hasStat(path: string) { + try { + const { root, filePath } = getFsInfo(path) + const r = await rclone('/operations/stat', { + params: { + query: { + fs: root === ':local:' ? ':local:/' : root, + remote: filePath, + }, + }, + }) + if (!r || !r.item) { + return false + } + return true + } catch { + return false + } +} + +export async function startCopy({ + sources, + destination, + options, +}: { + sources: string[] + destination: string + options: { + copy?: Record + config?: Record + filter?: Record + remotes?: Record> + } +}) { + for (const source of sources) { + const sourceExists = await hasStat(source) + if (!sourceExists) { + throw new Error(`Source does not exist, ${source} is missing`) + } + } + + if ( + sources.length > 1 && + options.filter && + ('include' in options.filter || 'include_from' in options.filter) + ) { + throw new Error('Include rules are not supported with multiple sources') + } + + const mergedOptions = { + ...(options.config || {}), + ...(options.copy || {}), + ...(options.filter || {}), + } + + const pendingJobs: Parameters[0] = [] + const handledSourcePaths: Record = {} + const folderSources = sources.filter((path) => path.endsWith('/') || path.endsWith('\\')) + + console.log('[Copy] ======DST INFO====== ', destination, ' ====================') + const { + root: dstRoot, + dirPath: dstDirPath, + fullDirPath: dstFullDirPath, + remoteName: dstRemoteName, + } = getFsInfo(destination) + + console.log('[Copy] ======DST INFO====== ', destination, ' ====================') + + const dstOptions = + options.remotes && dstRemoteName && dstRemoteName in options.remotes + ? (JSON.parse(options.remotes[dstRemoteName] as unknown as string) as any) + : undefined + + for (const source of sources) { + console.log('[Copy] ======START====== ', source, ' ====================') + if (handledSourcePaths[source]) { + console.log('[Copy] skipping because source is already handled', source) + continue + } + + handledSourcePaths[source] = true + + console.log('[Copy] ======SRC INFO====== ', source, ' ====================') + + const { + root: srcRoot, + filePath: srcFilePath, + fullDirPath: srcFullDirPath, + type: srcType, + name: srcName, + remoteName: srcRemoteName, + } = getFsInfo(source) + + console.log('[Copy] ======SRC INFO====== ', source, ' ====================') + + const srcOptions = + options.remotes && srcRemoteName && srcRemoteName in options.remotes + ? (JSON.parse(options.remotes[srcRemoteName] as unknown as string) as any) + : undefined + + if (srcType === 'folder') { + const jobParams: Parameters[0][number] = { + _path: 'sync/copy', + srcFs: serializeOptions(srcFullDirPath, { + remote: srcOptions, + global: mergedOptions, + }), + dstFs: serializeOptions(`${dstFullDirPath}${srcName}`, { + remote: dstOptions, + }), + createEmptySrcDirs: true, + } + + pendingJobs.push(jobParams) + continue + } + + if (folderSources.some((folder) => source.startsWith(folder))) { + console.log( + '[Copy] skipping because source or parent folder is already handled', + source + ) + continue + } + + console.log('[Copy] ', source, 'srcRoot', srcRoot, srcFilePath) + console.log('[Copy] ', destination, 'dstRoot', dstRoot, dstDirPath) + + const jobParams: Parameters[0][number] = { + _path: 'operations/copyfile', + srcFs: serializeOptions(srcRoot, { + remote: srcOptions, + global: mergedOptions, + }), + srcRemote: srcFilePath, + dstFs: serializeOptions(dstRoot, { + remote: dstOptions, + }), + dstRemote: `${dstDirPath === '/' ? '' : dstDirPath}${srcName}`, + } + + pendingJobs.push(jobParams) + } + + return startBatch(pendingJobs) +} + +export async function startMove({ + sources, + destination, + options, +}: { + sources: string[] + destination: string + options: { + move?: Record + config?: Record + filter?: Record + remotes?: Record> + } +}) { + for (const source of sources) { + const sourceExists = await hasStat(source) + if (!sourceExists) { + throw new Error(`Source does not exist, ${source} is missing`) + } + } + + if ( + sources.length > 1 && + options.filter && + ('include' in options.filter || 'include_from' in options.filter) + ) { + throw new Error('Include rules are not supported with multiple sources') + } + + const mergedOptions = { + ...(options.config || {}), + ...(options.move || {}), + ...(options.filter || {}), + } + + const pendingJobs: Parameters[0] = [] + const handledSourcePaths: Record = {} + const folderSources = sources.filter((path) => path.endsWith('/') || path.endsWith('\\')) + + const { + root: dstRoot, + dirPath: dstDirPath, + fullDirPath: dstFullDirPath, + remoteName: dstRemoteName, + } = getFsInfo(destination) + + const dstOptions = + options.remotes && dstRemoteName && dstRemoteName in options.remotes + ? (JSON.parse(options.remotes[dstRemoteName] as unknown as string) as any) + : undefined + + for (const source of sources) { + if (handledSourcePaths[source]) { + console.log('[Move] skipping because source is already handled', source) + continue + } + + handledSourcePaths[source] = true + + const { + root: srcRoot, + filePath: srcFilePath, + fullDirPath: srcFullDirPath, + type: srcType, + name: srcName, + remoteName: srcRemoteName, + } = getFsInfo(source) + + const srcOptions = + options.remotes && srcRemoteName && srcRemoteName in options.remotes + ? (JSON.parse(options.remotes[srcRemoteName] as unknown as string) as any) + : undefined + + if (srcType === 'folder') { + const jobParams: Parameters[0][number] = { + _path: 'sync/move', + srcFs: serializeOptions(srcFullDirPath, { + remote: srcOptions, + global: mergedOptions, + }), + dstFs: serializeOptions(`${dstFullDirPath}${srcName}`, { + remote: dstOptions, + }), + createEmptySrcDirs: true, + } + + pendingJobs.push(jobParams) + continue + } + + if (folderSources.some((folder) => source.startsWith(folder))) { + console.log( + '[Move] skipping because source or parent folder is already handled', + source + ) + continue + } + + const jobParams: Parameters[0][number] = { + _path: 'operations/movefile', + srcFs: serializeOptions(srcRoot, { + remote: srcOptions, + global: mergedOptions, + }), + srcRemote: srcFilePath, + dstFs: serializeOptions(dstRoot, { + remote: dstOptions, + }), + dstRemote: `${dstDirPath === '/' ? '' : dstDirPath}${srcName}`, + } + + pendingJobs.push(jobParams) + } + + return startBatch(pendingJobs) } /* JOBS */ -export async function listJobs() { - console.log('[listJobs]') - - const allStats = await fetch('http://localhost:5572/core/stats', { - method: 'POST', - headers: getAuthHeader(), - }).then((res) => res.json() as any) - - const transferring = allStats?.transferring - - const transferredStats = await fetch('http://localhost:5572/core/transferred', { - method: 'POST', - headers: getAuthHeader(), - }).then((res) => res.json() as any) +async function fetchTransferred() { + const transferredStats = await rclone('/core/transferred') const transferred = transferredStats?.transferred + return transferred +} + +async function fetchJob(jobId: number, transferred: Awaited>) { + const job = await rclone('/core/stats', { + params: { + query: { + group: `job/${jobId}`, + }, + }, + }) + + const jobStatus = await rclone('/job/status', { + params: { + query: { + jobid: jobId, + }, + }, + }) + + let hasError = !!jobStatus?.error + + if ( + !hasError && + jobStatus.output && + typeof jobStatus.output === 'object' && + 'results' in jobStatus.output && + Array.isArray(jobStatus.output.results) + ) { + hasError = jobStatus.output.results.some((result: any) => !!result?.error) + } + + const relatedItems = transferred.filter((t) => t.group === `job/${jobId}`) + if (relatedItems.length === 0) { + console.log('[fetchJob] relatedItems not found', jobId) + return null + } + + console.log('[fetchJob] relatedItems', JSON.stringify(relatedItems, null, 2)) + + const sources = new Set() + + if (relatedItems.length === 1) { + if (relatedItems[0].srcFs) { + const combinedSource = `${relatedItems[0].srcFs}${relatedItems[0].name}` + + sources.add( + platform() === 'windows' + ? combinedSource.replace(RE_WINDOWS_EXTENDED_PATH, '') + : combinedSource + ) + } + } else { + for (const item of relatedItems) { + if (item.srcFs) { + sources.add( + platform() === 'windows' + ? item.srcFs.replace(RE_WINDOWS_EXTENDED_PATH, '') + : item.srcFs + ) + } + } + } + + if (sources.size === 0 && !hasError) { + console.log('[fetchJob] source or hasError not found', jobId) + return null + } + + return { + id: jobId, + bytes: job.bytes, + totalBytes: job.totalBytes, + speed: job.speed, + + done: job.bytes === job.totalBytes, + progress: Math.round((job.bytes / job.totalBytes) * 100), + hasError: hasError, + + sources: Array.from(sources), + } +} + +export async function listTransfers() { + console.log('[listTransfers]') + + const allStats = await rclone('/core/stats') + + const transferring = allStats?.transferring + + const transferred = await fetchTransferred() + const jobs = { - active: [] as any[], - inactive: [] as any[], + active: [] as JobItem[], + inactive: [] as JobItem[], } const activeJobIds = new Set( transferring - ?.filter((t: any) => t.group.startsWith('job/')) - .map((t: any) => Number(t.group.split('/')[1])) - .sort((a: number, b: number) => a - b) + ?.filter((t) => t.group?.startsWith('job/')) + .map((t) => Number(t.group!.split('/')[1])) + .sort((a, b) => a - b) ) - console.log('[listJobs] activeJobIds', activeJobIds.size) + console.log('[listTransfers] activeJobIds', activeJobIds.size) const isWindows = platform() === 'windows' - console.log('[listJobs] isWindows', isWindows) + console.log('[listTransfers] isWindows', isWindows) for (const jobId of activeJobIds) { - const job = await fetch(`http://localhost:5572/core/stats?group=job/${jobId}`, { - method: 'POST', - headers: getAuthHeader(), - }).then((res) => res.json() as Promise) - - const srcFs = transferred.find((t: any) => t.group === `job/${jobId}`)?.srcFs - const dstFs = transferred.find((t: any) => t.group === `job/${jobId}`)?.dstFs - - if (!srcFs) { - console.log('[listJobs] srcFs not found', jobId) - continue + const job = await fetchJob(jobId, transferred) + if (job) { + jobs.active.push({ + ...job, + type: 'active', + }) } - - jobs.active.push({ - id: jobId, - bytes: job.bytes, - totalBytes: job.totalBytes, - speed: job.speed, - - done: job.bytes === job.totalBytes, - progress: Math.round((job.bytes / job.totalBytes) * 100), - fatal: job.fatalError, - - srcFs: isWindows ? srcFs.replace(/^(\/\/\?\/|\\\\\?\\)/, '') : srcFs, - dstFs: isWindows ? dstFs.replace(/^(\/\/\?\/|\\\\\?\\)/, '') : dstFs, - }) } const inactiveJobIds = new Set( transferred - ?.filter((t: any) => t.group.startsWith('job/')) - .map((t: any) => Number(t.group.split('/')[1])) - .filter((id: number) => !activeJobIds.has(id)) - .sort((a: number, b: number) => a - b) + ?.filter((t) => t.group?.startsWith('job/')) + .map((t) => Number(t.group!.split('/')[1])) + .filter((id) => !activeJobIds.has(id)) + .sort((a, b) => a - b) ) for (const jobId of inactiveJobIds) { - const job = await fetch(`http://localhost:5572/core/stats?group=job/${jobId}`, { - method: 'POST', - headers: getAuthHeader(), - }).then((res) => res.json() as Promise) - - const srcFs = transferred.find((t: any) => t.group === `job/${jobId}`)?.srcFs - const dstFs = transferred.find((t: any) => t.group === `job/${jobId}`)?.dstFs - - if (!srcFs) { - console.log('[listJobs] srcFs not found', jobId) - continue + const job = await fetchJob(jobId, transferred) + if (job) { + jobs.inactive.push({ + ...job, + speed: 0, + type: 'inactive', + }) } - - jobs.inactive.push({ - id: jobId, - bytes: job.bytes, - totalBytes: job.totalBytes, - speed: 0, - - done: job.bytes === job.totalBytes, - progress: Math.round((job.bytes / job.totalBytes) * 100), - fatal: job.fatalError, - - srcFs: isWindows ? srcFs.replace(/^(\/\/\?\/|\\\\\?\\)/, '') : srcFs, - dstFs: isWindows ? dstFs.replace(/^(\/\/\?\/|\\\\\?\\)/, '') : dstFs, - }) } return jobs } -export async function stopJob(jobId: number) { - console.log('[stopJob]', jobId) - - await fetch(`http://localhost:5572/job/stopgroup?group=job/${jobId}`, { - method: 'POST', - headers: getAuthHeader(), - }) -} - /* OPERATIONS */ -export async function mountRemote({ - remotePath, - mountPoint, - mountOptions, - vfsOptions, - _filter, - _config, +export async function startMount({ + source, + destination, + options, }: { - remotePath: string - mountPoint: string - mountOptions?: Record - vfsOptions?: Record - _filter?: Record - _config?: Record + source: string + destination: string + options: { + mount?: Record + vfs?: Record + filter?: Record + config?: Record + remotes?: Record> + } }) { - console.log('[mountRemote]', remotePath, mountPoint) + const currentPlatform = platform() + const needsVolumeName = ['windows', 'macos'].includes(currentPlatform) + const mountOptions = { ...(options.mount || {}) } - const options = new URLSearchParams() - options.set('fs', remotePath) - options.set('mountPoint', mountPoint) + const hasVolumeName = 'volname' in mountOptions && mountOptions.volname && destination !== '*' + if (!hasVolumeName && needsVolumeName) { + const segments = source.split(RE_PATH_SEPARATOR).filter(Boolean) + console.log('[Mount] segments', segments) - if (mountOptions && Object.keys(mountOptions).length > 0) { - options.set('mountOpt', JSON.stringify(parseRcloneOptions(mountOptions))) + const sourcePath = segments.length === 1 ? segments[0].replace(/:/g, '') : segments.pop() + console.log('[Mount] sourcePath', sourcePath) + + mountOptions.volname = `${sourcePath}-${Math.random().toString(36).substring(2, 3).toUpperCase()}` } - if (vfsOptions && Object.keys(vfsOptions).length > 0) { - options.set('vfsOpt', JSON.stringify(parseRcloneOptions(vfsOptions))) + const mergedOptions = { + ...mountOptions, + ...(options.config || {}), + ...(options.vfs || {}), + ...(options.filter || {}), } - if (_filter && Object.keys(_filter).length > 0) { - options.set('_filter', JSON.stringify(parseRcloneOptions(_filter))) + const { fullDirPath: srcFullDirPath, remoteName: srcRemoteName } = getFsInfo(source) + + const srcOptions = + options.remotes && srcRemoteName && srcRemoteName in options.remotes + ? (JSON.parse(options.remotes[srcRemoteName] as unknown as string) as any) + : undefined + + if (destination === '*' && currentPlatform === 'windows') { + await pRetry( + async () => + await rclone('/mount/mount', { + params: { + query: { + fs: serializeOptions(srcFullDirPath, { + global: mergedOptions, + remote: srcOptions, + }), + mountPoint: '*', + mount: 'nfsmount', + }, + }, + }), + { + retries: 3, + } + ) } - if (_config && Object.keys(_config).length > 0) { - options.set('_config', JSON.stringify(parseRcloneOptions(_config))) + const { + root: dstRoot, + filePath: dstFilePath, + fullDirPath: dstFullDirPath, + } = getFsInfo(destination) + + const dstFs = dstRoot === ':local:' ? ':local:/' : dstRoot + const dstFilePathNormalized = dstFilePath.replace(RE_BACKSLASH, '/') + + let directoryExists: boolean | undefined + + try { + const r = await pRetry( + async () => + await rclone('/operations/stat', { + params: { + query: { + fs: dstFs, + remote: dstFilePathNormalized, + }, + }, + }), + { + retries: 3, + } + ) + if (!r || !r.item) { + directoryExists = false + } else { + if (!r.item.IsDir) { + throw new Error('The selected directory is not a directory') + } + directoryExists = true + } + } catch (err) { + console.error('[Mount] Error checking if directory exists:', err) } + console.log('[Mount] directoryExists', directoryExists) - const r = await fetch(`http://localhost:5572/mount/mount?${options.toString()}`, { - method: 'POST', - headers: getAuthHeader(), - }) - .then((res) => res.json() as Promise<{ remotes: string[] } | Promise<{ error: string }>>) - .catch((e) => { - Sentry.captureException(e) - console.log('error', e) - throw e - }) + const isPlatformWindows = platform() === 'windows' - if ('error' in r) { - throw new Error(r.error) - } + if (directoryExists) { + let isEmpty = false + try { + const { list } = await pRetry( + async () => + await rclone('/operations/list', { + params: { + query: { + fs: dstRoot === ':local:' ? ':local:/' : dstRoot, + remote: dstFilePath, + }, + }, + }), + { + retries: 3, + } + ) + isEmpty = !list || list.length === 0 + } catch (err) { + console.error('[Mount] Error checking if directory is empty:', err) + } - return -} + if (!isEmpty) { + throw new Error('The selected directory must be empty to mount a remote.') + } -export async function unmountRemote({ - mountPoint, -}: { - mountPoint: string -}) { - console.log('[unmountRemote]', mountPoint) - - const options = new URLSearchParams() - options.set('mountPoint', mountPoint) - - const r = await fetch(`http://localhost:5572/mount/unmount?${options.toString()}`, { - method: 'POST', - headers: getAuthHeader(), - }) - .then((res) => res.json()) - .catch((e) => { - Sentry.captureException(e) - console.log('error', e) - throw e - }) - - if ('error' in r) { - throw new Error(r.error) - } - - return -} - -export async function unmountAllRemotes() { - console.log('[unmountAllRemotes]') - - const r = await fetch('http://localhost:5572/mount/unmountall', { - method: 'POST', - headers: getAuthHeader(), - }) - .then((res) => res.json()) - .catch((e) => { - Sentry.captureException(e) - console.log('error', e) - throw e - }) - - if ('error' in r) { - throw new Error(r.error) - } - - return -} - -export async function startCopy({ - srcFs, - dstFs, - _config, - _filter, - remoteOptions, -}: { - srcFs: string - dstFs: string - _config?: Record - _filter?: Record - remoteOptions?: Record -}) { - console.log('[startCopy]', srcFs, dstFs) - - const params = new URLSearchParams() - params.set('srcFs', srcFs) - params.set('dstFs', dstFs) - // params.set('b2_disable_checksum', 'true') - params.set('_async', 'true') - - if (_config && Object.keys(_config).length > 0) { - params.set('_config', JSON.stringify(parseRcloneOptions(_config))) - } - - if (_filter && Object.keys(_filter).length > 0) { - params.set('_filter', JSON.stringify(parseRcloneOptions(_filter))) - } - - if (remoteOptions && Object.keys(remoteOptions).length > 0) { - for (const [key, value] of Object.entries(remoteOptions)) { - params.set(key, value.toString()) + if (isPlatformWindows) { + try { + await pRetry( + async () => + await rclone('/operations/rmdir', { + params: { + query: { + fs: dstRoot === ':local:' ? ':local:/' : dstRoot, + remote: dstFilePath, + }, + }, + }), + { + retries: 3, + } + ) + } catch (err) { + console.error('[Mount] Error removing directory:', err) + } + } + } else if (!isPlatformWindows) { + try { + await pRetry( + async () => + await rclone('/operations/mkdir', { + params: { + query: { + fs: dstRoot === ':local:' ? ':local:/' : dstRoot, + remote: dstFilePath, + }, + }, + }), + { + retries: 3, + } + ) + } catch (error) { + console.error('[Mount] Error creating directory:', error) + throw new Error('Failed to create mount directory. Try creating it manually first.') } } - const r = await fetch(`http://localhost:5572/sync/copy?${params.toString()}`, { - method: 'POST', - headers: getAuthHeader(), - }).then((res) => res.json() as Promise<{ jobid: string }>) - - console.log('[startCopy] operation started:', r) - - if (!r.jobid) { - throw new Error('Failed to start copy job') - } - - return r.jobid -} - -export async function startMove({ - srcFs, - dstFs, - createEmptySrcDirs, - deleteEmptyDstDirs, - _config, - _filter, - remoteOptions, -}: { - srcFs: string - dstFs: string - createEmptySrcDirs?: boolean // create empty src directories on destination if set - deleteEmptyDstDirs?: boolean // delete empty src directories if set - _config?: Record - _filter?: Record - remoteOptions?: Record -}) { - console.log('[startMove]', srcFs, dstFs, createEmptySrcDirs, deleteEmptyDstDirs) - - const params = new URLSearchParams() - params.set('srcFs', srcFs) - params.set('dstFs', dstFs) - - if (createEmptySrcDirs) { - params.set('createEmptySrcDirs', 'true') - } - - if (deleteEmptyDstDirs) { - params.set('deleteEmptyDstDirs', 'true') - } - - // params.set('b2_disable_checksum', 'true') - params.set('_async', 'true') - - if (_config && Object.keys(_config).length > 0) { - params.set('_config', JSON.stringify(parseRcloneOptions(_config))) - } - - if (_filter && Object.keys(_filter).length > 0) { - params.set('_filter', JSON.stringify(parseRcloneOptions(_filter))) - } - - if (remoteOptions && Object.keys(remoteOptions).length > 0) { - for (const [key, value] of Object.entries(remoteOptions)) { - params.set(key, value.toString()) + await pRetry( + async () => + await rclone('/mount/mount', { + params: { + query: { + fs: serializeOptions(srcFullDirPath, { + global: mergedOptions, + remote: srcOptions, + }), + mountPoint: + platform() === 'windows' + ? dstFullDirPath.replace(':local:', '').replace(RE_BACKSLASH, '/') + : dstFullDirPath.replace(':local:', '/'), + mount: 'nfsmount', + }, + }, + }), + { + retries: 3, } - } - - const r = await fetch(`http://localhost:5572/sync/move?${params.toString()}`, { - method: 'POST', - headers: getAuthHeader(), - }).then((res) => res.json() as Promise<{ jobid: string }>) - - console.log('[startMove] operation started:', r) - - if (!r.jobid) { - throw new Error('Failed to start move job') - } - - return r.jobid + ) } export async function startBisync({ - path1, - path2, - _config, - _filter, - remoteOptions, - outerOptions, + source, + destination, + options, }: { - path1: string - path2: string - _config?: Record - _filter?: Record - remoteOptions?: Record - outerOptions?: Record + source: string + destination: string + options: { + config?: Record + bisync?: Record + filter?: Record + remotes?: Record> + outer?: Record + } }) { - console.log('[startBisync]', path1, path2) - - const params = new URLSearchParams() - params.set('path1', path1) - params.set('path2', path2) - params.set('_async', 'true') - - if (_config && Object.keys(_config).length > 0) { - params.set('_config', JSON.stringify(parseRcloneOptions(_config))) + const sourceExists = await hasStat(source) + if (!sourceExists) { + throw new Error(`Source does not exist, ${source} is missing`) } - if (_filter && Object.keys(_filter).length > 0) { - params.set('_filter', JSON.stringify(parseRcloneOptions(_filter))) + const mergedOptions = { + ...(options.config || {}), + ...(options.bisync || {}), + ...(options.filter || {}), } - if (remoteOptions && Object.keys(remoteOptions).length > 0) { - for (const [key, value] of Object.entries(remoteOptions)) { - params.set(key, value.toString()) + const { fullDirPath: srcFullDirPath, remoteName: srcRemoteName } = getFsInfo(source) + const { fullDirPath: dstFullDirPath, remoteName: dstRemoteName } = getFsInfo(destination) + + const srcOptions = + options.remotes && srcRemoteName && srcRemoteName in options.remotes + ? (JSON.parse(options.remotes[srcRemoteName] as unknown as string) as any) + : undefined + + const dstOptions = + options.remotes && dstRemoteName && dstRemoteName in options.remotes + ? (JSON.parse(options.remotes[dstRemoteName] as unknown as string) as any) + : undefined + + const r = await pRetry( + async () => + await rclone('/sync/bisync', { + params: { + query: { + path1: serializeOptions(srcFullDirPath, { + global: mergedOptions, + remote: srcOptions, + }), + path2: serializeOptions(dstFullDirPath, { + remote: dstOptions, + }), + _async: true, + ...(options.outer && Object.keys(options.outer).length > 0 + ? Object.fromEntries( + Object.entries(options.outer).map(([key, value]) => [ + key, + Array.isArray(value) ? value.join(',') : value, + ]) + ) + : {}), + }, + }, + }), + { + retries: 3, } + ) + + if (!r?.jobid) { + console.error('Failed to start job: missing jobid', r) + throw new Error('Failed to start operation') } - if (outerOptions && Object.keys(outerOptions).length > 0) { - for (const [key, value] of Object.entries(outerOptions)) { - params.set(key, value.toString()) + await new Promise((resolve) => setTimeout(resolve, 1000)) + + const jobStatus = await pRetry( + async () => + await rclone('/job/status', { + params: { + query: { + jobid: r.jobid!, + }, + }, + }), + { + retries: 3, } + ).catch(null) + + console.log('jobStatus', JSON.stringify(jobStatus, null, 2)) + + if (!jobStatus) { + console.error('Failed to start job:', r.jobid) + throw new Error('Failed to start operation') } - const r = await fetch(`http://localhost:5572/sync/bisync?${params.toString()}`, { - method: 'POST', - headers: getAuthHeader(), - }).then((res) => res.json() as Promise<{ jobid: string }>) - - console.log('[startBisync] operation started:', r) - - if (!r.jobid) { - throw new Error('Failed to start bisync job') + if (jobStatus.error) { + console.error('Failed to start job:', r.jobid, jobStatus.error) + throw new Error(jobStatus.error) } return r.jobid } export async function startSync({ - srcFs, - dstFs, - _config, - _filter, - remoteOptions, + source, + destination, + options, }: { - srcFs: string - dstFs: string - _config?: Record - _filter?: Record - remoteOptions?: Record + source: string + destination: string + options: { + config?: Record + sync?: Record + filter?: Record + remotes?: Record> + } }) { - console.log('[startSync]', srcFs, dstFs) - - const params = new URLSearchParams() - params.set('srcFs', srcFs) - params.set('dstFs', dstFs) - // params.set('b2_disable_checksum', 'true') - params.set('_async', 'true') - - if (_config && Object.keys(_config).length > 0) { - params.set('_config', JSON.stringify(parseRcloneOptions(_config))) + const sourceExists = await hasStat(source) + if (!sourceExists) { + throw new Error(`Source does not exist, ${source} is missing`) } - if (_filter && Object.keys(_filter).length > 0) { - params.set('_filter', JSON.stringify(parseRcloneOptions(_filter))) + const mergedOptions = { + ...(options.config || {}), + ...(options.sync || {}), + ...(options.filter || {}), } - if (remoteOptions && Object.keys(remoteOptions).length > 0) { - for (const [key, value] of Object.entries(remoteOptions)) { - params.set(key, value.toString()) + const { fullDirPath: srcFullDirPath, remoteName: srcRemoteName } = getFsInfo(source) + const { fullDirPath: dstFullDirPath, remoteName: dstRemoteName } = getFsInfo(destination) + + const srcOptions = + options.remotes && srcRemoteName && srcRemoteName in options.remotes + ? (JSON.parse(options.remotes[srcRemoteName] as unknown as string) as any) + : undefined + + const dstOptions = + options.remotes && dstRemoteName && dstRemoteName in options.remotes + ? (JSON.parse(options.remotes[dstRemoteName] as unknown as string) as any) + : undefined + + const r = await pRetry( + async () => + await rclone('/sync/sync', { + params: { + query: { + srcFs: serializeOptions(srcFullDirPath, { + global: mergedOptions, + remote: srcOptions, + }), + dstFs: serializeOptions(dstFullDirPath, { + remote: dstOptions, + }), + createEmptySrcDirs: true, + _async: true, + }, + }, + }), + { + retries: 3, } + ) + + if (!r?.jobid) { + console.error('Failed to start job: missing jobid', r) + throw new Error('Failed to start operation') } - const r = await fetch(`http://localhost:5572/sync/sync?${params.toString()}`, { - method: 'POST', - headers: getAuthHeader(), - }).then((res) => res.json() as Promise<{ jobid: string }>) + await new Promise((resolve) => setTimeout(resolve, 1000)) - console.log('[startSync] operation started:', r) + const jobStatus = await pRetry( + async () => + await rclone('/job/status', { + params: { + query: { + jobid: r.jobid!, + }, + }, + }), + { + retries: 3, + } + ).catch(null) - if (!r.jobid) { - throw new Error('Failed to start sync job') + console.log('jobStatus', JSON.stringify(jobStatus, null, 2)) + + if (!jobStatus) { + console.error('Failed to start job:', r.jobid) + throw new Error('Failed to start operation') } + + if (jobStatus.error) { + console.error('Failed to start job:', r.jobid, jobStatus.error) + throw new Error(jobStatus.error) + } + + return r.jobid } export async function startDelete({ - fs, - rmDirs, - _filter, - _config, + sources, + options, }: { - fs: string - rmDirs?: boolean // delete empty src directories if set - _filter?: Record - _config?: Record + sources: string[] + options: { + filter?: Record + config?: Record + remotes?: Record> + } }) { - console.log('[startDelete]', fs, rmDirs) - - const params = new URLSearchParams() - params.set('fs', fs) - - if (rmDirs) { - params.set('rmDirs', 'true') + for (const source of sources) { + const sourceExists = await hasStat(source) + if (!sourceExists) { + throw new Error(`Source does not exist, ${source} is missing`) + } } - params.set('_async', 'true') - - if (_filter && Object.keys(_filter).length > 0) { - params.set('_filter', JSON.stringify(parseRcloneOptions(_filter))) + if ( + sources.length > 1 && + options.filter && + ('include' in options.filter || 'include_from' in options.filter) + ) { + throw new Error('Include rules are not supported with multiple sources') } - if (_config && Object.keys(_config).length > 0) { - params.set('_config', JSON.stringify(parseRcloneOptions(_config))) + const mergedOptions = { + ...(options.config || {}), + ...(options.filter || {}), } - const r = await fetch(`http://localhost:5572/operations/delete?${params.toString()}`, { - method: 'POST', - headers: getAuthHeader(), - }) + const pendingJobs: Parameters[0] = [] + const handledSourcePaths: Record = {} + const folderSources = sources.filter((path) => path.endsWith('/') || path.endsWith('\\')) - console.log('[startDelete] operation started:', r) + for (const source of sources) { + if (handledSourcePaths[source]) { + console.log('[Delete] skipping because source is already handled', source) + continue + } - if (!r.ok) { - throw new Error('Failed to start delete job') + handledSourcePaths[source] = true + + const { + root: srcRoot, + filePath: srcFilePath, + type: srcType, + remoteName: srcRemoteName, + } = getFsInfo(source) + + const srcOptions = + options.remotes && srcRemoteName && srcRemoteName in options.remotes + ? (JSON.parse(options.remotes[srcRemoteName] as unknown as string) as any) + : undefined + + if (srcType === 'folder') { + const jobParams: Parameters[0][number] = { + _path: 'operations/delete', + fs: serializeOptions(source, { + global: mergedOptions, + remote: srcOptions, + }), + } + pendingJobs.push(jobParams) + continue + } + + if (folderSources.some((folder) => source.startsWith(folder))) { + console.log( + '[Delete] skipping because source or parent folder is already handled', + source + ) + continue + } + + const jobParams: Parameters[0][number] = { + _path: 'operations/deletefile', + fs: serializeOptions(srcRoot, { + global: mergedOptions, + remote: srcOptions, + }), + remote: srcFilePath, + } + pendingJobs.push(jobParams) } + + return startBatch(pendingJobs) } export async function startPurge({ - fs, - remote, - _filter, - _config, + sources, + options, }: { - fs: string // a remote name string e.g. "drive:" - remote: string // a path within that remote e.g. "dir" - _filter?: Record - _config?: Record + sources: string[] + options: { + config?: Record + remotes?: Record> + } }) { - const params = new URLSearchParams() - params.set('fs', fs) - params.set('remote', remote) - params.set('_async', 'true') - - if (_filter && Object.keys(_filter).length > 0) { - params.set('_filter', JSON.stringify(parseRcloneOptions(_filter))) + for (const source of sources) { + const sourceExists = await hasStat(source) + if (!sourceExists) { + throw new Error(`Source does not exist, ${source} is missing`) + } } - if (_config && Object.keys(_config).length > 0) { - params.set('_config', JSON.stringify(parseRcloneOptions(_config))) + const pendingJobs: Parameters[0] = [] + const handledSourcePaths: Record = {} + + for (const source of sources) { + if (handledSourcePaths[source]) { + console.log('[Purge] skipping because source is already handled', source) + continue + } + + handledSourcePaths[source] = true + + const { + root: srcRoot, + dirPath: srcDirPath, + type: srcType, + remoteName: srcRemoteName, + } = getFsInfo(source) + + if (srcType !== 'folder') { + throw new Error('Only folders can be purged') + } + + const srcOptions = + options.remotes && srcRemoteName && srcRemoteName in options.remotes + ? (JSON.parse(options.remotes[srcRemoteName] as unknown as string) as any) + : undefined + + const jobParams: Parameters[0][number] = { + _path: 'operations/purge', + fs: serializeOptions(srcRoot, { + global: options.config, + remote: srcOptions, + }), + remote: srcDirPath, + } + pendingJobs.push(jobParams) } - const r = await fetch(`http://localhost:5572/operations/purge?${params.toString()}`, { - method: 'POST', - headers: getAuthHeader(), - }) - - console.log('[startPurge] operation started:', r) - - if (!r.ok) { - throw new Error('Failed to start purge job') - } -} - -export async function startDownload({ - fs, - remote, - url, - autoFilename = false, -}: { - fs: string // a remote name string e.g. "drive:" - remote: string // a path within that remote e.g. "dir" - url: string // string, URL to read from - autoFilename?: boolean // boolean, set to true to retrieve destination file name from url -}) { - const params = new URLSearchParams() - params.set('fs', fs) - params.set('remote', remote) - params.set('url', url) - params.set('autoFilename', autoFilename.toString()) - params.set('_async', 'true') - - const r = await fetch(`http://localhost:5572/operations/copyurl?${params.toString()}`, { - method: 'POST', - headers: getAuthHeader(), - }) - - console.log('[startDownload] operation started:', r) - - if (!r.ok) { - throw new Error('Failed to start download job') - } -} - -/* FLAGS */ -export async function getCurrentGlobalFlags() { - console.log('[getCurrentGlobalFlags]') - - const r = await fetch('http://localhost:5572/options/get', { - method: 'POST', - headers: getAuthHeader(), - }).then((res) => res.json() as Promise) - - return r -} - -export async function getCopyFlags() { - console.log('[getCopyFlags]') - - const r = await fetch('http://localhost:5572/options/info', { - method: 'POST', - headers: getAuthHeader(), - }).then((res) => res.json() as Promise) - - const mainFlags = r.main - - const copyFlags = mainFlags - .filter((flag: any) => flag?.Groups?.includes('Copy')) - .sort((a: any, b: any) => a.Name.localeCompare(b.Name)) - - return copyFlags -} - -export async function getSyncFlags() { - console.log('[getSyncFlags]') - - const r = await fetch('http://localhost:5572/options/info', { - method: 'POST', - headers: getAuthHeader(), - }).then((res) => res.json() as Promise) - - const mainFlags = r.main - - const syncFlags = mainFlags - .filter((flag: any) => flag?.Groups?.includes('Copy') || flag?.Groups?.includes('Sync')) - .sort((a: any, b: any) => a.Name.localeCompare(b.Name)) - - return syncFlags -} - -export async function getFilterFlags() { - console.log('[getFilterFlags]') - - const r = await fetch('http://localhost:5572/options/info', { - method: 'POST', - headers: getAuthHeader(), - }).then((res) => res.json() as Promise) - - const filterFlags = r.filter - - // ignore "Metadata" fields as they have the same FieldNames as the normal non-metadata filters - const filteredFlags = filterFlags - .filter((flag: any) => !flag.Groups.includes('Metadata')) - .sort((a: any, b: any) => a.Name.localeCompare(b.Name)) - - return filteredFlags -} - -export async function getVfsFlags() { - console.log('[getVfsFlags]') - - const r = await fetch('http://localhost:5572/options/info', { - method: 'POST', - headers: getAuthHeader(), - }).then((res) => res.json() as Promise) - - const vfsFlags = r.vfs - - const IGNORED_FLAGS = ['NONE'] - - const filteredFlags = vfsFlags - .filter((flag: any) => !IGNORED_FLAGS.includes(flag.Name)) - .sort((a: any, b: any) => a.Name.localeCompare(b.Name)) - - return filteredFlags -} - -export async function getMountFlags() { - console.log('[getMountFlags]') - - const r = await fetch('http://localhost:5572/options/info', { - method: 'POST', - headers: getAuthHeader(), - }).then((res) => res.json() as Promise) - - const mountFlags = r.mount - - const IGNORED_FLAGS = ['debug_fuse', 'daemon', 'daemon_timeout'] - - const filteredFlags = mountFlags - .filter((flag: any) => !IGNORED_FLAGS.includes(flag.Name)) - .sort((a: any, b: any) => a.Name.localeCompare(b.Name)) - - return filteredFlags -} - -export async function getConfigFlags() { - console.log('[getConfigFlags]') - - const r = await fetch('http://localhost:5572/options/info', { - method: 'POST', - headers: getAuthHeader(), - }).then((res) => res.json() as Promise) - - const mainFlags = r.main - - const copyFlags = mainFlags - .filter( - (flag: any) => - flag?.Groups?.includes('Performance') || - flag?.Groups?.includes('Listing') || - flag?.Groups?.includes('Networking') || - flag?.Groups?.includes('Check') || - flag?.Name === 'use_server_modtime' - ) - .sort((a: any, b: any) => a.Name.localeCompare(b.Name)) - - return copyFlags -} - -export async function getServeFlags(type: string) { - console.log('[getServeFlags] ', type) - - const r = await fetch('http://localhost:5572/options/info', { - method: 'POST', - headers: getAuthHeader(), - }).then((res) => res.json() as Promise) - - const serveFlags = r[type].map((flag: any) => ({ - ...flag, - FieldName: flag.Name, - DefaultStr: - flag.Name === 'addr' - ? flag.DefaultStr.replace('[', '').replace(']', '') - : flag.DefaultStr, - })) - - return serveFlags.sort((a: any, b: any) => a.Name.localeCompare(b.Name)) -} - -/* SERVE */ -export interface ServeListItem { - id: string - addr: string - params: { - fs: string - type: string - opt?: Record - vfsOpt?: Record - } -} - -export async function listServes() { - console.log('[listServes]') - - const r = await fetch('http://localhost:5572/serve/list', { - method: 'POST', - headers: getAuthHeader(), - }) - .then( - (res) => - res.json() as Promise<{ - list: ServeListItem[] - }> - ) - .catch((e) => { - Sentry.captureException(e) - console.log('error', e) - throw e - }) - - if (!Array.isArray(r?.list)) { - throw new Error('[listServes] Failed to get running serves') - } - - return r.list + return startBatch(pendingJobs) } export async function startServe({ - // type, - // fs, - // addr, + type, + fs, + addr, _filter, _config, ...props }: { - // type: string - // fs: string - // addr: string - // options?: Record + type: string + fs: string + addr: string _filter?: Record _config?: Record } & Record) { - console.log('[startServe] ', props) + return rclone('/serve/start', { + params: { + query: { + type, + fs, + addr, + _filter: + _filter && Object.keys(_filter).length > 0 + ? JSON.stringify(parseRcloneOptions(_filter)) + : undefined, + _config: + _config && Object.keys(_config).length > 0 + ? JSON.stringify(parseRcloneOptions(_config)) + : undefined, + ...(props && Object.keys(props).length > 0 + ? Object.fromEntries( + Object.entries(props).map(([key, value]) => [ + key, + Array.isArray(value) ? value.join(',') : value, + ]) + ) + : {}), + }, + }, + }) +} - const params = new URLSearchParams() - // params.set('type', type) - // params.set('fs', fs) - // params.set('user', 'usr') - // params.set('pass', 'pas') +export async function startBatch(inputs: ({ _path: string } & Record)[]) { + console.log('[startBatch] inputs', inputs) - for (const [key, value] of Object.entries(props)) { - if (Array.isArray(value)) { - for (const v of value) params.append(key, String(v)) - } else if (value !== undefined) { - params.set(key, String(value)) + const r = await pRetry( + async () => + await rclone('/job/batch', { + body: { + inputs, + _async: true, + }, + }), + { + retries: 3, + } + ) + + await new Promise((resolve) => setTimeout(resolve, 1000)) + + const jobStatus = await pRetry( + async () => + await rclone('/job/status', { + params: { + query: { + jobid: r.jobid, + }, + }, + }), + { + retries: 3, + } + ).catch(null) + + console.log('jobStatus', JSON.stringify(jobStatus, null, 2)) + + if (!jobStatus) { + console.error('Failed to start job:', r.jobid) + throw new Error('Failed to start operation') + } + + const output = jobStatus.output as any + if ( + output?.results && + Array.isArray(output.results) && + output.results.length === inputs.length + ) { + const results = output.results + const allFailed = results.every((res: any) => res.error) + + if (allFailed) { + const errorMessages = results + .map((res: any) => { + const path = res.input?.srcRemote || res.input?.dstRemote || 'unknown' + return `${path}: ${res.error}` + }) + .join('\n') + + console.error('All batch operations failed:', errorMessages) + throw new Error(errorMessages) } } - // if (options && Object.keys(options).length > 0) { - // const parsed = parseRcloneOptions(options) - // for (const [key, value] of Object.entries(parsed)) { - // if (Array.isArray(value)) { - // for (const v of value) params.append(key, String(v)) - // } else if (value !== undefined) { - // params.set(key, String(value)) - // } - // } + if (jobStatus.error) { + console.error('Failed to start job:', r.jobid, jobStatus.error) + throw new Error(jobStatus.error) + } + + return r.jobid +} + +/* PASSWORD */ +export async function removeConfigPassword() { + console.log('[removeConfigPassword]') + + const state = useHostStore.getState() + const activeConfig = state.activeConfigFile + + if (!activeConfig || !activeConfig.id) { + throw new Error('No active configuration selected.') + } + + if (!activeConfig.isEncrypted) { + throw new Error('Configuration is not encrypted.') + } + + try { + await runRcloneCli(['config', 'encryption', 'remove']) + state.updateConfigFile(activeConfig.id, { + isEncrypted: false, + pass: undefined, + passCommand: undefined, + }) + console.log('[removeConfigPassword] restarting rclone') + await restartActiveRclone() + } catch (error) { + Sentry.captureException(error) + await message(error instanceof Error ? error.message : 'Failed to disable encryption.', { + title: 'Config Encryption', + kind: 'error', + okLabel: 'OK', + }) + throw error + } +} + +export async function setConfigPassword(options: { + password: string + persist?: boolean +}) { + console.log('[setConfigPassword]') + + const state = useHostStore.getState() + const activeConfig = state.activeConfigFile + + if (!activeConfig || !activeConfig.id) { + throw new Error('No active configuration selected.') + } + + // if (!activeConfig.isEncrypted) { + // throw new Error('Configuration is not encrypted.') // } - if (_filter && Object.keys(_filter).length > 0) { - params.set('_filter', JSON.stringify(parseRcloneOptions(_filter))) + const password = options.password + + if (!password) { + throw new Error('Password is required to update encryption.') } - if (_config && Object.keys(_config).length > 0) { - params.set('_config', JSON.stringify(parseRcloneOptions(_config))) - } - - const r = await fetch(`http://localhost:5572/serve/start?${params.toString()}`, { - method: 'POST', - headers: getAuthHeader(), - }) - .then((res) => res.json() as Promise<{ addr: string; id: string } | { error: string }>) - .catch((e) => { - Sentry.captureException(e) - console.log('error', e) - throw e + try { + await runRcloneCli(['config', 'encryption', 'set'], [password, password]) + state.updateConfigFile(activeConfig.id, { + isEncrypted: true, + pass: options.persist ? password : undefined, + passCommand: undefined, }) - if ('error' in r) { - throw new Error(r.error) - } - - return r -} - -export async function stopServe(id: string) { - console.log('[stopServe] ', id) - - const params = new URLSearchParams() - params.set('id', id) - - const r = await fetch(`http://localhost:5572/serve/stop?${params.toString()}`, { - method: 'POST', - headers: getAuthHeader(), - }) - .then((res) => res.json()) - .catch((e) => { - Sentry.captureException(e) - console.log('error', e) - throw e - }) - - if ('error' in r) { - throw new Error(r.error) + console.log('[setConfigPassword] restarting rclone') + await restartActiveRclone() + } catch (error) { + Sentry.captureException(error) + await message( + error instanceof Error ? error.message : 'Failed to update encryption password.', + { + title: 'Config Encryption', + kind: 'error', + okLabel: 'OK', + } + ) + throw error } } -export async function stopAllServes() { - console.log('[stopAllServes]') +/* OTHERS */ +export async function fetchServeList() { + try { + const response = await rclone('/serve/list') + return response.list + } catch (error) { + console.error('[fetchServeList] failed to fetch active serves', error) + return [] + } +} - const r = await fetch('http://localhost:5572/serve/stopall', { - method: 'POST', - headers: getAuthHeader(), - }) - .then((res) => res.json()) - .catch((e) => { - Sentry.captureException(e) - console.log('error', e) - throw e - }) - - if ('error' in r) { - throw new Error(r.error) +export async function fetchMountList() { + try { + const response = await rclone('/mount/listmounts') + return response.mountPoints + } catch (error) { + console.error('[fetchMountList] failed to fetch active mounts', error) + return [] } } diff --git a/lib/rclone/cli.ts b/lib/rclone/cli.ts new file mode 100644 index 0000000..c5d896e --- /dev/null +++ b/lib/rclone/cli.ts @@ -0,0 +1,382 @@ +import * as Sentry from '@sentry/browser' +import { invoke } from '@tauri-apps/api/core' +import { sep } from '@tauri-apps/api/path' +import { getAllWindows } from '@tauri-apps/api/window' +import { message } from '@tauri-apps/plugin-dialog' +import { Command } from '@tauri-apps/plugin-shell' +import { useHostStore } from '../../store/host' +import type { ConfigFile } from '../../types/config' +import { getConfigParentFolder } from '../format' +import { getConfigPath, isInternalRcloneInstalled, isSystemRcloneInstalled } from './common' + +export interface RcloneCliCommandContext { + command: Command + activeConfig: ConfigFile + configPath: string + configDirectory: string + env: Record + flavour: 'system' | 'internal' +} + +export async function promptForConfigPassword(message: string) { + console.log('[promptForConfigPassword] message:', message) + try { + const result = await invoke('tiny_prompt_text', { + title: 'Rclone UI', + message: message.replace(/"/g, '“'), + default: null, + sensitive: true, + }) + console.log('[promptForConfigPassword] received result type:', typeof result) + + if (typeof result === 'string') { + console.log('[promptForConfigPassword] password received, length:', result.length) + return result.trim() + } + + console.log('[promptForConfigPassword] no password received (cancelled or empty)') + return null + } catch (error) { + console.error('[promptForConfigPassword] Error prompting for password:', error) + return null + } +} + +async function validateConfigAccess( + commandName: 'rclone-system' | 'rclone-internal', + env: Record +): Promise<{ + success: boolean + code?: number | null + stderr?: string + error?: Error +}> { + console.log('[validateConfigAccess] command:', commandName) + try { + const command = Command.create(commandName, ['config', 'dump'], { env }) + const result = await command.execute() + console.log('[validateConfigAccess] exit code:', result.code) + + if (result.code === 0) { + return { success: true } + } + + return { + success: false, + code: result.code ?? null, + stderr: result.stderr, + } + } catch (error) { + console.error('[validateConfigAccess] execution error:', error) + return { + success: false, + error: error instanceof Error ? error : new Error(String(error)), + } + } +} + +export async function ensureEncryptedConfigEnv( + activeConfig: ConfigFile, + env: Record, + autoPromptForPassword: boolean, + commandName: 'rclone-system' | 'rclone-internal', + promptMessage: string +) { + console.log('[ensureEncryptedConfigEnv] ensuring encrypted config env for:', activeConfig.id) + + env.RCLONE_ASK_PASSWORD = 'false' + + if (activeConfig.passCommand) { + console.log('[ensureEncryptedConfigEnv] using passCommand for decryption') + const validationEnv = { + ...env, + RCLONE_CONFIG_PASS_COMMAND: activeConfig.passCommand, + } + + const validation = await validateConfigAccess(commandName, validationEnv) + if (validation.success) { + console.log('[ensureEncryptedConfigEnv] passCommand validation succeeded') + env.RCLONE_CONFIG_PASS_COMMAND = activeConfig.passCommand + return + } + + console.error('[ensureEncryptedConfigEnv] passCommand validation failed', validation.code) + await message( + 'Failed to decrypt the configuration using the configured password command. Please verify the command output.', + { + title: 'Password Command Failed', + kind: 'error', + okLabel: 'OK', + } + ) + throw new Error('Failed to decrypt configuration using pass command.') + } + + let password = activeConfig.pass ?? undefined + let passwordSource: 'stored' | 'prompted' | null = password ? 'stored' : null + + const hostStore = useHostStore.getState() + const updateConfigFile = hostStore.updateConfigFile + const activeConfigId = activeConfig.id + + if (!password && !autoPromptForPassword) { + throw new Error('Password required to access encrypted configuration.') + } + + while (true) { + if (!password) { + if (!autoPromptForPassword) { + throw new Error('Password required to access encrypted configuration.') + } + + const promptedPassword = await promptForConfigPassword(promptMessage) + console.log('[ensureEncryptedConfigEnv] password received:', !!promptedPassword) + + if (!promptedPassword) { + console.log('[ensureEncryptedConfigEnv] password prompt cancelled by user') + throw new Error('Password prompt cancelled by user.') + } + + password = promptedPassword + passwordSource = 'prompted' + } + + const validationEnv = { + ...env, + RCLONE_CONFIG_PASS: password, + } + + const validation = await validateConfigAccess(commandName, validationEnv) + if (validation.success) { + console.log('[ensureEncryptedConfigEnv] password validation succeeded') + env.RCLONE_CONFIG_PASS = password + return + } + + console.error('[ensureEncryptedConfigEnv] password validation failed', validation.code) + + if (passwordSource === 'stored') { + console.log('[ensureEncryptedConfigEnv] clearing invalid stored password') + if (activeConfigId && updateConfigFile) { + try { + updateConfigFile(activeConfigId, { pass: undefined }) + console.log('[ensureEncryptedConfigEnv] stored password cleared') + } catch (error) { + Sentry.captureException(error) + console.error( + '[ensureEncryptedConfigEnv] failed to clear stored password', + error + ) + } + } + + await message( + 'The saved password for this configuration appears to be incorrect. You will be prompted for a new password.', + { + title: 'Invalid Password', + kind: 'error', + okLabel: 'OK', + } + ) + + password = undefined + passwordSource = null + continue + } + + if (!autoPromptForPassword) { + throw new Error('Password required to access encrypted configuration.') + } + + const response = await message('Incorrect password. Please try again.', { + title: 'Invalid Password', + kind: 'error', + buttons: { + ok: 'Try Again', + cancel: 'Cancel', + }, + }) + + if (response !== 'Try Again') { + console.log('[ensureEncryptedConfigEnv] user cancelled password retry') + throw new Error('Password prompt cancelled by user.') + } + + password = undefined + passwordSource = null + } +} + +async function createRcloneCliCommand( + args: string[], + additionalEnv?: Record, + autoPromptForPassword = true +): Promise { + console.log('[createRcloneCliCommand] creating rclone CLI command with args:', args) + const env: Record = {} + const hostStore = useHostStore.getState() + const activeConfig = hostStore.activeConfigFile + + if (!activeConfig || !activeConfig.id) { + throw new Error('No active configuration selected.') + } + + console.log('[createRcloneCliCommand] active config:', activeConfig) + + let configPath: string + try { + configPath = await getConfigPath({ id: activeConfig.id, validate: true }) + } catch (error) { + Sentry.captureException(error) + throw error + } + + console.log('[createRcloneCliCommand] config path:', configPath) + + const configDirectory = getConfigParentFolder(configPath) + console.log('[createRcloneCliCommand] config directory:', configDirectory) + + const proxy = hostStore.proxy + console.log('[createRcloneCliCommand] proxy:', proxy) + if (proxy?.url) { + env.http_proxy = proxy.url + env.https_proxy = proxy.url + env.HTTP_PROXY = proxy.url + env.HTTPS_PROXY = proxy.url + env.no_proxy = proxy.ignoredHosts.join(',') + env.NO_PROXY = proxy.ignoredHosts.join(',') + } + + console.log('[createRcloneCliCommand] checking for system rclone installation') + const hasSystem = await isSystemRcloneInstalled() + console.log('[createRcloneCliCommand] checking for internal rclone installation') + const hasInternal = await isInternalRcloneInstalled() + + console.log('[createRcloneCliCommand] has system:', hasSystem) + console.log('[createRcloneCliCommand] has internal:', hasInternal) + + if (!hasSystem && !hasInternal) { + console.log('[createRcloneCliCommand] no rclone installation found') + const error = new Error('Unable to locate an rclone installation.') + Sentry.captureException(error) + throw error + } + + const flavour = hasSystem ? 'system' : 'internal' + const commandName = flavour === 'system' ? 'rclone-system' : 'rclone-internal' + + if (!hasSystem || activeConfig.id !== 'default') { + console.log('[createRcloneCliCommand] setting config directory and path') + env.RCLONE_CONFIG_DIR = configDirectory + env.RCLONE_CONFIG = configPath.endsWith('rclone.conf') + ? configPath + : `${configDirectory}${sep()}rclone.conf` + } + + if (activeConfig.isEncrypted) { + console.log('[createRcloneCliCommand] ensuring encrypted configuration access') + await ensureEncryptedConfigEnv( + activeConfig, + env, + autoPromptForPassword, + commandName, + `Please enter the current password for "${activeConfig.label}"` + ) + } + + if (additionalEnv) { + console.log('[createRcloneCliCommand] setting additional environment') + Object.assign(env, additionalEnv) + } + + console.log( + '[createRcloneCliCommand] creating command, name:', + commandName, + 'args:', + args, + 'env:', + env + ) + + const command = Command.create(commandName, args, { env }) + + console.log('[createRcloneCliCommand] command created') + + return { + command, + activeConfig, + configPath, + configDirectory, + env, + flavour, + } +} + +export async function runRcloneCli(args: string[], input: string[] = []) { + const { command } = await createRcloneCliCommand(args, undefined, true) + + let stdout = '' + let stderr = '' + + console.log('[runRcloneCli] running command', 'args:', args, 'input:', input) + + return await new Promise((resolve, reject) => { + command.stdout.on('data', (line) => { + console.log('[runRcloneCli] stdout:', line) + stdout += line + }) + command.stderr.on('data', (line) => { + console.log('[runRcloneCli] stderr:', line) + stderr += line + }) + command.addListener('error', (event) => { + console.log('[runRcloneCli] error:', event) + const error = typeof event === 'string' ? new Error(event) : event + Sentry.captureException(error) + reject(error instanceof Error ? error : new Error('Unknown rclone CLI error.')) + }) + command.addListener('close', (event) => { + console.log('[runRcloneCli] close:', event) + + if (event.code === 0) { + resolve() + return + } + + const error = new Error( + `rclone command failed (code ${event.code ?? 'unknown'}): ${stderr || stdout}` + ) + Sentry.captureException(error) + reject(error) + }) + + command + .spawn() + .then(async (child) => { + console.log('[runRcloneCli] child:', child) + for (const line of input) { + console.log('[runRcloneCli] writing input:', line) + await child.write(`${line}\n`) + await new Promise((resolve) => setTimeout(resolve, 100)) + console.log('[runRcloneCli] input written') + } + }) + .catch((error) => { + console.log('[runRcloneCli] error:', error) + Sentry.captureException(error) + reject(error) + }) + }) +} + +export async function restartActiveRclone() { + try { + ;(await getAllWindows()) + .filter((window) => window.label === 'main')[0] + .emit('restart-rclone') + // await getCurrentWindow().emit('restart-rclone') + } catch (error) { + Sentry.captureException(error) + console.error('[restartActiveRclone] failed to emit restart event', error) + } +} diff --git a/lib/rclone/client.ts b/lib/rclone/client.ts new file mode 100644 index 0000000..284ce75 --- /dev/null +++ b/lib/rclone/client.ts @@ -0,0 +1,110 @@ +import { fetch as tauriFetch } from '@tauri-apps/plugin-http' +import pRetry from 'p-retry' +import createRCDClient, { + type OpenApiMethodResponse, + type OpenApiClient, + type OpenApiClientPathsWithMethod, + type OpenApiMaybeOptionalInit, + type OpenApiRequiredKeysOf, + type RCDClient, +} from 'rclone-sdk' +import { usePersistedStore } from '../../store/persisted' + +// const client = createRCDClient({ +// baseUrl: 'http://localhost:5572', +// fetch: (request: Request) => tauriFetch(request), +// }) + +let client: RCDClient | null = null + +function getClient() { + if (!client) { + const currentHost = usePersistedStore.getState().currentHost + if (!currentHost) { + console.error('[rclone] No current host') + throw new Error('No current host') + } + + let authHeader = '' + if (currentHost.authUser && currentHost.authPassword) { + authHeader = `Basic ${btoa(`${currentHost.authUser}:${currentHost.authPassword}`)}` + } + + client = createRCDClient({ + baseUrl: currentHost.url, + headers: authHeader + ? { + 'Authorization': authHeader, + } + : undefined, + fetch: (request: Request) => tauriFetch(request), + }) + } + return client +} + +export function clearClient() { + client = null +} + +type ClientPaths = T extends OpenApiClient ? P : never +type Paths = ClientPaths +type InitParam = OpenApiRequiredKeysOf extends never + ? [(Init & { [key: string]: unknown })?] + : [Init & { [key: string]: unknown }] + +export default async function rclone< + Path extends OpenApiClientPathsWithMethod, + Init extends OpenApiMaybeOptionalInit = OpenApiMaybeOptionalInit< + Paths[Path], + 'post' + >, +>( + path: Path, + ...init: InitParam +): Promise> { + console.log('[rclone] STARTING', path) + + const client = await pRetry(() => getClient(), { + 'maxTimeout': 500, + }) //! for some reason this still fails sometimes + + if (!client) { + throw new Error('Failed to get client after retries') + } + + console.log('[rclone] init', init) + const result = await client.POST( + path, + ...(init as InitParam>) + ) + + if (result?.error) { + console.log('[rclone] result?.error', result.error) + const message = + typeof result.error === 'string' ? result.error : JSON.stringify(result.error) + + throw new Error(message) + } + + const data = result.data as { error?: unknown } | undefined + if (data?.error) { + console.log('[rclone] data?.error', data.error) + const message = typeof data.error === 'string' ? data.error : JSON.stringify(data.error) + + throw new Error(message) + } + + if (!result.response.ok) { + console.log( + '[rclone] !result.response.ok', + result.response.status, + result.response.statusText + ) + throw new Error(`${result.response.status} ${result.response.statusText}`) + } + + console.log('[rclone] ENDING', path) + + return result.data as OpenApiMethodResponse +} diff --git a/lib/rclone/common.ts b/lib/rclone/common.ts index f655ab8..6f1e97d 100644 --- a/lib/rclone/common.ts +++ b/lib/rclone/common.ts @@ -1,24 +1,18 @@ import { appLocalDataDir, sep } from '@tauri-apps/api/path' import { exists, mkdir, writeTextFile } from '@tauri-apps/plugin-fs' -import { fetch } from '@tauri-apps/plugin-http' +import { fetch as tauriFetch } from '@tauri-apps/plugin-http' import { Command } from '@tauri-apps/plugin-shell' +import createRCDClient from 'rclone-sdk' +import { useHostStore } from '../../store/host' +import type { FlagValue } from '../../types/rclone' import { getConfigParentFolder } from '../format' -import { usePersistedStore } from '../store' +import rclone from './client' import { DOUBLE_BACKSLASH_REGEX } from './constants' export async function getDefaultPaths() { console.log('[getDefaultPaths]') - const r = await fetch('http://localhost:5572/config/paths', { - method: 'POST', - }) - - if (!r.ok) { - console.error('[getDefaultPaths] failed to make request to config/paths') - throw new Error('Failed to make request to config/paths') - } - - const defaultPaths = (await r.json()) as { cache: string; config: string; temp: string } + const defaultPaths = await rclone('/config/paths') console.log('[getDefaultPaths] json', JSON.stringify(defaultPaths, null, 2)) @@ -33,6 +27,7 @@ export async function getDefaultPaths() { export async function getSystemConfigPath() { console.log('[getSystemConfigPath] running system rclone') + const instance = Command.create('rclone-system', [ 'rcd', '--rc-no-auth', @@ -53,13 +48,20 @@ export async function getSystemConfigPath() { await new Promise((resolve) => setTimeout(resolve, 200)) try { - const defaultPaths = await getDefaultPaths() + // no host store at this point + const client = createRCDClient({ + baseUrl: 'http://localhost:5572', + fetch: (request: Request) => tauriFetch(request), + }) - if (typeof defaultPaths?.config === 'undefined') { + const defaultPaths = await client.POST('/config/paths', {}) + + const configPath = defaultPaths.data?.config + if (!configPath) { throw new Error('Failed to fetch config path') } - return defaultPaths.config + return configPath.replace(DOUBLE_BACKSLASH_REGEX, '\\') } catch (error) { console.error('[getSystemConfigPath] error', error) if (error instanceof Error) { @@ -166,7 +168,7 @@ export async function isInternalRcloneInstalled() { } } -export function parseRcloneOptions(options: Record) { +export function parseRcloneOptions(options: Record) { console.log('[parseRcloneOptions]', options) return options @@ -247,7 +249,7 @@ export function shouldUpdateRclone(versionData: { yours: string; latest: string console.log('[shouldUpdateRclone] current version:', currentVersion) console.log('[shouldUpdateRclone] latest version:', latestVersion) - if (usePersistedStore.getState().lastSkippedVersion === currentVersion) { + if (useHostStore.getState().lastSkippedVersion === currentVersion) { console.log('[shouldUpdateRclone] current version is in the lastSkippedVersion') return false } diff --git a/lib/rclone/constants.ts b/lib/rclone/constants.ts index 53712e1..5e5fcb2 100644 --- a/lib/rclone/constants.ts +++ b/lib/rclone/constants.ts @@ -1,83 +1,177 @@ export const RCLONE_CONFIG_DEFAULTS = { - 'MultiThreadCutoff': '64M', - 'MultiThreadStreams': 8, - 'MultiThreadChunkSize': '8M', - 'UseListR': true, - 'UseServerModTime': true, - 'BufferSize': '32M', - 'Transfers': 8, - 'Checkers': 16, -} - -export const RCLONE_VFS_DEFAULTS = { - 'ChunkSize': '4M', - 'ChunkStreams': 16, -} + copy: { + 'multi_thread_cutoff': '64M', + 'multi_thread_streams': 8, + 'multi_thread_chunk_size': '8M', + }, + config: { + 'fast_list': true, + 'use_server_modtime': true, + 'buffer_size': '32M', + 'transfers': 8, + 'checkers': 16, + }, + vfs: { + 'chunk_size': '4M', + 'chunk_streams': 16, + }, +} as const export const RCLONE_CONF_REGEX = /[\/\\]rclone\.conf$/ export const DOUBLE_BACKSLASH_REGEX = /\\\\/g -export const SUPPORTED_BACKENDS = [ - 'azureblob', - 'azurefiles', +export const SERVE_TYPES = ['dlna', 'ftp', 'sftp', 'http', 'nfs', 'restic', 's3', 'webdav'] as const + +export const SUPPORTS_CLEANUP = [ + 's3', 'b2', 'box', - 'drive', - 'dropbox', - 'sftp', - 's3', - 'ftp', - 'gcs', - 'protondrive', - 'webdav', - 'onedrive', - 'http', - 'storj', - 'gphotos', - 'netstorage', - 'oos', - 'swift', - 'compress', - 'union', - 'hasher', - 'combine', - 'chunker', - 'memory', - 'crypt', - 'alias', - 'cache', - 'local', - 'hdfs', - 'cloudinary', - 'doi', - 'filelu', - 'filescom', - 'imagekit', - 'internetarchive', - 'koofr', - 'linkbox', - 'mega', - 'opendrive', - 'pixeldrain', - 'seafile', - 'sia', - 'smb', - 'ulozto', - 'gofile', - 'qingstor', 'filefabric', - 'quatrix', - 'sugarsync', - 'hidrive', + 'drive', + 'internetarchive', 'jottacloud', 'mailru', + 'mega', + 'onedrive', + 'oos', 'pcloud', 'pikpak', + 'putio', + 'protondrive', + 'qingstor', + 'seafile', + 'yandex', +] as const + +export const SUPPORTS_PURGE = [ + 'netstorage', + 'box', + 'sharefile', + 'dropbox', + 'filefabric', + 'filescom', + 'gofile', + 'gcs', + 'drive', + 'hdfs', + 'hifile', + 'iclouddrive', + 'imagekit', + 'jottacloud', + 'koofr', + 'mailru', + 'mega', + 'azureblob', + 'onedrive', + 'opendrive', + 'swift', + 'pikpak', + 'pcloud', + 'pixeldrain', 'premiumizeme', 'putio', - 'sharefile', + 'protondrive', + 'quatrix', + 'seafile', + 'sugarsync', + 'storj', + 'webdav', 'yandex', 'zoho', -] +] as const -export const SERVE_TYPES = ['dlna', 'ftp', 'sftp', 'http', 'nfs', 'restic', 's3', 'webdav'] as const +export const SUPPORTS_ABOUT = [ + 'box', + 'dropbox', + 'gofile', + 'drive', + 'hdfs', + 'internetarchive', + 'jottacloud', + 'koofr', + 'mailru', + 'mega', + 'azurefiles', + 'onedrive', + 'opendrive', + 'swift', + 'pcloud', + 'pikpak', + 'pixeldrain', + 'premiumizeme', + 'putio', + 'protondrive', + 'quatrix', + 'seafile', + 'sftp', + 'webdav', + 'yandex', + 'zoho', + 'local', +] as const + +// export const SUPPORTED_OPERATIONS = [ +// { +// id: 'uncategorized', +// name: 'Uncategorized', +// icon: , +// titleColor: 'text-foreground', +// indicatorColor: 'text-foreground-500', +// }, +// { +// id: 'copy', +// name: 'Copy', +// icon: , +// titleColor: 'text-primary-400', +// indicatorColor: 'text-primary-300', +// }, +// { +// id: 'move', +// name: 'Move', +// icon: , +// titleColor: 'text-primary-400', +// indicatorColor: 'text-primary-300', +// }, +// { +// id: 'delete', +// name: 'Delete', +// icon: , +// titleColor: 'text-danger-400', +// indicatorColor: 'text-danger-300', +// }, +// { +// id: 'sync', +// name: 'Sync', +// icon: , +// titleColor: 'text-success-300', +// indicatorColor: 'text-success-300', +// }, +// { +// id: 'bisync', +// name: 'Bisync', +// icon: , +// titleColor: 'text-primary-400', +// indicatorColor: 'text-primary-300', +// }, +// { +// id: 'mount', +// name: 'Mount', +// icon: , +// titleColor: 'text-secondary-500', +// indicatorColor: 'text-secondary-400', +// }, +// { +// id: 'purge', +// name: 'Purge', +// icon: , +// titleColor: 'text-warning-300', +// indicatorColor: 'text-warning-300', +// }, +// { +// id: 'serve', +// name: 'Serve', +// icon: , +// titleColor: 'text-cyan-500', +// indicatorColor: 'text-cyan-300', +// }, +// ] as const diff --git a/lib/rclone/init.ts b/lib/rclone/init.ts index 593b2e9..d4b70bd 100644 --- a/lib/rclone/init.ts +++ b/lib/rclone/init.ts @@ -7,11 +7,14 @@ import { copyFile, exists, mkdir, readTextFile, remove } from '@tauri-apps/plugi import { writeFile } from '@tauri-apps/plugin-fs' import { fetch } from '@tauri-apps/plugin-http' import { platform } from '@tauri-apps/plugin-os' -import { exit } from '@tauri-apps/plugin-process' +import { exit, relaunch } from '@tauri-apps/plugin-process' import { Command } from '@tauri-apps/plugin-shell' +import { useHostStore } from '../../store/host' +import { useStore } from '../../store/memory' +import { usePersistedStore } from '../../store/persisted' import { getConfigParentFolder } from '../format' -import { usePersistedStore, useStore } from '../store' import { openSmallWindow } from '../window' +import { ensureEncryptedConfigEnv } from './cli' import { createConfigFile, getConfigPath, @@ -25,7 +28,7 @@ import { export async function initRclone(args: string[]) { console.log('[initRclone] starting with args:', args) - const system = await isSystemRcloneInstalled() + const system = !(await invoke('is_flathub')) && (await isSystemRcloneInstalled()) console.log('[initRclone] system rclone installed:', system) let internal = await isInternalRcloneInstalled() console.log('[initRclone] internal rclone installed:', internal) @@ -33,8 +36,7 @@ export async function initRclone(args: string[]) { // rclone not available, let's download it if (!system && !internal) { console.log('[initRclone] no rclone installation found, provisioning...') - useStore.setState({ startupDisplayed: true }) - useStore.setState({ startupStatus: 'initializing' }) + useStore.setState({ startupDisplayed: true, startupStatus: 'initializing' }) await openSmallWindow({ name: 'Startup', url: '/startup', @@ -46,6 +48,7 @@ export async function initRclone(args: string[]) { useStore.setState({ startupStatus: 'fatal' }) return } + console.log('[initRclone] provision succeeded') useStore.setState({ startupStatus: 'initialized' }) @@ -92,7 +95,7 @@ export async function initRclone(args: string[]) { console.log('[initRclone] user skipping version:', skipping) if (skipping) { console.log('[initRclone] saving skipped version:', rcloneVersion!.yours) - usePersistedStore.setState({ lastSkippedVersion: rcloneVersion!.yours }) + useHostStore.setState({ lastSkippedVersion: rcloneVersion!.yours }) } } else { console.log('[initRclone] system rclone updated successfully') @@ -119,12 +122,14 @@ export async function initRclone(args: string[]) { console.error('[initRclone] failed to update rclone', error) useStore.setState({ startupStatus: 'error' }) } + + await new Promise((resolve) => setTimeout(resolve, 1000)) } - const persistedState = usePersistedStore.getState() - let configFiles = persistedState.configFiles || [] + const hostState = useHostStore.getState() + let configFiles = hostState.configFiles || [] console.log('[initRclone] loaded config files count:', configFiles.length) - let activeConfigFile = persistedState.activeConfigFile + let activeConfigFile = hostState.activeConfigFile console.log('[initRclone] active config file:', activeConfigFile?.id) if (system) { @@ -135,18 +140,24 @@ export async function initRclone(args: string[]) { console.log('[initRclone] created system config file') } + const existingDefaultConfig = configFiles.find((config) => config.id === 'default') configFiles = configFiles.filter((config) => config.id !== 'default') console.log('[initRclone] filtered config files, remaining count:', configFiles.length) - configFiles.unshift({ - id: 'default', - label: 'Default config', - sync: undefined, - isEncrypted: false, - pass: undefined, - passCommand: undefined, - }) + + const defaultConfig = existingDefaultConfig + ? existingDefaultConfig + : { + id: 'default', + label: 'Default config', + sync: undefined, + isEncrypted: false, + pass: undefined, + passCommand: undefined, + } + + configFiles.unshift(defaultConfig) console.log('[initRclone] added default config to list') - usePersistedStore.setState({ configFiles }) + useHostStore.setState({ configFiles }) if (!activeConfigFile) { console.log('[initRclone] no active config file, setting default') @@ -157,7 +168,7 @@ export async function initRclone(args: string[]) { } console.log('[initRclone] set active config file to:', activeConfigFile.id) - usePersistedStore.setState({ activeConfigFile }) + useHostStore.setState({ activeConfigFile }) } if (internal && activeConfigFile.id === 'default') { @@ -196,12 +207,12 @@ export async function initRclone(args: string[]) { await getConfigPath({ id: 'default', validate: true }) ) console.log('[initRclone] switched to default config') - usePersistedStore.setState({ activeConfigFile: configFiles[0] }) + useHostStore.setState({ activeConfigFile: configFiles[0] }) } } - let password: string | null = activeConfigFile.pass || activeConfigFile.passCommand || null - console.log('[initRclone] password configured:', !!password) + const passwordConfigured = activeConfigFile.pass || activeConfigFile.passCommand || null + console.log('[initRclone] password configured:', !!passwordConfigured) try { console.log('[initRclone] reading config file', configPath) const configContent = await readTextFile(configPath) @@ -211,39 +222,26 @@ export async function initRclone(args: string[]) { if (isEncrypted) { console.log('[initRclone] config file is encrypted') - if (password) { - console.log('[initRclone] using existing password') + if (passwordConfigured) { + console.log('[initRclone] using existing password configuration') } else { - console.log('[initRclone] prompting user for password') - password = await promptForConfigPassword(activeConfigFile.label) - console.log('[initRclone] password received:', !!password) + console.log('[initRclone] no stored password configured') + } - if (!password) { - console.error('[initRclone] no password provided, exiting') - await message('Password is required for encrypted configurations.', { - title: 'Password Required', - kind: 'error', - okLabel: 'OK', - }) - await exit(0) - return - } + if (!activeConfigFile.isEncrypted) { + console.log('[initRclone] updating config file encryption flag') + const updatedConfigFile = { ...activeConfigFile, isEncrypted: true } + const updatedConfigFiles = configFiles.map((config) => + config.id === activeConfigFile!.id ? updatedConfigFile : config + ) + useHostStore.setState({ + configFiles: updatedConfigFiles, + activeConfigFile: updatedConfigFile, + }) + console.log('[initRclone] saved updated encryption flag') - if (!activeConfigFile.isEncrypted) { - console.log('[initRclone] updating config file encryption flag') - const updatedConfigFile = { ...activeConfigFile, isEncrypted: true } - const updatedConfigFiles = configFiles.map((config) => - config.id === activeConfigFile!.id ? updatedConfigFile : config - ) - usePersistedStore.setState({ - configFiles: updatedConfigFiles, - activeConfigFile: updatedConfigFile, - }) - console.log('[initRclone] saved updated encryption flag') - - // Update activeConfigFile reference for the rest of the function - activeConfigFile = updatedConfigFile - } + // Update activeConfigFile reference for the rest of the function + activeConfigFile = updatedConfigFile } } else if (activeConfigFile.isEncrypted) { console.log('[initRclone] config file is not encrypted, clearing encryption flag') @@ -251,7 +249,7 @@ export async function initRclone(args: string[]) { const updatedConfigFiles = configFiles.map((config) => config.id === activeConfigFile!.id ? updatedConfigFile : config ) - usePersistedStore.setState({ + useHostStore.setState({ configFiles: updatedConfigFiles, activeConfigFile: updatedConfigFile, }) @@ -279,11 +277,11 @@ export async function initRclone(args: string[]) { env: {}, } - if (persistedState.proxy) { - console.log('[initRclone] proxy configured:', persistedState.proxy.url) + if (hostState.proxy) { + console.log('[initRclone] proxy configured:', hostState.proxy.url) try { console.log('[initRclone] testing proxy connection') - await invoke('test_proxy_connection', { proxy_url: persistedState.proxy.url }) + await invoke('test_proxy_connection', { proxy_url: hostState.proxy.url }) console.log('[initRclone] proxy connection successful') } catch (error) { console.error('[initRclone] proxy connection failed:', error) @@ -305,36 +303,62 @@ export async function initRclone(args: string[]) { } } console.log('[initRclone] setting proxy environment variables') - extraParams.env.http_proxy = persistedState.proxy.url - extraParams.env.https_proxy = persistedState.proxy.url - extraParams.env.HTTP_PROXY = persistedState.proxy.url - extraParams.env.HTTPS_PROXY = persistedState.proxy.url - extraParams.env.no_proxy = persistedState.proxy.ignoredHosts.join(',') - extraParams.env.NO_PROXY = persistedState.proxy.ignoredHosts.join(',') + extraParams.env.http_proxy = hostState.proxy.url + extraParams.env.https_proxy = hostState.proxy.url + extraParams.env.HTTP_PROXY = hostState.proxy.url + extraParams.env.HTTPS_PROXY = hostState.proxy.url + extraParams.env.no_proxy = hostState.proxy.ignoredHosts.join(',') + extraParams.env.NO_PROXY = hostState.proxy.ignoredHosts.join(',') console.log( '[initRclone] proxy env vars set, ignored hosts:', - persistedState.proxy.ignoredHosts.length + hostState.proxy.ignoredHosts.length ) } - if (activeConfigFile.isEncrypted) { - console.log('[initRclone] setting encryption environment variables') - extraParams.env.RCLONE_ASK_PASSWORD = 'false' - if (activeConfigFile.passCommand) { - console.log('[initRclone] using passCommand for decryption') - extraParams.env.RCLONE_CONFIG_PASS_COMMAND = activeConfigFile.passCommand - } else { - console.log('[initRclone] using password for decryption') - extraParams.env.RCLONE_CONFIG_PASS = activeConfigFile.pass || password! - } - } - if (internal || activeConfigFile.id !== 'default') { console.log('[initRclone] setting custom config path:', configFolderPath) extraParams.env.RCLONE_CONFIG_DIR = configFolderPath extraParams.env.RCLONE_CONFIG = `${configFolderPath}${sep()}rclone.conf` } + const commandName = system ? 'rclone-system' : internal ? 'rclone-internal' : null + + if (activeConfigFile.isEncrypted && commandName) { + console.log('[initRclone] ensuring encrypted configuration access') + try { + await ensureEncryptedConfigEnv( + activeConfigFile, + extraParams.env, + true, + commandName, + `Please enter the current password for "${activeConfigFile.label}"` + ) + } catch (error) { + if (error instanceof Error && error.message === 'Password prompt cancelled by user.') { + console.error('[initRclone] password prompt cancelled by user') + const response = await message( + 'Password is required for encrypted configurations.', + { + title: 'Password Required', + kind: 'error', + buttons: { + cancel: 'Close', + ok: 'Try Again', + }, + } + ) + console.log('[initRclone] message response:', response) + if (response === 'Try Again') { + await relaunch() + return + } + await exit(0) + return + } + throw error + } + } + console.log('[initRclone] extraParams', extraParams) if (system) { @@ -576,30 +600,3 @@ export async function provisionRclone() { return true } - -/** - * Prompts the user for a password for an encrypted configuration - * @param configLabel - The label of the configuration file - * @returns Promise - The password entered by the user, or null if cancelled - */ -async function promptForConfigPassword(configLabel: string): Promise { - console.log('[promptForConfigPassword] prompting for config:', configLabel) - try { - const result = await invoke('prompt_password', { - title: 'Rclone UI', - message: `Please enter the password for the encrypted configuration "${configLabel}".`, - }) - console.log('[promptForConfigPassword] received result type:', typeof result) - - if (typeof result === 'string') { - console.log('[promptForConfigPassword] password received, length:', result.length) - return result.trim() - } - - console.log('[promptForConfigPassword] no password received (cancelled or empty)') - return null - } catch (error) { - console.error('[promptForConfigPassword] Error prompting for password:', error) - return null - } -} diff --git a/lib/rclone/mount.ts b/lib/rclone/mount.ts index 5d134f8..2b7d7aa 100644 --- a/lib/rclone/mount.ts +++ b/lib/rclone/mount.ts @@ -4,7 +4,6 @@ import { exists, writeFile } from '@tauri-apps/plugin-fs' import { fetch } from '@tauri-apps/plugin-http' import { revealItemInDir } from '@tauri-apps/plugin-opener' import { platform } from '@tauri-apps/plugin-os' -import { Command } from '@tauri-apps/plugin-shell' export async function needsMountPlugin() { console.log('[needsMountPlugin]') @@ -74,36 +73,3 @@ export async function dialogGetMountPlugin() { } } } - -export async function unmount(mountPoint: string, force = false) { - console.log('[unmount]', mountPoint, force) - - const command = Command.create('umount', [force ? '-f' : '', mountPoint]) - - const output = await command.execute() - - if (output.code !== 0) { - if (output.stderr.toLowerCase().includes('busy')) { - const answer = await ask('This resource is busy, do you wish to force unmount?', { - title: 'Could not unmount', - kind: 'warning', - okLabel: 'Force Unmount', - cancelLabel: 'Cancel', - }) - if (answer) { - return await unmount(mountPoint, true) - } - throw new Error(output.stderr) - } - - if (output.stderr.toLowerCase().includes('not currently mounted')) { - console.error('[unmount] not currently mounted') - return - } - - console.error('[unmount] failed to unmount', output.stderr) - throw new Error(output.stderr) - } - - return -} diff --git a/lib/rclone/overrides.ts b/lib/rclone/overrides.ts new file mode 100644 index 0000000..274ddb1 --- /dev/null +++ b/lib/rclone/overrides.ts @@ -0,0 +1,59 @@ +export const OVERRIDES = { + alias: { + Description: 'ALIAS', + }, + crypt: { + Description: 'CRYPT', + }, + union: { + Description: 'UNION', + }, + memory: { + Description: 'MEMORY', + }, + archive: { + Description: 'ARCHIVE', + }, + compress: { + Description: 'COMPRESS', + }, + hasher: { + Description: 'HASHER', + }, + chunker: { + Description: 'CHUNKER', + }, + cache: { + Description: 'CACHE', + }, + combine: { + Description: 'COMBINE', + }, + 'google cloud storage': { + Description: 'Google Cloud Storage', + }, + 'hdfs': { + Description: 'Hadoop Distributed File System', + }, + 'pixeldrain': { + Description: 'Pixeldrain', + }, + 'swift': { + Description: 'Openstack Swift', + }, + 'azureblob': { + Description: 'Azure Blob Storage', + }, + 'azurefiles': { + Description: 'Azure Files', + }, + 'filelu': { + Description: 'FileLu', + }, + 's3': { + Description: 'Amazon S3 & Compatible Storage', + }, + 'seafile': { + Description: 'Seafile', + }, +} diff --git a/lib/store.ts b/lib/store.ts deleted file mode 100644 index 45a31ef..0000000 --- a/lib/store.ts +++ /dev/null @@ -1,283 +0,0 @@ -import { LazyStore } from '@tauri-apps/plugin-store' -import { shared } from 'use-broadcast-ts' -import { create } from 'zustand' -import { type StateStorage, createJSONStorage, persist } from 'zustand/middleware' -import type { ConfigFile } from '../types/config' -import type { ScheduledTask } from '../types/task' -import type { Template } from '../types/template' -import type { SERVE_TYPES } from './rclone/constants' - -// const { LazyStore } = window.__TAURI__.store -const store = new LazyStore('store.json') - -export interface RemoteConfig { - disabledActions?: ('tray' | 'tray-mount' | 'tray-browse' | 'tray-remove' | 'tray-cleanup')[] - - defaultRemotePath?: string - defaultMountPoint?: string - mountOnStart?: boolean - - mountDefaults?: Record - vfsDefaults?: Record - filterDefaults?: Record - copyDefaults?: Record - moveDefaults?: Record - syncDefaults?: Record - configDefaults?: Record - serveDefaults?: Record<(typeof SERVE_TYPES)[number], Record> - bisyncDefaults?: Record - remoteDefaults?: Record -} - -interface State { - firstWindow: boolean - - rcloneLoaded: boolean - rcloneAuth: string - rcloneAuthHeader: string - - remotes: string[] - setRemotes: (remotes: string[]) => void - addRemote: (remote: string) => void - removeRemote: (remote: string) => void - - startupStatus: - | null - | 'initializing' - | 'initialized' - | 'updating' - | 'updated' - | 'error' - | 'fatal' - - startupDisplayed: boolean -} - -type SupportedAction = - | 'tray-mount' - | 'tray-sync' - | 'tray-copy' - | 'tray-serve' - | 'tray-move' - | 'tray-bisync' - | 'tray-delete' - | 'tray-purge' - | 'tray-download' - -interface PersistedState { - remoteConfigList: Record - setRemoteConfig: (remote: string, config: RemoteConfig) => void - mergeRemoteConfig: (remote: string, config: RemoteConfig) => void - - disabledActions: SupportedAction[] - - setDisabledActions: (actions: SupportedAction[]) => void - - proxy: - | { - url: string - ignoredHosts: string[] - } - | undefined - - favoritePaths: { remote: string; path: string; added: number }[] - - settingsPass: string | undefined - setSettingsPass: (pass: string | undefined) => void - - licenseKey: string | undefined - setLicenseKey: (key: string | undefined) => void - licenseValid: boolean - setLicenseValid: (valid: boolean) => void - - startOnBoot: boolean - setStartOnBoot: (startOnBoot: boolean) => void - - scheduledTasks: ScheduledTask[] - addScheduledTask: ( - task: Omit< - ScheduledTask, - 'id' | 'isRunning' | 'currentRunId' | 'lastRun' | 'configId' | 'isEnabled' - > - ) => void - removeScheduledTask: (id: string) => void - updateScheduledTask: (id: string, task: Partial) => void - - templates: Template[] - - configFiles: ConfigFile[] - addConfigFile: (configFile: ConfigFile) => void - removeConfigFile: (id: string) => void - activeConfigFile: ConfigFile | null - setActiveConfigFile: (configFile: string) => void - updateConfigFile: (id: string, configFile: Partial) => void - - lastSkippedVersion: string | undefined - - hideStartup: boolean - - themeV2: { - tray: 'light' | 'dark' | undefined - } -} - -const getStorage = (store: LazyStore): StateStorage => ({ - getItem: async (name: string): Promise => { - console.log('getItem', { name }) - return (await store.get(name)) || null - }, - setItem: async (name: string, value: string): Promise => { - console.log('setItem', { name, value }) - await store.set(name, value) - await store.save() - }, - removeItem: async (name: string): Promise => { - console.log('removeItem', { name }) - await store.delete(name) - await store.save() - }, -}) - -export const useStore = create()( - shared( - (set) => ({ - firstWindow: true, - - rcloneLoaded: false, - rcloneAuth: '', - rcloneAuthHeader: '', - - remotes: [], - setRemotes: (remotes: string[]) => set((_) => ({ remotes })), - addRemote: (remote: string) => - set((state) => ({ remotes: [...state.remotes, remote] })), - removeRemote: (remote: string) => - set((state) => ({ remotes: state.remotes.filter((r) => r !== remote) })), - - startupStatus: null, - startupDisplayed: false, - }), - { name: 'shared-store' } - ) -) - -export const usePersistedStore = create()( - persist( - (set) => ({ - remoteConfigList: {}, - setRemoteConfig: (remote: string, config: Record) => - set((state) => ({ - remoteConfigList: { ...state.remoteConfigList, [remote]: config }, - })), - mergeRemoteConfig: (remote: string, config: Record) => - set((state) => ({ - remoteConfigList: { - ...state.remoteConfigList, - [remote]: { ...state.remoteConfigList[remote], ...config }, - }, - })), - - disabledActions: [], - setDisabledActions: (actions: SupportedAction[]) => - set((_) => ({ disabledActions: actions })), - - favoritePaths: [], - - proxy: undefined, - - settingsPass: undefined, - setSettingsPass: (pass: string | undefined) => set((_) => ({ settingsPass: pass })), - - licenseKey: undefined, - setLicenseKey: (key: string | undefined) => set((_) => ({ licenseKey: key })), - licenseValid: false, - setLicenseValid: (valid: boolean) => set((_) => ({ licenseValid: valid })), - - startOnBoot: false, - setStartOnBoot: (startOnBoot: boolean) => set((_) => ({ startOnBoot })), - - scheduledTasks: [], - addScheduledTask: ( - task: Omit< - ScheduledTask, - 'id' | 'isRunning' | 'currentRunId' | 'lastRun' | 'configId' | 'isEnabled' - > - ) => { - const state = usePersistedStore.getState() - const configId = state.activeConfigFile?.id - - if (!configId) { - console.error('No active config file for scheduled task') - throw new Error('No active config file') - } - - set((state) => ({ - scheduledTasks: [ - ...state.scheduledTasks, - { - ...task, - id: crypto.randomUUID(), - isRunning: false, - isEnabled: true, - configId, - }, - ], - })) - }, - removeScheduledTask: (id: string) => - set((state) => ({ - scheduledTasks: state.scheduledTasks.filter((t) => t.id !== id), - })), - updateScheduledTask: (id: string, task: Partial) => - set((state) => ({ - scheduledTasks: state.scheduledTasks.map((t) => - t.id === id ? { ...t, ...task } : t - ), - })), - - templates: [], - - configFiles: [], - addConfigFile: (configFile: ConfigFile) => - set((state) => ({ - configFiles: [...state.configFiles, configFile], - })), - removeConfigFile: (id: string) => - set((state) => ({ - configFiles: state.configFiles.filter((f) => f.id !== id), - })), - activeConfigFile: null, - setActiveConfigFile: (id: string) => - set((state) => ({ - activeConfigFile: state.configFiles.find((f) => f.id === id) || null, - })), - updateConfigFile: (id: string, configFile: Partial) => - set((state) => ({ - configFiles: state.configFiles.map((f) => - f.id === id ? { ...f, ...configFile } : f - ), - })), - - lastSkippedVersion: undefined, - - hideStartup: false, - - themeV2: { - tray: undefined, - }, - }), - { - name: 'store', - storage: createJSONStorage(() => getStorage(store)), - version: 1, - } - ) -) - -// useStore.persist.onFinishHydration(() => { -// console.log('onFinishHydration') -// }) - -store.onKeyChange('store', async (_) => { - await usePersistedStore.persist.rehydrate() -}) diff --git a/lib/tray.ts b/lib/tray.ts index 13aa13f..4be08e6 100644 --- a/lib/tray.ts +++ b/lib/tray.ts @@ -1,171 +1,189 @@ import * as Sentry from '@sentry/browser' -import { Menu } from '@tauri-apps/api/menu' -import { MenuItem } from '@tauri-apps/api/menu' +import { invoke } from '@tauri-apps/api/core' +import { Menu, MenuItem, PredefinedMenuItem } from '@tauri-apps/api/menu' import { resolveResource } from '@tauri-apps/api/path' -import type { TrayIconEvent } from '@tauri-apps/api/tray' -import { TrayIcon } from '@tauri-apps/api/tray' -import { getAllWindows, getCurrentWindow } from '@tauri-apps/api/window' -import { ask } from '@tauri-apps/plugin-dialog' +import { TrayIcon, type TrayIconEvent } from '@tauri-apps/api/tray' +import { getCurrentWindow } from '@tauri-apps/api/window' +import { ask, message } from '@tauri-apps/plugin-dialog' +import { openUrl } from '@tauri-apps/plugin-opener' import { platform } from '@tauri-apps/plugin-os' -import { buildMenu } from './menu' -import { usePersistedStore } from './store' -import { resetMainWindow } from './window' +import { exit } from '@tauri-apps/plugin-process' +import { usePersistedStore } from '../store/persisted' +import { openSmallWindow, openWindow } from './window' -export async function triggerTrayRebuild() { - return getAllWindows().then((windows) => { - windows.find((w) => w.label === 'main')?.emit('rebuild-tray') +async function buildMenu() { + console.log('[buildMenu]') + + const menuItems: (MenuItem | PredefinedMenuItem)[] = [] + + const onboardingMenuItem = await MenuItem.new({ + id: 'onboarding', + text: 'Onboarding', + action: async () => { + await openSmallWindow({ + name: 'Onboarding', + url: '/onboarding', + }) + }, }) -} + menuItems.push(onboardingMenuItem) -let interval: NodeJS.Timeout | null = null + const openMenuItem = await MenuItem.new({ + id: 'open', + text: 'Open', + action: async () => { + await invoke('show_toolbar') + }, + }) + menuItems.push(openMenuItem) -async function getTray() { - return await TrayIcon.getById('main-tray') + await PredefinedMenuItem.new({ + item: 'Separator', + }).then((item) => { + menuItems.push(item) + }) + + const transfersMenuItem = await MenuItem.new({ + id: 'transfers', + text: 'Transfers', + action: async () => { + await openWindow({ + name: 'Transfers', + url: '/transfers', + }) + }, + }) + menuItems.push(transfersMenuItem) + + const schedulesMenuItem = await MenuItem.new({ + id: 'schedules', + text: 'Schedules', + action: async () => { + await openWindow({ + name: 'Schedules', + url: '/schedules', + }) + }, + }) + menuItems.push(schedulesMenuItem) + + const templatesMenuItem = await MenuItem.new({ + id: 'templates', + text: 'Templates', + action: async () => { + await openWindow({ + name: 'Templates', + url: '/templates', + }) + }, + }) + menuItems.push(templatesMenuItem) + + await PredefinedMenuItem.new({ + item: 'Separator', + }).then((item) => { + menuItems.push(item) + }) + + const settingsItem = await MenuItem.new({ + id: 'settings', + text: 'Settings', + action: async () => { + await openWindow({ + name: 'Settings', + url: '/settings', + }) + }, + }) + menuItems.push(settingsItem) + + const issuesItem = await MenuItem.new({ + id: 'issues', + text: 'Issues?', + action: async () => { + const confirmed = await ask( + 'Please open an issue on Github and we will get it sorted in less than 48 hours, no matter if you have a license or not.', + { + title: 'Sorry ):', + kind: 'info', + okLabel: 'Open Github', + cancelLabel: 'Cancel', + } + ) + if (confirmed) { + await openUrl('https://github.com/rclone-ui/rclone-ui/issues') + } + }, + }) + menuItems.push(issuesItem) + + const quitItem = await MenuItem.new({ + id: 'quit', + text: 'Quit', + action: async () => { + await getCurrentWindow().emit('close-app') + }, + }) + menuItems.push(quitItem) + + return await Menu.new({ + id: 'main-menu', + items: menuItems, + }) } async function resolveTrayIconForTheme() { console.log('[resolveTrayIconForTheme]') - const existingTheme = usePersistedStore.getState().themeV2 + const existingTheme = usePersistedStore.getState().appearance console.log('[resolveTrayIconForTheme] existingTheme', existingTheme) - if (platform() !== 'macos' && 'tray' in existingTheme && existingTheme.tray) { - const pickedPath = - existingTheme.tray === 'dark' - ? 'icons/favicon/icon.png' - : 'icons/favicon/icon-light.png' + if (existingTheme.tray === 'system') { + if (platform() === 'macos') { + return await resolveResource('icons/favicon/icon.png') + } - return await resolveResource(pickedPath) - } - - let theme: 'light' | 'dark' = 'dark' - - if (['windows', 'macos'].includes(platform())) { try { const currentWindow = getCurrentWindow() const windowTheme = await currentWindow.theme() console.log('[resolveTrayIconForTheme] windowTheme', windowTheme) - theme = windowTheme === 'dark' ? 'dark' : 'light' - } catch {} - } else { - const answer = await ask( - 'Based on your tray/menubar, how do you want the icon to look?\n\nIf your menubar has a dark background select light, otherwise select dark.', - { - title: 'Greetings, Linux User', - okLabel: 'Dark', - cancelLabel: 'Light', - } - ) + const pickedPath = + windowTheme === 'dark' ? 'icons/favicon/icon.png' : 'icons/favicon/icon-light.png' - if (answer) { - theme = 'dark' - } else { - theme = 'light' + return await resolveResource(pickedPath) + } catch { + await message('Failed to get window theme', { + title: 'Error', + kind: 'error', + }) + await new Promise((resolve) => setTimeout(resolve, 5000)) + await exit() } } - console.log('[resolveTrayIconForTheme] theme', theme) - - usePersistedStore.setState({ themeV2: { tray: theme } }) - - const pickedPath = theme === 'dark' ? 'icons/favicon/icon.png' : 'icons/favicon/icon-light.png' - console.log('[resolveTrayIconForTheme] pickedPath', pickedPath) + const pickedPath = + existingTheme.tray === 'dark' ? 'icons/favicon/icon-light.png' : 'icons/favicon/icon.png' return await resolveResource(pickedPath) } -export async function showLoadingTray() { - console.log('[showLoadingTray]') - - if (platform() === 'linux') { - console.log('[showLoadingTray] platform is linux, skipping') - return - } - - const tray = await getTray() - if (!tray) { - console.error('[showLoadingTray] tray not found') - return - } - - const globeIconPath = await resolveResource('icons/favicon/frame_00_delay-0.1s.png') - - const quitItem = await MenuItem.new({ - id: 'quit-loading', - text: 'Quit', - action: async () => { - const answer = await ask('An operation is in progress, are you sure you want to exit?') - if (answer) { - await getCurrentWindow().emit('close-app') - } - }, - }) - - const loadingMenu = await Menu.new({ - id: 'loading-menu', - items: [quitItem], - }) - - await tray.setMenu(loadingMenu) - await tray.setIcon(globeIconPath) - await tray.setTooltip('Loading...') - - let currentIcon = 1 - - interval = setInterval(async () => { - if (currentIcon > 17) { - currentIcon = 1 - } - const globeIconPath = await resolveResource( - `icons/favicon/frame_${currentIcon < 10 ? '0' : ''}${currentIcon}_delay-0.1s.png` - ) - await tray?.setIcon(globeIconPath) - currentIcon += 1 - }, 200) -} - -export async function showDefaultTray() { - console.log('[showDefaultTray]') - - const tray = await getTray() - if (!tray) { - console.error('[showDefaultTray] tray not found') - return - } - - if (interval) { - clearInterval(interval) - interval = null - } - - const newMenu = await buildMenu() - await tray.setMenu(newMenu) - - const iconPath = await resolveTrayIconForTheme() - await tray.setIcon(iconPath) - await tray.setTooltip('Rclone') - - console.log('[showDefaultTray] tray menu rebuilt') -} - export async function initTray() { console.log('[initTray] platform', platform()) try { console.log('[initTray]') - const initialIcon = await resolveResource('icons/favicon/icon.png') await TrayIcon.new({ - id: 'main-tray', - icon: initialIcon, + id: 'rclone-menu5', + menu: await buildMenu(), + icon: await resolveTrayIconForTheme(), tooltip: 'Rclone', menuOnLeftClick: true, + iconAsTemplate: true, action: async (event: TrayIconEvent) => { if (event.type === 'Click') { console.log('[onTrayAction] tray clicked:', event) - - await resetMainWindow() } }, }) diff --git a/lib/window.ts b/lib/window.ts index 1b424d8..8789ff3 100644 --- a/lib/window.ts +++ b/lib/window.ts @@ -1,24 +1,6 @@ +import { invoke } from '@tauri-apps/api/core' import { WebviewWindow } from '@tauri-apps/api/webviewWindow' -import { - LogicalSize, - PhysicalSize, - availableMonitors, - currentMonitor, - getAllWindows, -} from '@tauri-apps/api/window' import { platform } from '@tauri-apps/plugin-os' -import { useStore } from './store' -import { showDefaultTray, showLoadingTray } from './tray' - -export async function resetMainWindow() { - const window = await getAllWindows().then((w) => w.find((w) => w.label === 'main')) - if (!window) return - - await window.setSize(new PhysicalSize(0, 0)) - await window.center() - await window.hide() - await window.setAlwaysOnTop(true) -} export async function openFullWindow({ name, @@ -28,53 +10,8 @@ export async function openFullWindow({ url: string }) { console.log('[openFullWindow] ', name, url) - - const w = new WebviewWindow(name, { - height: 0, - width: 0, - visibleOnAllWorkspaces: false, - resizable: true, - visible: false, - focus: true, - title: name, - decorations: true, - url: url, - theme: 'dark', - // @ts-expect-error - backgroundThrottling: 'disabled', - }) - - let monitor = await currentMonitor() - - if (!monitor) { - monitor = (await availableMonitors())[0] - } - - const size = monitor?.size - - if (!size) { - console.error('[openFullWindow] no monitor found') - throw new Error('No monitor found') - } - - if (platform() === 'windows') { - // subtract from the height to correct for the taskbar - size.height -= 100 - } - - await w.hide() - await w.setSize(size) - await w.center() - await w.setZoom(1) - await w.show() - await w.setFocus() - - if (platform() === 'linux') { - await w.setResizable(false) - await w.setResizable(true) - } - - return w + await invoke('open_full_window', { name, url }) + return WebviewWindow.getByLabel(name) } export async function openWindow({ @@ -89,44 +26,8 @@ export async function openWindow({ height?: number }) { console.log('[openWindow] ', name, url) - - const isFirstWindow = useStore.getState().firstWindow - - const w = new WebviewWindow(name, { - height: 0, - width: 0, - resizable: false, - visibleOnAllWorkspaces: false, - visible: false, - focus: true, - title: name, - decorations: false, - url: url, - theme: 'dark', - // @ts-expect-error - backgroundThrottling: 'disabled', - }) - - await showLoadingTray() - await new Promise((resolve) => setTimeout(resolve, isFirstWindow ? 900 : 150)) - await showDefaultTray() - - // await w.hide() - await w.setSize(new LogicalSize(width, height)) - await w.center() - await w.setDecorations(true) - await w.setZoom(1) - await w.show() - await w.setFocus() - - useStore.setState({ firstWindow: false }) - - if (platform() === 'linux') { - await w.setResizable(false) - await w.setResizable(true) - } - - return w + await invoke('open_window', { name, url, width, height }) + return WebviewWindow.getByLabel(name) } export async function openSmallWindow({ @@ -137,51 +38,14 @@ export async function openSmallWindow({ url: string }) { console.log('[openSmallWindow] ', name, url) - - const isFirstWindow = useStore.getState().firstWindow - - const w = new WebviewWindow(name, { - height: 0, - width: 0, - resizable: false, - visibleOnAllWorkspaces: false, - visible: false, - focus: true, - title: name, - decorations: false, - url: url, - theme: 'dark', - closable: false, - // @ts-expect-error - backgroundThrottling: 'disabled', - }) - - await new Promise((resolve) => setTimeout(resolve, isFirstWindow ? 900 : 150)) - - await w.setSize(new LogicalSize(800, 500)) - await w.center() - await w.setZoom(1) - await w.show() - await w.setFocus() - - useStore.setState({ firstWindow: false }) - - if (platform() === 'linux') { - await w.setResizable(false) - await w.setResizable(true) - } - - return w + await invoke('open_small_window', { name, url }) + return WebviewWindow.getByLabel(name) } export async function lockWindows(ids?: string[]) { - const windows = await getAllWindows() - const lockedWindows = ids ? windows.filter((w) => ids.includes(w.label)) : windows - await Promise.all(lockedWindows.map((w) => w.setClosable(false))) + await invoke('lock_windows', { ids: ids ?? null }) } export async function unlockWindows(ids?: string[]) { - const windows = await getAllWindows() - const unlockedWindows = ids ? windows.filter((w) => ids.includes(w.label)) : windows - await Promise.all(unlockedWindows.map((w) => w.setClosable(true))) + await invoke('unlock_windows', { ids: ids ?? null }) } diff --git a/main.ts b/main.ts index 01947cc..6d2096c 100644 --- a/main.ts +++ b/main.ts @@ -2,34 +2,43 @@ import * as Sentry from '@sentry/browser' import { getVersion as getUiVersion } from '@tauri-apps/api/app' import { invoke } from '@tauri-apps/api/core' import { getCurrentWindow } from '@tauri-apps/api/window' +import { getCurrent, onOpenUrl } from '@tauri-apps/plugin-deep-link' import { ask, message } from '@tauri-apps/plugin-dialog' import { debug, error, info, trace, warn } from '@tauri-apps/plugin-log' import { platform } from '@tauri-apps/plugin-os' import { exit, relaunch } from '@tauri-apps/plugin-process' +import type { Child } from '@tauri-apps/plugin-shell' import { check } from '@tauri-apps/plugin-updater' import { CronExpressionParser } from 'cron-parser' import { defaultOptions } from 'tauri-plugin-sentry-api' +import { getDeepLinkUrl, handleDeepLinkUrl } from './lib/deep' import { isDirectoryEmpty } from './lib/fs' +import { LOCAL_HOST_ID, getHostInfo } from './lib/hosts' import { validateLicense } from './lib/license' import notify from './lib/notify' +import queryClient from './lib/query' import { - getRemote, - listJobs, - listRemotes, - mountRemote, + listTransfers, + startBisync, startCopy, startDelete, + startMount, startMove, startPurge, startSync, } from './lib/rclone/api' +import rcloneClient from './lib/rclone/client' import { compareVersions } from './lib/rclone/common' -import { SUPPORTED_BACKENDS } from './lib/rclone/constants' import { initRclone } from './lib/rclone/init' -import { usePersistedStore, useStore } from './lib/store' -import { initTray, showDefaultTray, showLoadingTray } from './lib/tray' +import { initTray } from './lib/tray' import { openSmallWindow } from './lib/window' -import type { ScheduledTask } from './types/task' +import { initHostStore, useHostStore } from './store/host' +import { useStore } from './store/memory' +import { usePersistedStore } from './store/persisted' +import type { ScheduledTask } from './types/cron' + +let currentRcloneChild: Child | null = null +let rcloneListenersRegistered = false try { Sentry.init({ @@ -76,32 +85,99 @@ async function waitForHydration() { console.log('[waitForHydration] store hydrated') } -async function validateInstance() { - console.log('[validateInstance]') +async function initializeHostStore() { + console.log('[initializeHostStore] initializing') + const currentHost = usePersistedStore.getState().currentHost + // Default to 'local' if fresh install/no host selected + const hostId = currentHost?.id || 'local' - const isOnline = navigator.onLine + await initHostStore(hostId) - if (!isOnline && platform() !== 'linux') { - await message( - 'You are not connected to the internet. Please check your connection and try again.', + console.log('[initializeHostStore] initialized for', hostId) +} + +async function checkHostReachability(): Promise { + console.log('[checkHostReachability] checking host reachability') + + const currentHost = usePersistedStore.getState().currentHost + + // If no host selected or local host, skip check (local rclone hasn't started yet) + if (!currentHost || currentHost.id === LOCAL_HOST_ID) { + console.log('[checkHostReachability] local or no host, skipping') + return + } + + console.log('[checkHostReachability] checking remote host:', currentHost.name) + + const checkReachability = async (): Promise => { + try { + const hostInfo = await getHostInfo({ + url: currentHost.url, + authUser: currentHost.authUser, + authPassword: currentHost.authPassword, + }) + return hostInfo !== null + } catch (err) { + console.error('[checkHostReachability] error:', err) + return false + } + } + + let isReachable = await checkReachability() + + while (!isReachable) { + console.log('[checkHostReachability] host not reachable') + + const answer = await ask( + `The selected host "${currentHost.name}" is not reachable.\n\nURL: ${currentHost.url}\n\nWould you like to retry, switch to local host, or exit?`, { - title: 'Error', - kind: 'error', - okLabel: 'Exit', + title: 'Host Not Reachable', + kind: 'warning', + okLabel: 'Retry', + cancelLabel: 'Use Local', } ) - return await exit(0) + + if (answer) { + // User chose to retry + console.log('[checkHostReachability] retrying connection') + isReachable = await checkReachability() + } else { + // User chose to use local host + console.log('[checkHostReachability] switching to local host') + const hosts = usePersistedStore.getState().hosts + const localHost = hosts.find((h) => h.id === LOCAL_HOST_ID) + if (localHost) { + usePersistedStore.setState({ currentHost: localHost }) + } + // Re-initialize host store for local + await initHostStore(LOCAL_HOST_ID) + return + } } + console.log('[checkHostReachability] host is reachable') +} + +async function validateInstance() { + console.log('[validateInstance] validating license') + const licenseKey = usePersistedStore.getState().licenseKey if (!licenseKey) { + console.log('[validateInstance] no license key, skipping license validation') usePersistedStore.setState({ licenseValid: false }) return } + if (!navigator.onLine) { + console.log('[validateInstance] not online, skipping license validation') + return + } + try { await validateLicense(licenseKey) } catch (e) { + console.log('[validateInstance] error validating license, marking as invalid') usePersistedStore.setState({ licenseValid: false }) if (e instanceof Error) { @@ -110,6 +186,7 @@ async function validateInstance() { kind: 'error', okLabel: 'OK', }) + console.log('[validateInstance] error message displayed, returning') return } @@ -118,6 +195,9 @@ async function validateInstance() { kind: 'error', okLabel: 'OK', }) + console.log('[validateInstance] default error message displayed, returning') + } finally { + console.log('[validateInstance] license validation complete') } } @@ -148,7 +228,7 @@ async function checkAlreadyRunning() { console.log('[checkAlreadyRunning] windows, showing message') await message( - "If you're on Windows, you might notice a few powershell/terminal windows opening and closing.\n\nThis is normal and expected, imagine we are playing whack-a-mole with the rclone process to close it.", + "If you're on Windows, you might notice a few powershell/terminal dialogs open and close.\n\nThis is normal and expected, imagine we are playing whack-a-mole with the rclone process to close it.", { 'title': 'Trigger Warning', 'kind': 'info', @@ -158,7 +238,7 @@ async function checkAlreadyRunning() { } const result = await invoke('stop_rclone_processes') console.log('[checkAlreadyRunning] stop_rclone_processes', result) - await new Promise((resolve) => setTimeout(resolve, 1000)) + await new Promise((resolve) => setTimeout(resolve, 700)) } else { console.log('[checkAlreadyRunning] exiting') await exit(0) @@ -170,27 +250,137 @@ async function checkAlreadyRunning() { } } +async function registerRcloneWindowListeners() { + if (rcloneListenersRegistered) { + return + } + + const window = getCurrentWindow() + + await window.listen('close-app', async () => { + console.log('[registerRcloneWindowListeners] close-app requested') + + const transfers = await queryClient.ensureQueryData({ + queryKey: ['transfers', 'list', 'all'], + queryFn: async () => await listTransfers(), + staleTime: 10_000, // 10 seconds + gcTime: 60_000, // 1 minute + }) + + if (transfers?.active && transfers.active.length > 0) { + const answer = await ask('All active transfers will be stopped.', { + title: 'Exit', + kind: 'info', + okLabel: 'Quit', + cancelLabel: 'Cancel', + }) + + if (!answer) { + return + } + } + + const child = currentRcloneChild + + if (child) { + try { + await child.kill() + } catch (error) { + console.error('[close-app] failed to kill rclone child', error) + Sentry.captureException(error) + } + currentRcloneChild = null + await new Promise((resolve) => setTimeout(resolve, 1000)) + } + + await exit(0) + }) + console.log('[registerRcloneWindowListeners] close-app listener registered') + + await window.listen('relaunch-app', async () => { + console.log('[registerRcloneWindowListeners] relaunch-app requested') + + const transfers = await queryClient.ensureQueryData({ + queryKey: ['transfers', 'list', 'all'], + queryFn: async () => await listTransfers(), + staleTime: 10_000, // 10 seconds + gcTime: 60_000, // 1 minute + }) + + if (transfers?.active && transfers.active.length > 0) { + const answer = await ask('All active transfers will be stopped.', { + title: 'Exit', + kind: 'info', + okLabel: 'Relaunch', + cancelLabel: 'Cancel', + }) + if (!answer) { + return + } + } + + const child = currentRcloneChild + + if (child) { + try { + await child.kill() + } catch (error) { + console.error('[relaunch-app] failed to kill rclone child', error) + Sentry.captureException(error) + } + currentRcloneChild = null + await new Promise((resolve) => setTimeout(resolve, 1000)) + } + + await relaunch() + }) + console.log('[registerRcloneWindowListeners] relaunch-app listener registered') + + await window.listen('restart-rclone', async () => { + console.log('[registerRcloneWindowListeners] restart-rclone requested') + + if (useStore.getState().isRestartingRclone) { + console.log('[restart-rclone] restart already in progress, ignoring request') + return + } + + useStore.setState({ isRestartingRclone: true }) + + try { + const child = currentRcloneChild + + if (child) { + try { + await child.kill() + } catch (error) { + console.error('[restart-rclone] failed to exit rclone process', error) + Sentry.captureException(error) + } + currentRcloneChild = null + await new Promise((resolve) => setTimeout(resolve, 1000)) + } + + await startRclone() + } catch (error) { + console.error('[restart-rclone] failed to restart rclone', error) + Sentry.captureException(error) + } finally { + useStore.setState({ isRestartingRclone: false }) + } + }) + console.log('[registerRcloneWindowListeners] restart-rclone listener registered') + + rcloneListenersRegistered = true +} + async function startRclone() { console.log('[startRclone]') - try { - const remotes = await listRemotes() - console.log('[startRclone] rclone rcd already running') - useStore.setState({ rcloneLoaded: true }) - useStore.setState({ remotes: remotes }) - return - } catch (error) { - console.error('[startRclone] error', error) - Sentry.captureException(error) - } + await registerRcloneWindowListeners() let rclone: Awaited> | null = null try { - const sessionPassword = Math.random().toString(36).substring(2, 15) - useStore.setState({ rcloneAuth: sessionPassword }) - useStore.setState({ rcloneAuthHeader: 'Basic ' + btoa(`admin:${sessionPassword}`) }) - rclone = await initRclone([ 'rcd', // ...(platform() === 'macos' @@ -225,15 +415,9 @@ async function startRclone() { const command = rcloneCommandFn! - // command.stdout.on('data', (line) => { - // console.log('stdout ' + line) - // }) - // command.stderr.on('data', (line) => { - // console.log('stderr ' + line) - // }) - command.addListener('close', async (event) => { console.log('close', event) + currentRcloneChild = null if (platform() === 'windows') { return await exit(0) @@ -262,105 +446,49 @@ async function startRclone() { console.log('[startRclone] starting rclone') const childProcess = await command.spawn() + currentRcloneChild = childProcess console.log('[startRclone] running rclone') - getCurrentWindow().listen('close-app', async (e) => { - console.log('[startRclone] (main) window close-app requested') - - await showLoadingTray() - - const jobs = await listJobs().catch(() => ({ active: [] })) - - if (jobs.active.length > 0) { - const answer = await ask('All active jobs will be stopped.', { - title: 'Exit', - kind: 'info', - okLabel: 'Quit', - cancelLabel: 'Cancel', - }) - - if (!answer) { - await showDefaultTray() - return - } - } - - await childProcess.kill() - - await new Promise((resolve) => setTimeout(resolve, 1000)) - - await exit(0) - }) - console.log('[startRclone] set listener for close-app') - - getCurrentWindow().listen('relaunch-app', async (e) => { - console.log('[startRclone] (main) window relaunch-app requested') - await showLoadingTray() - - const jobs = await listJobs().catch(() => ({ active: [] })) - - if (jobs.active.length > 0) { - const answer = await ask('All active jobs will be stopped.', { - title: 'Exit', - kind: 'info', - okLabel: 'Relaunch', - cancelLabel: 'Cancel', - }) - if (!answer) { - await showDefaultTray() - return - } - } - - await childProcess.kill() - - await new Promise((resolve) => setTimeout(resolve, 1000)) - - await relaunch() - }) - console.log('[startRclone] set listener for relaunch-app') - - await new Promise((resolve) => setTimeout(resolve, 200)) - - useStore.setState({ rcloneLoaded: true }) - - console.log('[startRclone] listing remotes') - const remotes = await listRemotes() - console.log('[startRclone] got remotes') - - const remotesInfo = await Promise.all( - remotes.map(async (remote) => await getRemote(remote).catch(() => null)) - ) - const supportedRemotes = remotes.filter( - (_, index) => remotesInfo[index] && SUPPORTED_BACKENDS.includes(remotesInfo[index]!.type) - ) - - console.log(`[startRclone] skipped ${remotes.length - supportedRemotes.length} remotes`) - - useStore.setState({ remotes: supportedRemotes }) - - // console.log('childProcess', JSON.stringify(childProcess)) // prints `pid` + await new Promise((resolve) => setTimeout(resolve, 500)) } async function startupMounts() { - const remoteConfigList = usePersistedStore.getState().remoteConfigList - const remotes = useStore.getState().remotes + const remoteConfigList = useHostStore.getState().remoteConfigs + + const remotes = await queryClient.ensureQueryData({ + queryKey: ['remotes', 'list', 'all'], + queryFn: async () => await rcloneClient('/config/listremotes').then((r) => r?.remotes), + staleTime: 1000 * 60, + }) for (const remote in remotes) { const remoteConfig = remoteConfigList[remote] if (!remoteConfig) continue - if (remoteConfig.mountOnStart && remoteConfig.defaultMountPoint) { + if (remoteConfig.mountOnStart?.enabled && remoteConfig.mountOnStart?.mountPoint) { try { - const isEmpty = await isDirectoryEmpty(remoteConfig.defaultMountPoint) + const isEmpty = await isDirectoryEmpty(remoteConfig.mountOnStart.mountPoint) if (!isEmpty) { continue } - await mountRemote({ - remotePath: `${remote}:${remoteConfig?.defaultRemotePath || ''}`, - mountPoint: remoteConfig.defaultMountPoint, - mountOptions: remoteConfig?.mountDefaults, - vfsOptions: remoteConfig?.vfsDefaults, + const { + mountPoint, + remotePath, + mountOptions, + vfsOptions, + filterOptions, + configOptions, + } = remoteConfig.mountOnStart + + await startMount({ + source: `${remote}:${remotePath}`, + destination: mountPoint, + options: { + mount: mountOptions, + vfs: vfsOptions, + filter: filterOptions, + config: configOptions, + }, }) } catch (error) { console.error('Error mounting remote:', error) @@ -376,46 +504,61 @@ async function startupMounts() { } async function showStartup() { + console.log('[showStartup] showing startup') const hideStartup = usePersistedStore.getState().hideStartup if (hideStartup) { + console.log('[showStartup] startup hidden, returning') return } const startupDisplayed = useStore.getState().startupDisplayed - if (!startupDisplayed) { - useStore.setState({ startupDisplayed: true, startupStatus: 'initialized' }) - await openSmallWindow({ - name: 'Startup', - url: '/startup', - }) - if (!['windows', 'macos'].includes(platform())) { - usePersistedStore.setState({ hideStartup: true }) - } + if (startupDisplayed) { + console.log('[showStartup] startup already displayed, returning') + return } + + console.log('[showStartup] startup not displayed, setting displayed and status') + useStore.setState({ startupDisplayed: true, startupStatus: 'initialized' }) + console.log('[showStartup] store updated with startup displayed and status set') + await openSmallWindow({ + name: 'Startup', + url: '/startup', + }) + console.log('[showStartup] startup window opened') + if (!['windows', 'macos'].includes(platform())) { + usePersistedStore.setState({ hideStartup: true }) + } + console.log('[showStartup] startup hidden') } const MAX_INT_MS = 2_147_483_647 let hasScheduledTasks = false async function resumeTasks() { - console.log('resuming tasks') + console.log('[resumeTasks] resuming tasks') if (hasScheduledTasks) { + console.log('[resumeTasks] tasks already resumed') return } - const scheduledTasks = usePersistedStore.getState().scheduledTasks - const activeConfigId = usePersistedStore.getState().activeConfigFile?.id + const scheduledTasks = useHostStore.getState().scheduledTasks + const activeConfigId = useHostStore.getState().activeConfigFile?.id if (!activeConfigId) { + console.log('[resumeTasks] no active config id') return } + hasScheduledTasks = true + + console.log('[resumeTasks] resuming tasks for config', activeConfigId) + for (const task of scheduledTasks) { if (task.isRunning) { - usePersistedStore.getState().updateScheduledTask(task.id, { + useHostStore.getState().updateScheduledTask(task.id, { isRunning: false, currentRunId: undefined, - error: 'Task closed prematurely', + lastRunError: 'Task closed prematurely', }) continue } @@ -430,15 +573,15 @@ async function resumeTasks() { const difference = nextRun.getTime() - Date.now() if (difference <= MAX_INT_MS && difference > 0) { - setTimeout(() => { + setTimeout(async () => { console.log('running task', task) notify({ title: 'Task Started', - body: `Task ${task.type} (${task.id}) started`, + body: `Task ${task.operation} (${task.id}) started`, }) - handleTask(task) + await handleTask(task) }, difference) - console.log('scheduled task', task.type, task.id, nextRun) + console.log('scheduled task', task.operation, task.id, nextRun) } } catch (error) { console.error('Error scheduling task:', error) @@ -446,11 +589,11 @@ async function resumeTasks() { } } - hasScheduledTasks = true + console.log('[resumeTasks] tasks resumed') } async function handleTask(task: ScheduledTask) { - const currentTask = usePersistedStore.getState().scheduledTasks.find((t) => t.id === task.id) + const currentTask = useHostStore.getState().scheduledTasks.find((t) => t.id === task.id) if (!currentTask) { return @@ -462,15 +605,15 @@ async function handleTask(task: ScheduledTask) { const freshRunId = crypto.randomUUID() - usePersistedStore.getState().updateScheduledTask(task.id, { + useHostStore.getState().updateScheduledTask(task.id, { isRunning: true, currentRunId: freshRunId, lastRun: new Date().toISOString(), }) - console.log('running task', task.type, task.id) + console.log('running task', task.operation, task.id) - const currentRunId = usePersistedStore + const currentRunId = useHostStore .getState() .scheduledTasks.find((t) => t.id === task.id)?.currentRunId @@ -479,71 +622,69 @@ async function handleTask(task: ScheduledTask) { } try { - const { - srcFs, - dstFs, - _config, - _filter, - createEmptySrcDirs, - deleteEmptyDstDirs, - fs, - rmDirs, - remote, - } = task.args - - switch (task.type) { - case 'delete': - await startDelete({ - fs, - rmDirs, - _filter, - _config, - }) - break - case 'copy': + switch (task.operation) { + case 'copy': { + const { sources, options, destination } = task.args await startCopy({ - srcFs, - dstFs, - _config, - _filter, + sources, + destination, + options, }) break - case 'move': + } + case 'move': { + const { sources, options, destination } = task.args await startMove({ - srcFs, - dstFs, - createEmptySrcDirs, - deleteEmptyDstDirs, - _config, - _filter, + sources, + destination, + options, }) break - case 'sync': + } + case 'sync': { + const { source, destination, options } = task.args await startSync({ - srcFs, - dstFs, - _config, - _filter, + source, + destination, + options, }) break - case 'purge': + } + case 'bisync': { + const { source, destination, options } = task.args + await startBisync({ + source, + destination, + options, + }) + break + } + case 'delete': { + const { sources, options } = task.args + await startDelete({ + sources, + options, + }) + break + } + case 'purge': { + const { sources, options } = task.args await startPurge({ - fs, - remote, - _filter, - _config, + sources, + options, }) break + } default: break } } catch (err) { Sentry.captureException(err) console.error('Failed to start task:', err) - usePersistedStore.getState().updateScheduledTask(task.id, { + useHostStore.getState().updateScheduledTask(task.id, { isRunning: false, currentRunId: undefined, - error: err instanceof Error ? err.message : 'Unknown error', + lastRunError: err instanceof Error ? err.message : 'Unknown error', }) } finally { } @@ -560,7 +701,10 @@ async function checkVersion() { ) console.log('[checkVersion] meta.json fetched') - const metaJsonData = await metaJson.json() + const metaJsonData = (await metaJson.json()) as { + minimumVersion: string + okVersion: string + } console.log('[checkVersion] meta.json parsed') const { minimumVersion, okVersion } = metaJsonData @@ -666,35 +810,78 @@ async function checkVersion() { } } -async function checkTraySupport() { - console.log('[checkTraySupport] platform', platform()) +async function checkRclone() { + let currentHost = usePersistedStore.getState().currentHost - let traySupported = false - - try { - traySupported = await invoke('is_tray_supported') - } catch (error) { - console.error('[checkTraySupport] error', error) - Sentry.captureException(error) - } - - if (!traySupported) { - console.log('[checkTraySupport] tray not supported') - - const confirmed = await ask( - 'Your desktop environment does not appear to have a tray/menubar.\n\nRclone UI requires a tray to run. Do you still wish to continue?', - { - title: 'Tray Not Supported', - kind: 'error', - okLabel: 'Continue', - cancelLabel: 'Exit Now', - } - ) - - if (!confirmed) { - await Promise.race([new Promise((resolve) => setTimeout(resolve, 100_000)), exit(0)]) + if (!currentHost) { + currentHost = { + id: 'local', + name: 'Local Machine', + url: 'http://localhost:5572', + os: 'linux', + cliVersion: 'unknown', } } + + let hostInfo = await getHostInfo({ + url: currentHost.url, + authUser: currentHost.authUser, + authPassword: currentHost.authPassword, + }) + + if (!hostInfo) { + await message( + 'Failed to get host info, is it online and reachable?\n\nSwitching to local host.', + { + title: 'Host Not Reachable', + kind: 'error', + } + ) + currentHost = { + id: 'local', + name: 'Local Machine', + url: 'http://localhost:5572', + os: 'linux', + cliVersion: 'unknown', + } + + hostInfo = await getHostInfo({ + url: currentHost.url, + authUser: currentHost.authUser, + authPassword: currentHost.authPassword, + }) + + if (!hostInfo) { + const confirmed = await ask( + 'Local host is not reachable, please try again or file an issue on Github if the problem persists.', + { + title: 'Local Host Not Reachable', + kind: 'error', + okLabel: 'Exit', + } + ) + + if (confirmed) { + return await exit(0) + } + + await new Promise((resolve) => setTimeout(resolve, 60_000)) + return await exit(0) + } + } + + currentHost = { + ...currentHost, + os: hostInfo.os, + cliVersion: hostInfo.cliVersion, + } + + console.log('[checkRclone] setting currentHost', currentHost) + + usePersistedStore.setState({ currentHost }) + usePersistedStore.setState((prev) => ({ + hosts: [...prev.hosts.filter((h) => h.id !== currentHost!.id), currentHost], + })) } getCurrentWindow().listen('tauri://close-requested', async (e) => { @@ -702,25 +889,52 @@ getCurrentWindow().listen('tauri://close-requested', async (e) => { await getCurrentWindow().destroy() }) -getCurrentWindow().listen('rebuild-tray', async (e) => { - console.log('(main) window rebuild-tray requested') +// maybe place this inside handleDeepLink? +onOpenUrl((urls) => { + console.log('deep links while running', urls) + const receivedUrl = urls[0] - // wait for store to be updated - await new Promise((resolve) => setTimeout(resolve, 250)) + const deepLinkUrl = getDeepLinkUrl(receivedUrl) - await showDefaultTray() + console.log('deep link url', deepLinkUrl) + + handleDeepLinkUrl(deepLinkUrl) + + useStore.setState({ startupDisplayed: true, startupStatus: 'initializing' }) }) -checkTraySupport() - .then(() => initTray()) - .then(() => showLoadingTray()) - .then(() => waitForHydration()) +async function handleDeepLink() { + console.log('[handleDeepLink] getting current deep links') + const urls = await getCurrent() + if (!urls || urls.length === 0) { + console.log('[handleDeepLink] no deep links found') + return + } + + console.log('[handleDeepLink] getting deep link url') + const deepLinkUrl = getDeepLinkUrl(urls[0]) + console.log('[handleDeepLink] deep link url', deepLinkUrl) + + console.log('[handleDeepLink] handling deep link url') + handleDeepLinkUrl(deepLinkUrl) + console.log('[handleDeepLink] deep link url handled') + + console.log('[handleDeepLink] setting startup displayed and status') + useStore.setState({ startupDisplayed: true, startupStatus: 'initializing' }) + console.log('[handleDeepLink] startup displayed and status set') +} + +waitForHydration() + .then(() => initializeHostStore()) + .then(() => checkHostReachability()) .then(() => checkVersion()) .then(() => validateInstance()) .then(() => checkAlreadyRunning()) .then(() => startRclone()) + .then(() => checkRclone()) + .then(() => handleDeepLink()) .then(() => showStartup()) .then(() => startupMounts()) .then(() => resumeTasks()) - .then(() => showDefaultTray()) + .then(() => initTray()) .catch(console.error) diff --git a/package-lock.json b/package-lock.json index b5c0272..d085e88 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,20 +1,24 @@ { "name": "rclone-ui", - "version": "2.8.1", + "version": "3.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rclone-ui", - "version": "2.8.1", + "version": "3.0.0", "dependencies": { "@formkit/auto-animate": "^0.9.0", "@heroui/react": "^2.7.11", "@mux/mux-player-react": "^3.8.0", "@sentry/browser": "^10.8.0", + "@tanstack/query-persist-client-core": "^5.91.6", + "@tanstack/query-sync-storage-persister": "^5.90.9", + "@tanstack/react-query": "^5.90.7", "@tauri-apps/api": "~2.9.0", "@tauri-apps/plugin-autostart": "^2.5.1", "@tauri-apps/plugin-clipboard-manager": "^2.3.2", + "@tauri-apps/plugin-deep-link": "^2.4.5", "@tauri-apps/plugin-dialog": "^2.4.2", "@tauri-apps/plugin-fs": "^2.4.4", "@tauri-apps/plugin-http": "^2.5.4", @@ -27,14 +31,16 @@ "@tauri-apps/plugin-store": "^2.4.1", "@tauri-apps/plugin-updater": "^2.9.0", "@tauri-apps/plugin-window-state": "^2.4.1", + "canvas-confetti": "^1.9.4", "cron-parser": "^5.4.0", "cronstrue": "^3.9.0", "date-fns": "^4.1.0", "framer-motion": "^11.18.2", + "fuse.js": "^7.1.0", "lucide-react": "^0.552.0", "p-retry": "^7.1.0", + "rclone-sdk": "^1.72.0", "react": "^18.3.1", - "react-compiler-runtime": "^19.1.0-rc.3", "react-dom": "^18.3.1", "react-router-dom": "^6.28.1", "tauri-plugin-sentry-api": "^0.5.0", @@ -46,6 +52,8 @@ "@biomejs/biome": "^1.9.4", "@iarna/toml": "^2.2.5", "@tauri-apps/cli": "^2.9.2", + "@total-typescript/ts-reset": "^0.6.1", + "@types/canvas-confetti": "^1.9.0", "@types/node": "^22.10.7", "@types/react": "^18.3.1", "@types/react-dom": "^18.3.1", @@ -89,7 +97,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", @@ -241,7 +249,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -4886,6 +4894,69 @@ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, + "node_modules/@redocly/ajv": { + "version": "8.11.4", + "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.4.tgz", + "integrity": "sha512-77MhyFgZ1zGMwtCpqsk532SJEc3IJmSOXKTCeWoMTAvPnQOkuOgxEip1n5pG5YX1IzCTJ4kCvPKr8xYyzWFdhg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js-replace": "^1.0.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@redocly/config": { + "version": "0.22.2", + "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.2.tgz", + "integrity": "sha512-roRDai8/zr2S9YfmzUfNhKjOF0NdcOIqF7bhf4MVC5UxpjIysDjyudvlAiVbpPHp3eDRWbdzUgtkK1a7YiDNyQ==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/@redocly/openapi-core": { + "version": "1.34.5", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.5.tgz", + "integrity": "sha512-0EbE8LRbkogtcCXU7liAyC00n9uNG9hJ+eMyHFdUsy9lB/WGqnEBgwjA9q2cyzAVcdTkQqTBBU1XePNnN3OijA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@redocly/ajv": "^8.11.2", + "@redocly/config": "^0.22.0", + "colorette": "^1.2.0", + "https-proxy-agent": "^7.0.5", + "js-levenshtein": "^1.1.6", + "js-yaml": "^4.1.0", + "minimatch": "^5.0.1", + "pluralize": "^8.0.0", + "yaml-ast-parser": "0.0.43" + }, + "engines": { + "node": ">=18.17.0", + "npm": ">=9.5.0" + } + }, + "node_modules/@redocly/openapi-core/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@remix-run/router": { "version": "1.21.0", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.21.0.tgz", @@ -5265,6 +5336,59 @@ "tslib": "^2.8.0" } }, + "node_modules/@tanstack/query-core": { + "version": "5.90.7", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.7.tgz", + "integrity": "sha512-6PN65csiuTNfBMXqQUxQhCNdtm1rV+9kC9YwWAIKcaxAauq3Wu7p18j3gQY3YIBJU70jT/wzCCZ2uqto/vQgiQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-persist-client-core": { + "version": "5.91.6", + "resolved": "https://registry.npmjs.org/@tanstack/query-persist-client-core/-/query-persist-client-core-5.91.6.tgz", + "integrity": "sha512-VDnWKhXn4YD8fosh35oSEkwqvsO9vveOyswp4bL/L8SZuQukPFRjyNIq4xGHO/olQneudLt57MdA4mYLAp5P5g==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-sync-storage-persister": { + "version": "5.90.9", + "resolved": "https://registry.npmjs.org/@tanstack/query-sync-storage-persister/-/query-sync-storage-persister-5.90.9.tgz", + "integrity": "sha512-8x/1w8G2mWAI5gpnJqOM5vPxAsx3SXFrk1bsStr8wpaXFtAJWk+K5dMwmYB5ee7pVkeUvBhNo4+UPOMvG/GgwQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.7", + "@tanstack/query-persist-client-core": "5.91.6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.7", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.7.tgz", + "integrity": "sha512-wAHc/cgKzW7LZNFloThyHnV/AX9gTg3w5yAv0gvQHPZoCnepwqCMtzbuPbb2UvfvO32XZ46e8bPOYbfZhzVnnQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@tanstack/react-virtual": { "version": "3.11.3", "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.11.3.tgz", @@ -5537,6 +5661,15 @@ "@tauri-apps/api": "^2.8.0" } }, + "node_modules/@tauri-apps/plugin-deep-link": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-deep-link/-/plugin-deep-link-2.4.5.tgz", + "integrity": "sha512-Zf2RTj1D9IQQ45/jqW8XTKvql24HqlPjcpv0mV/O2jHQkNe11HOTZBVj6IK37qs+MWV7xZzcmazx/QVZnhAwaQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, "node_modules/@tauri-apps/plugin-dialog": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.4.2.tgz", @@ -5645,6 +5778,13 @@ "@tauri-apps/api": "^2.8.0" } }, + "node_modules/@total-typescript/ts-reset": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@total-typescript/ts-reset/-/ts-reset-0.6.1.tgz", + "integrity": "sha512-cka47fVSo6lfQDIATYqb/vO1nvFfbPw7uWLayIXIhGETj0wcOOlrlkobOMDNQOFr9QOafegUPq13V2+6vtD7yg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -5686,6 +5826,13 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/canvas-confetti": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@types/canvas-confetti/-/canvas-confetti-1.9.0.tgz", + "integrity": "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -5762,6 +5909,28 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=6" + } + }, "node_modules/ansi-regex": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", @@ -5806,6 +5975,14 @@ "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==" }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0", + "optional": true, + "peer": true + }, "node_modules/autoprefixer": { "version": "10.4.21", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", @@ -5959,6 +6136,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvas-confetti": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/canvas-confetti/-/canvas-confetti-1.9.4.tgz", + "integrity": "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==", + "license": "ISC", + "funding": { + "type": "donate", + "url": "https://www.paypal.me/kirilvatev" + } + }, "node_modules/castable-video": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/castable-video/-/castable-video-1.1.11.tgz", @@ -5977,6 +6164,14 @@ "react": ">=17.0.0" } }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -6065,6 +6260,14 @@ "integrity": "sha512-zW190nQTIoXcGCaU08DvVNFTmQhUpnJfVuAKfWqUQkflXKpaDdpaYoM0iluLS9lgJNHyBF58KKA2FBEwkD7wog==", "license": "MIT" }, + "node_modules/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -6157,7 +6360,7 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -6186,6 +6389,16 @@ "node": ">=0.10.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -6265,6 +6478,14 @@ "node": ">=6" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/fast-glob": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", @@ -6395,6 +6616,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fuse.js": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.1.0.tgz", + "integrity": "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=10" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -6452,6 +6682,35 @@ "integrity": "sha512-CSpT2aXsv71HST8C5ETeVo+6YybqCpHBiYrCRQSn3U5QUZuLTSsvtq/bj+zuvjLVADeKxoebzo16OkH8m1+65Q==", "license": "Apache-2.0" }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/input-otp": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.1.tgz", @@ -6578,11 +6837,36 @@ "jiti": "bin/jiti.js" } }, + "node_modules/js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -6596,6 +6880,14 @@ "node": ">=6" } }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -6740,7 +7032,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/mux-embed": { @@ -6817,6 +7109,34 @@ "node": ">= 6" } }, + "node_modules/openapi-fetch": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.15.0.tgz", + "integrity": "sha512-OjQUdi61WO4HYhr9+byCPMj0+bgste/LtSBEcV6FzDdONTs7x0fWn8/ndoYwzqCsKWIxEZwo4FN/TG1c1rI8IQ==", + "license": "MIT", + "dependencies": { + "openapi-typescript-helpers": "^0.0.15" + } + }, + "node_modules/openapi-react-query": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/openapi-react-query/-/openapi-react-query-0.5.1.tgz", + "integrity": "sha512-BzUDxICV9v8584pBiGM9jgCqSBBp/mP91SJ68Pu7PqeppqfWxazi3vVaMup5Acjxv3cN9GbV6xuVHfbGsJfexw==", + "license": "MIT", + "dependencies": { + "openapi-typescript-helpers": "^0.0.15" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.80.0", + "openapi-fetch": "^0.15.0" + } + }, + "node_modules/openapi-typescript-helpers": { + "version": "0.0.15", + "resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.0.15.tgz", + "integrity": "sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==", + "license": "MIT" + }, "node_modules/p-retry": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-7.1.0.tgz", @@ -6837,6 +7157,25 @@ "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==" }, + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -6927,6 +7266,17 @@ "ce-la-react": "^0.3.0" } }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=4" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -7094,6 +7444,75 @@ } ] }, + "node_modules/rclone-openapi": { + "version": "1.0.23", + "resolved": "https://registry.npmjs.org/rclone-openapi/-/rclone-openapi-1.0.23.tgz", + "integrity": "sha512-lO3ONGdjigjcuxCC2uGht7gmypQmBcgRiuZORDqTAuPwZEupQvGSyQ6Vt25z3LaeeN9ww2i62ajFnxP04NIE2w==", + "license": "MIT" + }, + "node_modules/rclone-sdk": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/rclone-sdk/-/rclone-sdk-1.72.0.tgz", + "integrity": "sha512-IQOzLJJHwn+mSoB8jzxLR3xmro13+A+DlNrdvPhXut1nsumxixC2Bm9povX7g6ig5TVfvLQNNNYVLiZ+22Veuw==", + "license": "MIT", + "dependencies": { + "openapi-fetch": "^0.15.0", + "openapi-react-query": "^0.5.1", + "rclone-openapi": "^1.0.23", + "swr-openapi": "^5.4.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/rclone-sdk/node_modules/openapi-typescript": { + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.10.0.tgz", + "integrity": "sha512-vc+vAFU8plDiz7XYI7h/aBwW5NI+onJ2ydUd+Y51+h8O6vcMEp1+3MHIUB5cGclp08iHpCIotljhk51Nf+7+Yg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@redocly/openapi-core": "^1.34.5", + "ansi-colors": "^4.1.3", + "change-case": "^5.4.4", + "parse-json": "^8.3.0", + "supports-color": "^10.2.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "openapi-typescript": "bin/cli.js" + }, + "peerDependencies": { + "typescript": "^5.x" + } + }, + "node_modules/rclone-sdk/node_modules/swr-openapi": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/swr-openapi/-/swr-openapi-5.4.2.tgz", + "integrity": "sha512-MciLRP1EK/SrC8BYEGkTc6B61imZwS7OfDG+8oFwtQ0jkwH+X+HXGzs6Q7xe/z8YebZ4WK8U7kwBjg7WKU1G5A==", + "license": "MIT", + "dependencies": { + "openapi-typescript-helpers": "^0.0.15", + "type-fest": "^4.41.0" + }, + "funding": { + "type": "buymeacoffee", + "url": "https://buymeacoffee.com/htunnicliff" + }, + "peerDependencies": { + "openapi-fetch": "0.15.0", + "openapi-typescript": "7.10.0", + "react": "18 || 19", + "swr": "2", + "typescript": "^5.x" + }, + "peerDependenciesMeta": { + "openapi-typescript": { + "optional": true + } + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -7106,15 +7525,6 @@ "node": ">=0.10.0" } }, - "node_modules/react-compiler-runtime": { - "version": "19.1.0-rc.3", - "resolved": "https://registry.npmjs.org/react-compiler-runtime/-/react-compiler-runtime-19.1.0-rc.3.tgz", - "integrity": "sha512-Cssogys2XZu6SqxRdX2xd8cQAf57BBvFbLEBlIa77161lninbKUn/EqbecCe7W3eqDQfg3rIoOwzExzgCh7h/g==", - "license": "MIT", - "peerDependencies": { - "react": "^17.0.0 || ^18.0.0 || ^19.0.0 || ^0.0.0-experimental" - } - }, "node_modules/react-dom": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", @@ -7209,6 +7619,17 @@ "node": ">=8.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -7505,6 +7926,20 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -7516,6 +7951,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swr": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.3.6.tgz", + "integrity": "sha512-wfHRmHWk/isGNMwlLGlZX5Gzz/uTgo0o2IRuTMcf4CPuPFJZlq0rDaKUx+ozB5nBOReNV1kiOyzMfj+MBMikLw==", + "license": "MIT", + "peer": true, + "dependencies": { + "dequal": "^2.0.3", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/tailwind-merge": { "version": "2.5.4", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.5.4.tgz", @@ -7716,11 +8165,22 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -7767,6 +8227,14 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js-replace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", + "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==", + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/use-broadcast-ts": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/use-broadcast-ts/-/use-broadcast-ts-2.0.1.tgz", @@ -8068,6 +8536,25 @@ "node": ">= 14.6" } }, + "node_modules/yaml-ast-parser": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", + "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", + "license": "Apache-2.0", + "optional": true, + "peer": true + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "optional": true, + "peer": true, + "engines": { + "node": ">=12" + } + }, "node_modules/zustand": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz", diff --git a/package.json b/package.json index 15f122c..cb41d14 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,19 @@ { "name": "rclone-ui", - "private": true, - "version": "2.8.1", + "version": "3.0.0", + "description": "The GUI for rclone & s3", + "author": { + "name": "Rclone UI", + "url": "https://github.com/rclone-ui/rclone-ui" + }, "type": "module", + "repository": { + "type": "git", + "url": "https://github.com/rclone-ui/rclone-ui" + }, + "homepage": "https://rcloneui.com", + "keywords": ["rclone", "gui", "drive", "s3", "cloud", "storage", "sync", "backup"], + "license": "Apache-2.0", "scripts": { "dev": "node scripts/buildExternal.js && vite", "build": "node scripts/buildExternal.js && tsc && vite build", @@ -17,16 +28,21 @@ "build:windows:arm": "tauri build --runner cargo-xwin --target aarch64-pc-windows-msvc", "build:linux:arm": "tauri build --target aarch64-unknown-linux-gnu", "build:wasm": "tauri build --target wasm32-unknown-unknown", - "sources:cargo": "node scripts/flatpak-cargo-generator.js" + "sources:cargo": "node scripts/flatpak-cargo-generator.js", + "pub": "npm publish ./cli" }, "dependencies": { "@formkit/auto-animate": "^0.9.0", "@heroui/react": "^2.7.11", "@mux/mux-player-react": "^3.8.0", "@sentry/browser": "^10.8.0", + "@tanstack/query-persist-client-core": "^5.91.6", + "@tanstack/query-sync-storage-persister": "^5.90.9", + "@tanstack/react-query": "^5.90.7", "@tauri-apps/api": "~2.9.0", "@tauri-apps/plugin-autostart": "^2.5.1", "@tauri-apps/plugin-clipboard-manager": "^2.3.2", + "@tauri-apps/plugin-deep-link": "^2.4.5", "@tauri-apps/plugin-dialog": "^2.4.2", "@tauri-apps/plugin-fs": "^2.4.4", "@tauri-apps/plugin-http": "^2.5.4", @@ -39,14 +55,16 @@ "@tauri-apps/plugin-store": "^2.4.1", "@tauri-apps/plugin-updater": "^2.9.0", "@tauri-apps/plugin-window-state": "^2.4.1", + "canvas-confetti": "^1.9.4", "cron-parser": "^5.4.0", "cronstrue": "^3.9.0", "date-fns": "^4.1.0", "framer-motion": "^11.18.2", + "fuse.js": "^7.1.0", "lucide-react": "^0.552.0", "p-retry": "^7.1.0", + "rclone-sdk": "^1.72.0", "react": "^18.3.1", - "react-compiler-runtime": "^19.1.0-rc.3", "react-dom": "^18.3.1", "react-router-dom": "^6.28.1", "tauri-plugin-sentry-api": "^0.5.0", @@ -58,6 +76,8 @@ "@biomejs/biome": "^1.9.4", "@iarna/toml": "^2.2.5", "@tauri-apps/cli": "^2.9.2", + "@total-typescript/ts-reset": "^0.6.1", + "@types/canvas-confetti": "^1.9.0", "@types/node": "^22.10.7", "@types/react": "^18.3.1", "@types/react-dom": "^18.3.1", diff --git a/public/browse.html b/public/browse.html index d6106af..2e4211b 100644 --- a/public/browse.html +++ b/public/browse.html @@ -295,7 +295,7 @@ It's not pretty, but at the same time Adobe's .PSD implementation exists. } } - async function fetchAndInjectContent(url, pass, isInitialLoad = false) { + async function fetchAndInjectContent(url, auth, isInitialLoad = false) { try { // Only show navigation spinner for non-initial loads if (!isInitialLoad) { @@ -303,17 +303,22 @@ It's not pretty, but at the same time Adobe's .PSD implementation exists. } let newUrl = url - if (pass) { - newUrl = url.replace('localhost:5572', 'admin:' + pass + '@localhost:5572') - } else { - console.log('no pass') - } - + console.log('newUrl', newUrl) const fetch = window.__TAURI__.http.fetch // Fetch the webpage content - const response = await fetch(newUrl); + const options = { + method: 'GET', + }; + + if (auth) { + options.headers = { + 'Authorization': 'Basic ' + auth + }; + } + + const response = await fetch(newUrl, options); const html = await response.text(); console.log('fetched html for', newUrl) @@ -373,7 +378,11 @@ It's not pretty, but at the same time Adobe's .PSD implementation exists. document.body.appendChild(navSpinner); // Update the URL in the address bar without reloading - window.history.pushState({}, '', `?url=${encodeURIComponent(url)}` + (pass ? `&pass=${encodeURIComponent(pass)}` : '')); + const params = new URLSearchParams(); + params.set('url', url); + if (auth) params.set('auth', auth); + + window.history.pushState({}, '', `?${params.toString()}`); // Re-add our event handlers setupEventHandlers(); @@ -413,7 +422,10 @@ It's not pretty, but at the same time Adobe's .PSD implementation exists. currentUrl = currentUrl.split('/'); currentUrl.pop(); const fullUrl = currentUrl.join('/') + '/'; - fetchAndInjectContent(fullUrl); + + const urlParams = new URLSearchParams(window.location.search); + const auth = urlParams.get('auth'); + fetchAndInjectContent(fullUrl, auth); return; } @@ -457,11 +469,22 @@ It's not pretty, but at the same time Adobe's .PSD implementation exists. toggleNavSpinner(true); console.log('downloading', downloadUrl); - // Fetch the file contents - const response = await http.fetch(downloadUrl, { + + const options = { method: 'GET', responseType: 2 // ResponseType.Binary - }); + }; + + const urlParams = new URLSearchParams(window.location.search); + const auth = urlParams.get('auth'); + if (auth) { + options.headers = { + 'Authorization': 'Basic ' + auth + }; + } + + // Fetch the file contents + const response = await http.fetch(downloadUrl, options); const data = await response.arrayBuffer(); @@ -531,7 +554,9 @@ It's not pretty, but at the same time Adobe's .PSD implementation exists. event.preventDefault(); // Fetch and inject the new content - fetchAndInjectContent(fullUrl); + const urlParams = new URLSearchParams(window.location.search); + const auth = urlParams.get('auth'); + fetchAndInjectContent(fullUrl, auth); } function isInViewport(element) { @@ -557,10 +582,10 @@ It's not pretty, but at the same time Adobe's .PSD implementation exists. // Initial setup const urlParams = new URLSearchParams(window.location.search); const url = urlParams.get('url'); - const pass = urlParams.get('pass'); + const auth = urlParams.get('auth'); if (url) { // Pass true to indicate this is the initial load - fetchAndInjectContent(url, pass, true); + fetchAndInjectContent(url, auth, true); } else { document.body.innerHTML = '
Error: No URL provided.
'; } @@ -570,9 +595,11 @@ It's not pretty, but at the same time Adobe's .PSD implementation exists. // Handle browser back/forward buttons window.addEventListener('popstate', () => { - const newUrl = new URLSearchParams(window.location.search).get('url'); + const params = new URLSearchParams(window.location.search); + const newUrl = params.get('url'); + const auth = params.get('auth'); if (newUrl) { - fetchAndInjectContent(newUrl, pass); + fetchAndInjectContent(newUrl, auth); } }); } diff --git a/public/icon.png b/public/icon.png new file mode 100644 index 0000000..5bed538 Binary files /dev/null and b/public/icon.png differ diff --git a/public/icons/backends/archive.png b/public/icons/backends/archive.png new file mode 100644 index 0000000..fec7410 Binary files /dev/null and b/public/icons/backends/archive.png differ diff --git a/public/icons/backends/fichier.png b/public/icons/backends/fichier.png new file mode 100644 index 0000000..406132a Binary files /dev/null and b/public/icons/backends/fichier.png differ diff --git a/public/icons/backends/iclouddrive.png b/public/icons/backends/iclouddrive.png new file mode 100644 index 0000000..69d5314 Binary files /dev/null and b/public/icons/backends/iclouddrive.png differ diff --git a/public/icons/platforms/linux.png b/public/icons/platforms/linux.png new file mode 100644 index 0000000..97de7a9 Binary files /dev/null and b/public/icons/platforms/linux.png differ diff --git a/public/icons/platforms/macos.png b/public/icons/platforms/macos.png new file mode 100644 index 0000000..0b0f31f Binary files /dev/null and b/public/icons/platforms/macos.png differ diff --git a/public/icons/platforms/windows.png b/public/icons/platforms/windows.png new file mode 100644 index 0000000..1f7dbdf Binary files /dev/null and b/public/icons/platforms/windows.png differ diff --git a/public/onboarding/github-light.png b/public/onboarding/github-light.png new file mode 100644 index 0000000..febbd11 Binary files /dev/null and b/public/onboarding/github-light.png differ diff --git a/public/onboarding/github.png b/public/onboarding/github.png new file mode 100644 index 0000000..febbd11 Binary files /dev/null and b/public/onboarding/github.png differ diff --git a/public/onboarding/mobile.png b/public/onboarding/mobile.png new file mode 100644 index 0000000..c1d7640 Binary files /dev/null and b/public/onboarding/mobile.png differ diff --git a/public/onboarding/remote-light.png b/public/onboarding/remote-light.png new file mode 100644 index 0000000..6564c5d Binary files /dev/null and b/public/onboarding/remote-light.png differ diff --git a/public/onboarding/remote.png b/public/onboarding/remote.png new file mode 100644 index 0000000..06e603a Binary files /dev/null and b/public/onboarding/remote.png differ diff --git a/public/onboarding/schedules-light.png b/public/onboarding/schedules-light.png new file mode 100644 index 0000000..ca84d7b Binary files /dev/null and b/public/onboarding/schedules-light.png differ diff --git a/public/onboarding/schedules.png b/public/onboarding/schedules.png new file mode 100644 index 0000000..8f156e1 Binary files /dev/null and b/public/onboarding/schedules.png differ diff --git a/public/onboarding/templates-light.png b/public/onboarding/templates-light.png new file mode 100644 index 0000000..a3ef90e Binary files /dev/null and b/public/onboarding/templates-light.png differ diff --git a/public/onboarding/templates.png b/public/onboarding/templates.png new file mode 100644 index 0000000..b9d8770 Binary files /dev/null and b/public/onboarding/templates.png differ diff --git a/reset.d.ts b/reset.d.ts new file mode 100644 index 0000000..69fc8bf --- /dev/null +++ b/reset.d.ts @@ -0,0 +1 @@ +import '@total-typescript/ts-reset' diff --git a/scripts/buildExternal.js b/scripts/buildExternal.js index b92cfb0..1b8f1df 100644 --- a/scripts/buildExternal.js +++ b/scripts/buildExternal.js @@ -47,6 +47,14 @@ async function buildExternal() { }) await Promise.all(jsFiles.map((file) => unlink(file).catch(() => {}))) })(), + // Remove store/*.js files - preserve .ts files + (async () => { + const jsFiles = await glob('**/*.js', { + cwd: join(projectRoot, 'store'), + absolute: true, + }) + await Promise.all(jsFiles.map((file) => unlink(file).catch(() => {}))) + })(), // Remove main.js unlink(join(projectRoot, 'main.js')).catch(() => {}), // Ignore if file doesn't exist ]) diff --git a/scripts/rc-request.mjs b/scripts/rc-request.mjs new file mode 100644 index 0000000..3f07ca8 --- /dev/null +++ b/scripts/rc-request.mjs @@ -0,0 +1,204 @@ +#!/usr/bin/env node + +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import process from 'node:process' + +function printUsage() { + console.log(`Usage: node scripts/rc-request.mjs [options] + +Options: + -X, --method HTTP method to use (default: POST) + -b, --base Base URL (default: http://localhost:5572) + -q, --query Query parameter to append (repeatable) + -d, --data JSON payload string + --data-file Path to file containing JSON payload + -h, --help Show this help message + +Examples: + node scripts/rc-request.mjs /rc/noop + node scripts/rc-request.mjs -q _async=true /job/list + node scripts/rc-request.mjs -d '{"fs":"rct:","remote":""}' /operations/list`) +} + +function parseArgs(argv) { + const args = { + baseUrl: process.env.RCLONE_RC_BASE_URL || 'http://localhost:5572', + method: 'POST', + path: null, + query: [], + data: null, + } + + for (let i = 0; i < argv.length; i += 1) { + const value = argv[i] + switch (value) { + case '-h': + case '--help': { + printUsage() + process.exit(0) + } + case '-X': + case '--method': { + const next = argv[++i] + if (!next) { + throw new Error('Expected HTTP method after --method') + } + args.method = next.toUpperCase() + break + } + case '-b': + case '--base': { + const next = argv[++i] + if (!next) { + throw new Error('Expected URL after --base') + } + args.baseUrl = next + break + } + case '-q': + case '--query': { + const next = argv[++i] + if (!next) { + throw new Error('Expected key=value after --query') + } + args.query.push(next) + break + } + case '-d': + case '--data': { + const next = argv[++i] + if (next === undefined) { + throw new Error('Expected JSON string after --data') + } + args.data = next + break + } + case '--data-file': { + const next = argv[++i] + if (!next) { + throw new Error('Expected file path after --data-file') + } + args.data = readFileSync(resolve(next), 'utf8') + break + } + default: { + if (args.path) { + throw new Error(`Unexpected argument: ${value}`) + } + args.path = value + break + } + } + } + + if (!args.path) { + throw new Error('Missing path argument (e.g. /rc/noop)') + } + + return args +} + +function ensurePath(path) { + if (!path.startsWith('/')) { + return `/${path}` + } + return path +} + +function buildUrl(baseUrl, path, queryEntries) { + const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/` + const url = new URL(ensurePath(path), normalizedBase) + + for (const entry of queryEntries) { + const [key, ...valueParts] = entry.split('=') + if (!key) { + throw new Error(`Invalid query entry "${entry}", expected key=value`) + } + const value = valueParts.length > 0 ? valueParts.join('=') : '' + url.searchParams.append(key, value) + } + + return url +} + +function parseJsonMaybe(input) { + if (input === null || input === undefined) { + return null + } + const trimmed = input.trim() + if (!trimmed) { + return null + } + try { + return JSON.parse(trimmed) + } catch (error) { + throw new Error(`Failed to parse JSON payload: ${error?.message || error}`) + } +} + +async function main() { + let args + try { + args = parseArgs(process.argv.slice(2)) + } catch (error) { + console.error(`[rc-request] ${error.message}`) + printUsage() + process.exit(1) + } + + const url = buildUrl(args.baseUrl, args.path, args.query) + const payload = parseJsonMaybe(args.data) + const headers = new Headers() + + const requestInit = { + method: args.method, + headers, + } + + if (payload !== null) { + headers.set('Content-Type', 'application/json') + requestInit.body = JSON.stringify(payload) + } + + const startedAt = Date.now() + let response + try { + response = await fetch(url, requestInit) + } catch (error) { + console.error(`[rc-request] Request failed: ${error?.message || error}`) + process.exit(1) + } + const durationMs = Date.now() - startedAt + const text = await response.text() + + let parsedBody = null + if (text) { + try { + parsedBody = JSON.parse(text) + } catch { + parsedBody = text + } + } + + const result = { + url: url.toString(), + method: args.method, + status: response.status, + statusText: response.statusText, + durationMs, + headers: Object.fromEntries(response.headers.entries()), + body: parsedBody, + } + + console.log(JSON.stringify(result, null, 2)) + + if (!response.ok) { + process.exitCode = 1 + } +} + +main().catch((error) => { + console.error(`[rc-request] Unexpected error: ${error?.message || error}`) + process.exit(1) +}) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7e5812f..179fbb7 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -249,12 +249,13 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "app" -version = "2.8.1" +version = "3.0.0" dependencies = [ "cocoa", "fix-path-env", "log", "machine-uid", + "objc", "reqwest 0.11.27", "sentry", "serde", @@ -264,8 +265,10 @@ dependencies = [ "tauri-build", "tauri-plugin-autostart", "tauri-plugin-clipboard-manager", + "tauri-plugin-deep-link", "tauri-plugin-dialog", "tauri-plugin-fs", + "tauri-plugin-global-shortcut", "tauri-plugin-http", "tauri-plugin-log", "tauri-plugin-notification", @@ -278,6 +281,7 @@ dependencies = [ "tauri-plugin-single-instance", "tauri-plugin-store", "tauri-plugin-updater", + "tinyfiledialogs", "x11rb", "zbus", "zip 0.6.6", @@ -433,7 +437,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -468,7 +472,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -643,7 +647,7 @@ dependencies = [ "proc-macro-crate 3.4.0", "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -831,9 +835,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.44" +version = "1.2.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37521ac7aabe3d13122dc382493e20c9416f299d2ccd5b3a5340a2570cdeb0f3" +checksum = "35900b6c8d709fb1d854671ae27aeaa9eec2f8b01b364e1619a40da3e6fe2afe" dependencies = [ "find-msvc-tools", "jobserver", @@ -965,6 +969,26 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.16", + "once_cell", + "tiny-keccak", +] + [[package]] name = "constant_time_eq" version = "0.1.5" @@ -1153,7 +1177,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" dependencies = [ "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -1163,7 +1187,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" dependencies = [ "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -1187,7 +1211,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -1198,7 +1222,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -1245,7 +1269,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -1258,7 +1282,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -1278,7 +1302,7 @@ checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", "unicode-xid", ] @@ -1360,7 +1384,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -1392,7 +1416,16 @@ checksum = "788160fb30de9cdd857af31c6a2675904b16ece8fc2737b2c7127ba368c9d0f4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", +] + +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", ] [[package]] @@ -1505,7 +1538,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -1526,9 +1559,9 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "erased-serde" -version = "0.4.8" +version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "259d404d09818dec19332e31d94558aeb442fea04c817006456c24b5460bbd4b" +checksum = "89e8918065695684b2b0702da20382d5ae6065cf3327bc2d6436bd49a71ce9f3" dependencies = [ "serde", "serde_core", @@ -1595,7 +1628,7 @@ checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -1721,7 +1754,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -1815,7 +1848,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -2085,7 +2118,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -2104,6 +2137,24 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "global-hotkey" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9247516746aa8e53411a0db9b62b0e24efbcf6a76e0ba73e5a91b512ddabed7" +dependencies = [ + "crossbeam-channel", + "keyboard-types", + "objc2 0.6.3", + "objc2-app-kit", + "once_cell", + "serde", + "thiserror 2.0.17", + "windows-sys 0.59.0", + "x11rb", + "xkeysym", +] + [[package]] name = "gobject-sys" version = "0.18.0" @@ -2175,7 +2226,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -2236,6 +2287,12 @@ dependencies = [ "ahash", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.16.0" @@ -2704,9 +2761,9 @@ checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" [[package]] name = "iri-string" -version = "0.7.8" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" +checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" dependencies = [ "memchr", "serde", @@ -3074,7 +3131,7 @@ checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -3378,7 +3435,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -3409,7 +3466,7 @@ dependencies = [ "proc-macro-crate 3.4.0", "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -3745,9 +3802,9 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.74" +version = "0.10.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24ad14dd45412269e1a30f52ad8f0664f0f4f4a89ee8fe28c3b3527021ebb654" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" dependencies = [ "bitflags 2.10.0", "cfg-if", @@ -3766,7 +3823,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -3777,9 +3834,9 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" [[package]] name = "openssl-sys" -version = "0.9.110" +version = "0.9.111" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a9f0075ba3c21b09f8e8b2026584b1d18d49388648f2fbbf3c97ea8deced8e2" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" dependencies = [ "cc", "libc", @@ -3793,6 +3850,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -3967,7 +4034,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -4094,7 +4161,7 @@ dependencies = [ "phf_shared 0.11.3", "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -4446,9 +4513,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.41" +version = "1.0.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" dependencies = [ "proc-macro2", ] @@ -4638,7 +4705,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -4709,7 +4776,7 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", - "rustls-pemfile 1.0.4", + "rustls-pemfile", "serde", "serde_json", "serde_urlencoded", @@ -4843,6 +4910,16 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + [[package]] name = "rust_decimal" version = "1.39.0" @@ -4908,9 +4985,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.34" +version = "0.23.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a9586e9ee2b4f8fab52a0048ca7334d7024eef48e2cb9407e3497bb7cab7fa7" +checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" dependencies = [ "once_cell", "ring", @@ -4929,15 +5006,6 @@ dependencies = [ "base64 0.21.7", ] -[[package]] -name = "rustls-pemfile" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "rustls-pki-types" version = "1.13.0" @@ -5018,9 +5086,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.0.5" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1317c3bf3e7df961da95b0a56a172a02abead31276215a0497241a7624b487ce" +checksum = "9558e172d4e8533736ba97870c4b2cd63f84b382a3d6eb063da41b91cce17289" dependencies = [ "dyn-clone", "ref-cast", @@ -5037,7 +5105,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -5069,7 +5137,7 @@ checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -5300,7 +5368,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -5311,7 +5379,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -5335,7 +5403,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -5380,7 +5448,7 @@ dependencies = [ "indexmap 1.9.3", "indexmap 2.12.0", "schemars 0.9.0", - "schemars 1.0.5", + "schemars 1.1.0", "serde_core", "serde_json", "serde_with_macros", @@ -5396,7 +5464,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -5418,7 +5486,7 @@ checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -5544,7 +5612,7 @@ checksum = "0eb01866308440fc64d6c44d9e86c5cc17adfe33c4d6eed55da9145044d0ffc1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -5685,7 +5753,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -5718,9 +5786,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.108" +version = "2.0.110" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917" +checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea" dependencies = [ "proc-macro2", "quote", @@ -5750,7 +5818,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -5834,8 +5902,7 @@ dependencies = [ [[package]] name = "tao" version = "0.34.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7" +source = "git+https://github.com/tauri-apps/tao?branch=fix-windows-white-flash#cf28a8e8f205bf5cd7a41c4d7fc54ed9247c5f93" dependencies = [ "bitflags 2.10.0", "block2 0.6.2", @@ -5862,7 +5929,7 @@ dependencies = [ "parking_lot", "raw-window-handle", "scopeguard", - "tao-macros", + "tao-macros 0.1.3 (git+https://github.com/tauri-apps/tao?branch=fix-windows-white-flash)", "unicode-segmentation", "url", "windows", @@ -5879,7 +5946,17 @@ checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "git+https://github.com/tauri-apps/tao?branch=fix-windows-white-flash#cf28a8e8f205bf5cd7a41c4d7fc54ed9247c5f93" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", ] [[package]] @@ -5997,7 +6074,7 @@ dependencies = [ "serde", "serde_json", "sha2", - "syn 2.0.108", + "syn 2.0.110", "tauri-utils", "thiserror 2.0.17", "time", @@ -6015,7 +6092,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", "tauri-codegen", "tauri-utils", ] @@ -6066,6 +6143,27 @@ dependencies = [ "thiserror 2.0.17", ] +[[package]] +name = "tauri-plugin-deep-link" +version = "2.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e82759f7c7d51de3cbde51c04b3f2332de52436ed84541182cd8944b04e9e73" +dependencies = [ + "dunce", + "plist", + "rust-ini", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.17", + "tracing", + "url", + "windows-registry 0.5.3", + "windows-result 0.3.4", +] + [[package]] name = "tauri-plugin-dialog" version = "2.4.2" @@ -6106,6 +6204,21 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-global-shortcut" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "424af23c7e88d05e4a1a6fc2c7be077912f8c76bd7900fd50aa2b7cbf5a2c405" +dependencies = [ + "global-hotkey", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.17", +] + [[package]] name = "tauri-plugin-http" version = "2.5.4" @@ -6283,6 +6396,7 @@ dependencies = [ "serde", "serde_json", "tauri", + "tauri-plugin-deep-link", "thiserror 2.0.17", "tracing", "windows-sys 0.60.2", @@ -6500,7 +6614,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -6511,7 +6625,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -6561,6 +6675,25 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinyfiledialogs" +version = "3.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e25fa0bc43a6566e2cc6d7ac96df3fa5a57beba34445bead1b368ba8fe9ca568" +dependencies = [ + "cc", + "libc", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -6612,7 +6745,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -6809,7 +6942,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -6985,16 +7118,15 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "ureq" -version = "3.1.2" +version = "3.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99ba1025f18a4a3fc3e9b48c868e9beb4f24f4b4b1a325bada26bd4119f46537" +checksum = "d39cb1dbab692d82a977c0392ffac19e188bd9186a9f32806f0aaa859d75585a" dependencies = [ "base64 0.22.1", "der", "log", "native-tls", "percent-encoding", - "rustls-pemfile 2.2.0", "rustls-pki-types", "ureq-proto", "utf-8", @@ -7211,7 +7343,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", "wasm-bindgen-shared", ] @@ -7414,7 +7546,7 @@ checksum = "1d228f15bba3b9d56dde8bddbee66fa24545bd17b48d5128ccf4a8742b18e431" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -7430,9 +7562,9 @@ dependencies = [ [[package]] name = "weezl" -version = "0.1.10" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3" +checksum = "009936b22a61d342859b5f0ea64681cbb35a358ab548e2a44a8cf0dac2d980b8" [[package]] name = "win7-notifications" @@ -7557,7 +7689,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -7568,7 +7700,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -8077,7 +8209,7 @@ dependencies = [ "raw-window-handle", "sha2", "soup3", - "tao-macros", + "tao-macros 0.1.3 (registry+https://github.com/rust-lang/crates.io-index)", "thiserror 2.0.17", "url", "webkit2gtk", @@ -8146,6 +8278,12 @@ dependencies = [ "rustix 1.1.2", ] +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + [[package]] name = "yoke" version = "0.8.1" @@ -8165,7 +8303,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", "synstructure", ] @@ -8213,7 +8351,7 @@ dependencies = [ "proc-macro-crate 3.4.0", "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", "zbus_names", "zvariant", "zvariant_utils", @@ -8248,7 +8386,7 @@ checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -8268,7 +8406,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", "synstructure", ] @@ -8308,7 +8446,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", ] [[package]] @@ -8411,7 +8549,7 @@ dependencies = [ "proc-macro-crate 3.4.0", "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.110", "zvariant_utils", ] @@ -8424,6 +8562,6 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.108", + "syn 2.0.110", "winnow 0.7.13", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3749b6e..5bbf217 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "app" -version = "2.8.1" +version = "3.0.0" description = "A Tauri App" authors = ["you"] license = "" @@ -33,22 +33,29 @@ tauri-plugin-store = "2.4.1" tauri-plugin-process = "2.3.1" tauri-plugin-notification = { version = "2.3.3", features = [ "windows7-compat" ] } tauri-plugin-os = "2.3.2" +tauri-plugin-global-shortcut = "2.3.1" fix-path-env = { git = "https://github.com/tauri-apps/fix-path-env-rs" } zip = "0.6.6" machine-uid = "0.5.4" tauri-plugin-sentry = "0.5.0" sentry = "0.42" tauri-plugin-autostart = "2.5.1" -tauri-plugin-single-instance = "2.3.6" +tauri-plugin-single-instance = { version = "2.3.6", features = ["deep-link"] } tauri-plugin-updater = "2.9.0" tauri-plugin-clipboard-manager = "2.3.2" tauri-plugin-prevent-default = "3" reqwest = { version = "0.11", features = ["json"] } sysinfo = "0.37" +tinyfiledialogs = "3.9.1" +tauri-plugin-deep-link = "2" [target.'cfg(target_os = "macos")'.dependencies] cocoa = "0.26" +objc = "0.2" [target.'cfg(target_os = "linux")'.dependencies] zbus = { version = "5" } x11rb = "0.13" + +[patch.crates-io] +tao = { git = "https://github.com/tauri-apps/tao", branch = "fix-windows-white-flash" } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 51e7f18..07328b1 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -11,13 +11,16 @@ "Purge", "Delete", "Settings", - "Jobs", - "Cron", + "Transfers", + "Schedules", + "Templates", "Serve", "Bisync", "Download", "Browse", "Startup", + "Toolbar", + "Onboarding", "Test" ], "permissions": [ @@ -247,7 +250,6 @@ } ] }, - "opener:allow-open-url", { "identifier": "opener:allow-open-path", @@ -266,57 +268,21 @@ ] }, "opener:allow-default-urls", - "updater:allow-download", "updater:allow-download-and-install", "updater:allow-check", "updater:allow-install", - "autostart:allow-enable", "autostart:allow-disable", "autostart:allow-is-enabled", - { "identifier": "http:default", "allow": [ { - "url": "http://localhost:5572/**" + "url": "http://*:*" }, { - "url": "https://downloads.rclone.org/**" - }, - { - "url": "https://github.com/**" - }, - { - "url": "https://raw.githubusercontent.com/**" - }, - { - "url": "https://rcloneui.com/**" - }, - { - "url": "https://stripe.com/**" - }, - { - "url": "https://buy.stripe.com/**" - }, - { - "url": "https://checkout.stripe.com/**" - }, - { - "url": "https://js.stripe.com/**" - }, - { - "url": "https://donate.stripe.com/**" - }, - { - "url": "https://payments.stripe.com/**" - }, - { - "url": "https://checkout.link.com/**" - }, - { - "url": "https://**.sentry.io/**" + "url": "https://*:*" } ] }, @@ -326,6 +292,8 @@ "os:default", "sentry:allow-event", "sentry:allow-breadcrumb", - "sentry:allow-envelope" + "sentry:allow-envelope", + "deep-link:default", + "deep-link:allow-get-current" ] } diff --git a/src-tauri/common/shortcut.rs b/src-tauri/common/shortcut.rs new file mode 100644 index 0000000..7932704 --- /dev/null +++ b/src-tauri/common/shortcut.rs @@ -0,0 +1,184 @@ +use tauri::{AppHandle, Manager, PhysicalSize, Size, WebviewWindow, WebviewWindowBuilder}; +use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState}; + +use super::window::make_transparent; + +pub const DEFAULT_TOOLBAR_SHORTCUT: &str = "CmdOrCtrl+Shift+/"; +const TOOLBAR_WINDOW_LABEL: &str = "Toolbar"; + +// Fixed physical size for the toolbar window on Windows (in physical pixels) +// This avoids DPI scaling issues on multi-monitor setups with different scale factors +#[cfg(target_os = "windows")] +const TOOLBAR_PHYSICAL_WIDTH: u32 = 702; +#[cfg(target_os = "windows")] +const TOOLBAR_PHYSICAL_HEIGHT: u32 = 460; + +fn create_toolbar_window(app_handle: &AppHandle) -> Result { + let monitor = match app_handle.primary_monitor()? { + Some(monitor) => monitor, + None => app_handle + .available_monitors()? + .into_iter() + .next() + .ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::NotFound, "No monitors available") + })?, + }; + + let physical_size = monitor.size(); + let physical_position = monitor.position(); + + // On Windows, use physical pixels directly to avoid DPI scaling issues + // across multiple monitors with different scale factors + #[cfg(target_os = "windows")] + let builder = { + // Calculate position in physical pixels, centered on the primary monitor + let pos_x = physical_position.x + ((physical_size.width - TOOLBAR_PHYSICAL_WIDTH) / 2) as i32; + let pos_y = physical_position.y + (physical_size.height / 4) as i32; + + WebviewWindowBuilder::new( + app_handle, + TOOLBAR_WINDOW_LABEL, + tauri::WebviewUrl::App("/toolbar".into()), + ) + .title(TOOLBAR_WINDOW_LABEL) + // Use initial size (will be corrected to physical pixels after creation) + .inner_size(TOOLBAR_PHYSICAL_WIDTH as f64, TOOLBAR_PHYSICAL_HEIGHT as f64) + .position(pos_x as f64, pos_y as f64) + .resizable(false) + .decorations(false) + .shadow(false) + .focused(false) + .visible(false) + .visible_on_all_workspaces(true) + .zoom_hotkeys_enabled(false) + }; + + // On macOS and Linux, use logical coordinates (full screen size for click-through) + #[cfg(not(target_os = "windows"))] + let builder = { + let scale_factor = monitor.scale_factor(); + let logical_size = physical_size.to_logical::(scale_factor); + let logical_position = physical_position.to_logical::(scale_factor); + + let pos_x = logical_position.x; + let pos_y = logical_position.y + (logical_size.height / 4.0); + + let mut b = WebviewWindowBuilder::new( + app_handle, + TOOLBAR_WINDOW_LABEL, + tauri::WebviewUrl::App("/toolbar".into()), + ) + .title(TOOLBAR_WINDOW_LABEL) + .inner_size(logical_size.width, logical_size.height) + .position(pos_x, pos_y) + .resizable(false) + .decorations(false) + .shadow(false) + .focused(false) + .visible(false) + .visible_on_all_workspaces(true) + .zoom_hotkeys_enabled(false); + + #[cfg(target_os = "linux")] + { + b = b.transparent(true); + } + + b + }; + + let window = builder.build()?; + + // On Windows, explicitly set the size in physical pixels to ensure + // consistent sizing regardless of which monitor's DPI context is used + #[cfg(target_os = "windows")] + { + let physical_size = PhysicalSize::new(TOOLBAR_PHYSICAL_WIDTH, TOOLBAR_PHYSICAL_HEIGHT); + window.set_size(Size::Physical(physical_size))?; + } + + window.set_zoom(1.0)?; + window.hide()?; + + #[cfg(target_os = "macos")] + if let Err(err) = make_transparent(&window) { + log::warn!("failed to make toolbar window transparent: {}", err); + } + + Ok(window) +} + +pub fn ensure_toolbar_window(app_handle: &AppHandle) -> Result<(), tauri::Error> { + if app_handle + .get_webview_window(TOOLBAR_WINDOW_LABEL) + .is_none() + { + create_toolbar_window(app_handle)?; + } + Ok(()) +} + +pub fn show_toolbar_window(app_handle: &AppHandle) -> Result<(), tauri::Error> { + ensure_toolbar_window(app_handle)?; + + if let Some(window) = app_handle.get_webview_window(TOOLBAR_WINDOW_LABEL) { + window.show()?; + window.unminimize()?; + window.set_focus()?; + } + + Ok(()) +} + +fn open_toolbar(app_handle: &AppHandle) -> Result<(), tauri::Error> { + ensure_toolbar_window(app_handle)?; + + if let Some(window) = app_handle.get_webview_window(TOOLBAR_WINDOW_LABEL) { + if window.is_visible()? { + window.hide()?; + } else { + window.show()?; + window.unminimize()?; + window.set_focus()?; + } + } + + Ok(()) +} + +fn parse_shortcut(shortcut: &str) -> Result { + shortcut.parse::().map_err(|e| e.to_string()) +} + +fn register_toolbar_shortcut(app_handle: &AppHandle, shortcut: &str) -> Result<(), String> { + let parsed = parse_shortcut(shortcut)?; + app_handle + .global_shortcut() + .on_shortcut(parsed, |app, _shortcut, event| { + if matches!(event.state(), ShortcutState::Pressed) { + if let Err(err) = open_toolbar(app) { + log::error!("failed to open toolbar via shortcut: {}", err); + } + } + }) + .map_err(|e| e.to_string()) +} + +pub fn set_toolbar_shortcut(app_handle: &AppHandle, shortcut: Option<&str>) -> Result<(), String> { + let shortcut = shortcut + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + + app_handle + .global_shortcut() + .unregister_all() + .map_err(|e| e.to_string())?; + + if let Some(shortcut) = shortcut { + register_toolbar_shortcut(app_handle, shortcut.as_str())?; + } + + Ok(()) +} diff --git a/src-tauri/common/window.rs b/src-tauri/common/window.rs new file mode 100644 index 0000000..502d776 --- /dev/null +++ b/src-tauri/common/window.rs @@ -0,0 +1,250 @@ +use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindow, WebviewWindowBuilder}; + +#[cfg(target_os = "macos")] +pub fn make_transparent(window: &WebviewWindow) -> Result<(), tauri::Error> { + use cocoa::{ + appkit::NSColor, + base::{id, nil}, + foundation::NSString, + }; + use objc::{class, msg_send, runtime::Object, sel, sel_impl}; + + window.with_webview(|webview| unsafe { + let webview_obj = webview.inner() as *mut Object; + let no_value: id = msg_send![class!(NSNumber), numberWithBool:0]; + let key = NSString::alloc(nil).init_str("drawsBackground"); + let _: id = msg_send![webview_obj, setValue:no_value forKey:key]; + let _: () = msg_send![key, release]; + let ns_window: id = msg_send![webview_obj, window]; + let bg_color = NSColor::colorWithSRGBRed_green_blue_alpha_(nil, 0.0, 0.0, 0.0, 0.0); + let _: id = msg_send![ns_window, setBackgroundColor: bg_color]; + // let _: () = msg_send![ns_window, setIgnoresMouseEvents:true]; + })?; + + Ok(()) +} + +#[cfg(not(target_os = "macos"))] +pub fn make_transparent(_window: &WebviewWindow) -> Result<(), tauri::Error> { + Ok(()) +} + +#[tauri::command] +pub async fn open_full_window( + app_handle: AppHandle, + name: String, + url: String, +) -> Result<(), String> { + if let Some(existing) = app_handle.get_webview_window(&name) { + existing.set_focus().map_err(|e| e.to_string())?; + return Ok(()); + } + + let os = std::env::consts::OS; + + let primary_monitor = app_handle + .primary_monitor() + .map_err(|e| e.to_string())? + .or_else(|| app_handle.available_monitors().ok()?.into_iter().next()); + + let monitor = primary_monitor.ok_or("No monitor found")?; + let size = monitor.size(); + + let width = size.width as f64; + let mut height = size.height as f64; + + if os == "windows" { + height -= 100.0; + } + + let window = WebviewWindowBuilder::new(&app_handle, &name, WebviewUrl::App(url.into())) + .title(&name) + .inner_size(width, height) + .resizable(true) + .visible(false) + .focused(false) + .decorations(true) + // .additional_browser_args("--disable-pinch") + .zoom_hotkeys_enabled(false) + .build() + .map_err(|e| e.to_string())?; + + std::thread::sleep(std::time::Duration::from_millis(750)); + + window.center().map_err(|e| e.to_string())?; + window.set_zoom(1.0).map_err(|e| e.to_string())?; + window.show().map_err(|e| e.to_string())?; + window.set_focus().map_err(|e| e.to_string())?; + + if os != "windows" && os != "macos" { + window.set_resizable(false).map_err(|e| e.to_string())?; + window.set_resizable(true).map_err(|e| e.to_string())?; + } + + Ok(()) +} + +#[tauri::command] +pub async fn open_window( + app_handle: AppHandle, + name: String, + url: String, + width: Option, + height: Option, +) -> Result<(), String> { + if let Some(existing) = app_handle.get_webview_window(&name) { + existing.set_focus().map_err(|e| e.to_string())?; + return Ok(()); + } + + let os = std::env::consts::OS; + + let default_height = if os == "windows" { 755.0 } else { 725.0 }; + let width = width.unwrap_or(840.0); + let height = height.unwrap_or(default_height); + + let mut builder = WebviewWindowBuilder::new(&app_handle, &name, WebviewUrl::App(url.into())) + .title(&name) + .inner_size(width, height) + .resizable(false) + .visible(false) + .focused(false) + .decorations(true) + .zoom_hotkeys_enabled(false); + + #[cfg(target_os = "macos")] + { + builder = builder.title_bar_style(tauri::TitleBarStyle::Overlay); + } + + let window = builder.build().map_err(|e| e.to_string())?; + + std::thread::sleep(std::time::Duration::from_millis(750)); + + // window.center().map_err(|e| e.to_string())?; + let monitor = window + .current_monitor() + .map_err(|e| e.to_string())? + .or_else(|| app_handle.primary_monitor().ok().flatten()) + .ok_or("Monitor not found")?; + + let screen_size = monitor.size(); + let monitor_pos = monitor.position(); + let window_size = window.outer_size().map_err(|e| e.to_string())?; + + let x = monitor_pos.x + (screen_size.width as i32 - window_size.width as i32) / 2; + let y = monitor_pos.y + (screen_size.height as i32 - window_size.height as i32) / 2; + + let count = app_handle.webview_windows().len() as i32; + let offset = count * 20; + let sign = if count % 2 != 0 { -1 } else { 1 }; + let final_offset = offset * sign; + + window + .set_position(tauri::Position::Physical(tauri::PhysicalPosition { + x: x + final_offset, + y: y + final_offset, + })) + .map_err(|e| e.to_string())?; + + window.set_zoom(1.0).map_err(|e| e.to_string())?; + window.show().map_err(|e| e.to_string())?; + window.set_focus().map_err(|e| e.to_string())?; + + if os != "windows" && os != "macos" { + window.set_resizable(true).map_err(|e| e.to_string())?; + window.set_resizable(false).map_err(|e| e.to_string())?; + } + + Ok(()) +} + +#[tauri::command] +pub async fn open_small_window( + app_handle: AppHandle, + name: String, + url: String, +) -> Result<(), String> { + if let Some(existing) = app_handle.get_webview_window(&name) { + existing.set_focus().map_err(|e| e.to_string())?; + return Ok(()); + } + + let os = std::env::consts::OS; + + let mut builder = WebviewWindowBuilder::new(&app_handle, &name, WebviewUrl::App(url.into())) + .title(&name) + .inner_size(800.0, 500.0) + .resizable(false) + .visible(false) + .focused(false) + .decorations(false) + .closable(false) + .shadow(false) + .zoom_hotkeys_enabled(false); + + #[cfg(target_os = "linux")] + { + builder = builder.transparent(true); + } + + let window = builder.build().map_err(|e| e.to_string())?; + + window.center().map_err(|e| e.to_string())?; + window.set_zoom(1.0).map_err(|e| e.to_string())?; + + #[cfg(target_os = "macos")] + if let Err(err) = make_transparent(&window) { + log::warn!("failed to make small window transparent: {}", err); + } + + std::thread::sleep(std::time::Duration::from_millis(750)); + + window.set_decorations(false).map_err(|e| e.to_string())?; + window.show().map_err(|e| e.to_string())?; + window.set_focus().map_err(|e| e.to_string())?; + window.set_always_on_top(true).map_err(|e| e.to_string())?; + + if os != "windows" && os != "macos" { + window.set_resizable(true).map_err(|e| e.to_string())?; + window.set_resizable(false).map_err(|e| e.to_string())?; + } + + Ok(()) +} + +#[tauri::command] +pub async fn lock_windows(app_handle: AppHandle, ids: Option>) -> Result<(), String> { + let windows = app_handle.webview_windows(); + + for (label, window) in windows.iter() { + let should_lock = match &ids { + Some(id_list) => id_list.contains(label), + None => true, + }; + + if should_lock { + window.set_closable(false).map_err(|e| e.to_string())?; + } + } + + Ok(()) +} + +#[tauri::command] +pub async fn unlock_windows(app_handle: AppHandle, ids: Option>) -> Result<(), String> { + let windows = app_handle.webview_windows(); + + for (label, window) in windows.iter() { + let should_unlock = match &ids { + Some(id_list) => id_list.contains(label), + None => true, + }; + + if should_unlock { + window.set_closable(true).map_err(|e| e.to_string())?; + } + } + + Ok(()) +} diff --git a/src-tauri/flatpak.metainfo.xml b/src-tauri/flatpak.metainfo.xml index 0bc6842..f4d6c70 100644 --- a/src-tauri/flatpak.metainfo.xml +++ b/src-tauri/flatpak.metainfo.xml @@ -41,7 +41,7 @@
  • ✅ File Browser 2.0
  • ✅ Proxy support
  • ✅ Sync, Download, Copy, Mount, Serve, Move, and more
  • -
  • ✅ Jobs (see all background operations)
  • +
  • ✅ Transfers (see all background operations)
  • ✅ Set default flags for each remote separately, based on the operation (mount, copy, etc)
  • ✅ Customize the flags when running an operation on a case by case basis (on top of defaults)
  • ✅ Set a password for the Settings panel
  • @@ -114,10 +114,10 @@ - - https://github.com/rclone-ui/rclone-ui/releases/tag/v2.8.1 + + https://github.com/rclone-ui/rclone-ui/releases/tag/v3.0.0 -

    2.8.1 Release

    +

    3.0.0 Release

    • Improved Linux stability and overall performance.
    diff --git a/src-tauri/icons/small/backends/alias.png b/src-tauri/icons/small/backends/alias.png deleted file mode 100644 index a1f1ccc..0000000 Binary files a/src-tauri/icons/small/backends/alias.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/azureblob.png b/src-tauri/icons/small/backends/azureblob.png deleted file mode 100644 index 50916dd..0000000 Binary files a/src-tauri/icons/small/backends/azureblob.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/azurefiles.png b/src-tauri/icons/small/backends/azurefiles.png deleted file mode 100644 index 50916dd..0000000 Binary files a/src-tauri/icons/small/backends/azurefiles.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/b2.png b/src-tauri/icons/small/backends/b2.png deleted file mode 100644 index 99a6df6..0000000 Binary files a/src-tauri/icons/small/backends/b2.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/box.png b/src-tauri/icons/small/backends/box.png deleted file mode 100644 index 4e08f78..0000000 Binary files a/src-tauri/icons/small/backends/box.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/cache.png b/src-tauri/icons/small/backends/cache.png deleted file mode 100644 index ea3ba8d..0000000 Binary files a/src-tauri/icons/small/backends/cache.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/chunker.png b/src-tauri/icons/small/backends/chunker.png deleted file mode 100644 index 3bbea2a..0000000 Binary files a/src-tauri/icons/small/backends/chunker.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/cloudinary.png b/src-tauri/icons/small/backends/cloudinary.png deleted file mode 100644 index 5ff011b..0000000 Binary files a/src-tauri/icons/small/backends/cloudinary.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/combine.png b/src-tauri/icons/small/backends/combine.png deleted file mode 100644 index 55527df..0000000 Binary files a/src-tauri/icons/small/backends/combine.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/compress.png b/src-tauri/icons/small/backends/compress.png deleted file mode 100644 index 5b6356c..0000000 Binary files a/src-tauri/icons/small/backends/compress.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/crypt.png b/src-tauri/icons/small/backends/crypt.png deleted file mode 100644 index 747e42c..0000000 Binary files a/src-tauri/icons/small/backends/crypt.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/doi.png b/src-tauri/icons/small/backends/doi.png deleted file mode 100644 index 3b8db52..0000000 Binary files a/src-tauri/icons/small/backends/doi.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/drive.png b/src-tauri/icons/small/backends/drive.png deleted file mode 100644 index e593c5c..0000000 Binary files a/src-tauri/icons/small/backends/drive.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/dropbox.png b/src-tauri/icons/small/backends/dropbox.png deleted file mode 100644 index 112651b..0000000 Binary files a/src-tauri/icons/small/backends/dropbox.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/filefabric.png b/src-tauri/icons/small/backends/filefabric.png deleted file mode 100644 index ad55783..0000000 Binary files a/src-tauri/icons/small/backends/filefabric.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/filelu.png b/src-tauri/icons/small/backends/filelu.png deleted file mode 100644 index d3ba5e6..0000000 Binary files a/src-tauri/icons/small/backends/filelu.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/filescom.png b/src-tauri/icons/small/backends/filescom.png deleted file mode 100644 index bed1057..0000000 Binary files a/src-tauri/icons/small/backends/filescom.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/ftp.png b/src-tauri/icons/small/backends/ftp.png deleted file mode 100644 index a4e4192..0000000 Binary files a/src-tauri/icons/small/backends/ftp.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/gcs.png b/src-tauri/icons/small/backends/gcs.png deleted file mode 100644 index c19d79c..0000000 Binary files a/src-tauri/icons/small/backends/gcs.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/gofile.png b/src-tauri/icons/small/backends/gofile.png deleted file mode 100644 index ac5776f..0000000 Binary files a/src-tauri/icons/small/backends/gofile.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/gphotos.png b/src-tauri/icons/small/backends/gphotos.png deleted file mode 100644 index 366b1bd..0000000 Binary files a/src-tauri/icons/small/backends/gphotos.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/hasher.png b/src-tauri/icons/small/backends/hasher.png deleted file mode 100644 index b02e369..0000000 Binary files a/src-tauri/icons/small/backends/hasher.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/hdfs.png b/src-tauri/icons/small/backends/hdfs.png deleted file mode 100644 index a135d7b..0000000 Binary files a/src-tauri/icons/small/backends/hdfs.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/hidrive.png b/src-tauri/icons/small/backends/hidrive.png deleted file mode 100644 index 904cad1..0000000 Binary files a/src-tauri/icons/small/backends/hidrive.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/http.png b/src-tauri/icons/small/backends/http.png deleted file mode 100644 index 9708e92..0000000 Binary files a/src-tauri/icons/small/backends/http.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/imagekit.png b/src-tauri/icons/small/backends/imagekit.png deleted file mode 100644 index 354acef..0000000 Binary files a/src-tauri/icons/small/backends/imagekit.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/internetarchive.png b/src-tauri/icons/small/backends/internetarchive.png deleted file mode 100644 index 6e692ff..0000000 Binary files a/src-tauri/icons/small/backends/internetarchive.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/jottacloud.png b/src-tauri/icons/small/backends/jottacloud.png deleted file mode 100644 index fbfcfc2..0000000 Binary files a/src-tauri/icons/small/backends/jottacloud.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/koofr.png b/src-tauri/icons/small/backends/koofr.png deleted file mode 100644 index 57cf39f..0000000 Binary files a/src-tauri/icons/small/backends/koofr.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/linkbox.png b/src-tauri/icons/small/backends/linkbox.png deleted file mode 100644 index 76273d1..0000000 Binary files a/src-tauri/icons/small/backends/linkbox.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/local.png b/src-tauri/icons/small/backends/local.png deleted file mode 100644 index 07a8020..0000000 Binary files a/src-tauri/icons/small/backends/local.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/mailru.png b/src-tauri/icons/small/backends/mailru.png deleted file mode 100644 index d39be77..0000000 Binary files a/src-tauri/icons/small/backends/mailru.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/mega.png b/src-tauri/icons/small/backends/mega.png deleted file mode 100644 index 5d45c0e..0000000 Binary files a/src-tauri/icons/small/backends/mega.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/memory.png b/src-tauri/icons/small/backends/memory.png deleted file mode 100644 index bca37bb..0000000 Binary files a/src-tauri/icons/small/backends/memory.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/netstorage.png b/src-tauri/icons/small/backends/netstorage.png deleted file mode 100644 index 98b8ddf..0000000 Binary files a/src-tauri/icons/small/backends/netstorage.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/onedrive.png b/src-tauri/icons/small/backends/onedrive.png deleted file mode 100644 index c407771..0000000 Binary files a/src-tauri/icons/small/backends/onedrive.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/oos.png b/src-tauri/icons/small/backends/oos.png deleted file mode 100644 index c20030b..0000000 Binary files a/src-tauri/icons/small/backends/oos.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/opendrive.png b/src-tauri/icons/small/backends/opendrive.png deleted file mode 100644 index 5c18b93..0000000 Binary files a/src-tauri/icons/small/backends/opendrive.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/pcloud.png b/src-tauri/icons/small/backends/pcloud.png deleted file mode 100644 index 64369c7..0000000 Binary files a/src-tauri/icons/small/backends/pcloud.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/pikpak.png b/src-tauri/icons/small/backends/pikpak.png deleted file mode 100644 index 41439a3..0000000 Binary files a/src-tauri/icons/small/backends/pikpak.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/pixeldrain.png b/src-tauri/icons/small/backends/pixeldrain.png deleted file mode 100644 index 499ce1d..0000000 Binary files a/src-tauri/icons/small/backends/pixeldrain.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/premiumizeme.png b/src-tauri/icons/small/backends/premiumizeme.png deleted file mode 100644 index 2228a4e..0000000 Binary files a/src-tauri/icons/small/backends/premiumizeme.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/protondrive.png b/src-tauri/icons/small/backends/protondrive.png deleted file mode 100644 index 2324653..0000000 Binary files a/src-tauri/icons/small/backends/protondrive.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/putio.png b/src-tauri/icons/small/backends/putio.png deleted file mode 100644 index 0ddd57f..0000000 Binary files a/src-tauri/icons/small/backends/putio.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/qingstor.png b/src-tauri/icons/small/backends/qingstor.png deleted file mode 100644 index 949c446..0000000 Binary files a/src-tauri/icons/small/backends/qingstor.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/quatrix.png b/src-tauri/icons/small/backends/quatrix.png deleted file mode 100644 index c889007..0000000 Binary files a/src-tauri/icons/small/backends/quatrix.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/s3.png b/src-tauri/icons/small/backends/s3.png deleted file mode 100644 index bdcef32..0000000 Binary files a/src-tauri/icons/small/backends/s3.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/seafile.png b/src-tauri/icons/small/backends/seafile.png deleted file mode 100644 index e9cb59f..0000000 Binary files a/src-tauri/icons/small/backends/seafile.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/sftp.png b/src-tauri/icons/small/backends/sftp.png deleted file mode 100644 index fc533a1..0000000 Binary files a/src-tauri/icons/small/backends/sftp.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/sharefile.png b/src-tauri/icons/small/backends/sharefile.png deleted file mode 100644 index 72645fe..0000000 Binary files a/src-tauri/icons/small/backends/sharefile.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/sia.png b/src-tauri/icons/small/backends/sia.png deleted file mode 100644 index 03620f3..0000000 Binary files a/src-tauri/icons/small/backends/sia.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/smb.png b/src-tauri/icons/small/backends/smb.png deleted file mode 100644 index e765288..0000000 Binary files a/src-tauri/icons/small/backends/smb.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/storj.png b/src-tauri/icons/small/backends/storj.png deleted file mode 100644 index 0c95867..0000000 Binary files a/src-tauri/icons/small/backends/storj.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/sugarsync.png b/src-tauri/icons/small/backends/sugarsync.png deleted file mode 100644 index b5bc007..0000000 Binary files a/src-tauri/icons/small/backends/sugarsync.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/swift.png b/src-tauri/icons/small/backends/swift.png deleted file mode 100644 index 21be20d..0000000 Binary files a/src-tauri/icons/small/backends/swift.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/ulozto.png b/src-tauri/icons/small/backends/ulozto.png deleted file mode 100644 index fee4f92..0000000 Binary files a/src-tauri/icons/small/backends/ulozto.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/union.png b/src-tauri/icons/small/backends/union.png deleted file mode 100644 index 0ffa32e..0000000 Binary files a/src-tauri/icons/small/backends/union.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/webdav.png b/src-tauri/icons/small/backends/webdav.png deleted file mode 100644 index ae07b06..0000000 Binary files a/src-tauri/icons/small/backends/webdav.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/yandex.png b/src-tauri/icons/small/backends/yandex.png deleted file mode 100644 index c82f23c..0000000 Binary files a/src-tauri/icons/small/backends/yandex.png and /dev/null differ diff --git a/src-tauri/icons/small/backends/zoho.png b/src-tauri/icons/small/backends/zoho.png deleted file mode 100644 index 6133c87..0000000 Binary files a/src-tauri/icons/small/backends/zoho.png and /dev/null differ diff --git a/src-tauri/icons/small/providers/AWS.png b/src-tauri/icons/small/providers/AWS.png deleted file mode 100644 index 4c45637..0000000 Binary files a/src-tauri/icons/small/providers/AWS.png and /dev/null differ diff --git a/src-tauri/icons/small/providers/Alibaba.png b/src-tauri/icons/small/providers/Alibaba.png deleted file mode 100644 index 890622e..0000000 Binary files a/src-tauri/icons/small/providers/Alibaba.png and /dev/null differ diff --git a/src-tauri/icons/small/providers/ArvanCloud.png b/src-tauri/icons/small/providers/ArvanCloud.png deleted file mode 100644 index 1453528..0000000 Binary files a/src-tauri/icons/small/providers/ArvanCloud.png and /dev/null differ diff --git a/src-tauri/icons/small/providers/Ceph.png b/src-tauri/icons/small/providers/Ceph.png deleted file mode 100644 index d1f73ff..0000000 Binary files a/src-tauri/icons/small/providers/Ceph.png and /dev/null differ diff --git a/src-tauri/icons/small/providers/ChinaMobile.png b/src-tauri/icons/small/providers/ChinaMobile.png deleted file mode 100644 index ac289f7..0000000 Binary files a/src-tauri/icons/small/providers/ChinaMobile.png and /dev/null differ diff --git a/src-tauri/icons/small/providers/Cloudflare.png b/src-tauri/icons/small/providers/Cloudflare.png deleted file mode 100644 index 4459b36..0000000 Binary files a/src-tauri/icons/small/providers/Cloudflare.png and /dev/null differ diff --git a/src-tauri/icons/small/providers/DigitalOcean.png b/src-tauri/icons/small/providers/DigitalOcean.png deleted file mode 100644 index bb5945c..0000000 Binary files a/src-tauri/icons/small/providers/DigitalOcean.png and /dev/null differ diff --git a/src-tauri/icons/small/providers/Dreamhost.png b/src-tauri/icons/small/providers/Dreamhost.png deleted file mode 100644 index 3413d2e..0000000 Binary files a/src-tauri/icons/small/providers/Dreamhost.png and /dev/null differ diff --git a/src-tauri/icons/small/providers/Exaba.png b/src-tauri/icons/small/providers/Exaba.png deleted file mode 100644 index 9fa83ed..0000000 Binary files a/src-tauri/icons/small/providers/Exaba.png and /dev/null differ diff --git a/src-tauri/icons/small/providers/FlashBlade.png b/src-tauri/icons/small/providers/FlashBlade.png deleted file mode 100644 index 9019cdb..0000000 Binary files a/src-tauri/icons/small/providers/FlashBlade.png and /dev/null differ diff --git a/src-tauri/icons/small/providers/GCS.png b/src-tauri/icons/small/providers/GCS.png deleted file mode 100644 index c19d79c..0000000 Binary files a/src-tauri/icons/small/providers/GCS.png and /dev/null differ diff --git a/src-tauri/icons/small/providers/HuaweiOBS.png b/src-tauri/icons/small/providers/HuaweiOBS.png deleted file mode 100644 index 21d9328..0000000 Binary files a/src-tauri/icons/small/providers/HuaweiOBS.png and /dev/null differ diff --git a/src-tauri/icons/small/providers/IBMCOS.png b/src-tauri/icons/small/providers/IBMCOS.png deleted file mode 100644 index 7302479..0000000 Binary files a/src-tauri/icons/small/providers/IBMCOS.png and /dev/null differ diff --git a/src-tauri/icons/small/providers/IDrive.png b/src-tauri/icons/small/providers/IDrive.png deleted file mode 100644 index 09c31b1..0000000 Binary files a/src-tauri/icons/small/providers/IDrive.png and /dev/null differ diff --git a/src-tauri/icons/small/providers/IONOS.png b/src-tauri/icons/small/providers/IONOS.png deleted file mode 100644 index 3bfe29a..0000000 Binary files a/src-tauri/icons/small/providers/IONOS.png and /dev/null differ diff --git a/src-tauri/icons/small/providers/Leviia.png b/src-tauri/icons/small/providers/Leviia.png deleted file mode 100644 index ae870ae..0000000 Binary files a/src-tauri/icons/small/providers/Leviia.png and /dev/null differ diff --git a/src-tauri/icons/small/providers/Liara.png b/src-tauri/icons/small/providers/Liara.png deleted file mode 100644 index 97550a0..0000000 Binary files a/src-tauri/icons/small/providers/Liara.png and /dev/null differ diff --git a/src-tauri/icons/small/providers/Linode.png b/src-tauri/icons/small/providers/Linode.png deleted file mode 100644 index e8a4c7e..0000000 Binary files a/src-tauri/icons/small/providers/Linode.png and /dev/null differ diff --git a/src-tauri/icons/small/providers/LyveCloud.png b/src-tauri/icons/small/providers/LyveCloud.png deleted file mode 100644 index 35154d6..0000000 Binary files a/src-tauri/icons/small/providers/LyveCloud.png and /dev/null differ diff --git a/src-tauri/icons/small/providers/Magalu.png b/src-tauri/icons/small/providers/Magalu.png deleted file mode 100644 index be21c33..0000000 Binary files a/src-tauri/icons/small/providers/Magalu.png and /dev/null differ diff --git a/src-tauri/icons/small/providers/Mega.png b/src-tauri/icons/small/providers/Mega.png deleted file mode 100644 index 5d45c0e..0000000 Binary files a/src-tauri/icons/small/providers/Mega.png and /dev/null differ diff --git a/src-tauri/icons/small/providers/Minio.png b/src-tauri/icons/small/providers/Minio.png deleted file mode 100644 index 157773c..0000000 Binary files a/src-tauri/icons/small/providers/Minio.png and /dev/null differ diff --git a/src-tauri/icons/small/providers/Netease.png b/src-tauri/icons/small/providers/Netease.png deleted file mode 100644 index b23d047..0000000 Binary files a/src-tauri/icons/small/providers/Netease.png and /dev/null differ diff --git a/src-tauri/icons/small/providers/OVHcloud.png b/src-tauri/icons/small/providers/OVHcloud.png deleted file mode 100644 index 53b6a4f..0000000 Binary files a/src-tauri/icons/small/providers/OVHcloud.png and /dev/null differ diff --git a/src-tauri/icons/small/providers/Other.png b/src-tauri/icons/small/providers/Other.png deleted file mode 100644 index bdcef32..0000000 Binary files a/src-tauri/icons/small/providers/Other.png and /dev/null differ diff --git a/src-tauri/icons/small/providers/Outscale.png b/src-tauri/icons/small/providers/Outscale.png deleted file mode 100644 index 97b6f83..0000000 Binary files a/src-tauri/icons/small/providers/Outscale.png and /dev/null differ diff --git a/src-tauri/icons/small/providers/Petabox.png b/src-tauri/icons/small/providers/Petabox.png deleted file mode 100644 index a8ac360..0000000 Binary files a/src-tauri/icons/small/providers/Petabox.png and /dev/null differ diff --git a/src-tauri/icons/small/providers/Qiniu.png b/src-tauri/icons/small/providers/Qiniu.png deleted file mode 100644 index 68c396a..0000000 Binary files a/src-tauri/icons/small/providers/Qiniu.png and /dev/null differ diff --git a/src-tauri/icons/small/providers/RackCorp.png b/src-tauri/icons/small/providers/RackCorp.png deleted file mode 100644 index 6470aab..0000000 Binary files a/src-tauri/icons/small/providers/RackCorp.png and /dev/null differ diff --git a/src-tauri/icons/small/providers/Rclone.png b/src-tauri/icons/small/providers/Rclone.png deleted file mode 100644 index 81382bf..0000000 Binary files a/src-tauri/icons/small/providers/Rclone.png and /dev/null differ diff --git a/src-tauri/icons/small/providers/Scaleway.png b/src-tauri/icons/small/providers/Scaleway.png deleted file mode 100644 index e1c2868..0000000 Binary files a/src-tauri/icons/small/providers/Scaleway.png and /dev/null differ diff --git a/src-tauri/icons/small/providers/SeaweedFS.png b/src-tauri/icons/small/providers/SeaweedFS.png deleted file mode 100644 index 451354e..0000000 Binary files a/src-tauri/icons/small/providers/SeaweedFS.png and /dev/null differ diff --git a/src-tauri/icons/small/providers/Selectel.png b/src-tauri/icons/small/providers/Selectel.png deleted file mode 100644 index 82caa33..0000000 Binary files a/src-tauri/icons/small/providers/Selectel.png and /dev/null differ diff --git a/src-tauri/icons/small/providers/StackPath.png b/src-tauri/icons/small/providers/StackPath.png deleted file mode 100644 index c7ebcd8..0000000 Binary files a/src-tauri/icons/small/providers/StackPath.png and /dev/null differ diff --git a/src-tauri/icons/small/providers/Storj.png b/src-tauri/icons/small/providers/Storj.png deleted file mode 100644 index 0c95867..0000000 Binary files a/src-tauri/icons/small/providers/Storj.png and /dev/null differ diff --git a/src-tauri/icons/small/providers/Synology.png b/src-tauri/icons/small/providers/Synology.png deleted file mode 100644 index 537a677..0000000 Binary files a/src-tauri/icons/small/providers/Synology.png and /dev/null differ diff --git a/src-tauri/icons/small/providers/TencentCOS.png b/src-tauri/icons/small/providers/TencentCOS.png deleted file mode 100644 index 83b9e90..0000000 Binary files a/src-tauri/icons/small/providers/TencentCOS.png and /dev/null differ diff --git a/src-tauri/icons/small/providers/Wasabi.png b/src-tauri/icons/small/providers/Wasabi.png deleted file mode 100644 index b53c07c..0000000 Binary files a/src-tauri/icons/small/providers/Wasabi.png and /dev/null differ diff --git a/src-tauri/icons/small/providers/Zata.png b/src-tauri/icons/small/providers/Zata.png deleted file mode 100644 index 6f0a598..0000000 Binary files a/src-tauri/icons/small/providers/Zata.png and /dev/null differ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5200821..ac247e7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,83 +1,38 @@ -// use tauri::Manager; - -// #[derive(Clone, serde::Serialize)] -// struct Payload { -// args: Vec, -// cwd: String, -// } - use machine_uid; +use sentry; use std::fs::{self, File}; use std::path::Path; -use zip::ZipArchive; -use sysinfo::{System}; +use sysinfo::System; +use tauri::{AppHandle, Emitter, Manager}; use tauri_plugin_sentry; -use sentry; +use tinyfiledialogs as tfd; +use zip::ZipArchive; + +#[path = "../common/shortcut.rs"] +mod shortcut; + +#[path = "../common/window.rs"] +mod window; + +use shortcut::{ + ensure_toolbar_window, set_toolbar_shortcut, show_toolbar_window, DEFAULT_TOOLBAR_SHORTCUT, +}; + +use window::{lock_windows, open_full_window, open_small_window, open_window, unlock_windows}; #[tauri::command] -fn is_tray_supported() -> bool { - #[cfg(not(target_os = "linux"))] - { - return true; - } +fn update_toolbar_shortcut(app_handle: AppHandle, shortcut: Option) -> Result<(), String> { + set_toolbar_shortcut(&app_handle, shortcut.as_deref()) +} - #[cfg(target_os = "linux")] - { - use zbus::blocking::fdo::DBusProxy; - use zbus::blocking::{Connection, Proxy}; - use zbus::names::BusName; +#[tauri::command] +fn show_toolbar(app_handle: AppHandle) -> Result<(), String> { + show_toolbar_window(&app_handle).map_err(|e| e.to_string()) +} - if let Ok(conn) = Connection::session() { - if let Ok(dbus) = DBusProxy::new(&conn) { - let candidates = [ - "org.kde.StatusNotifierWatcher", - "org.freedesktop.StatusNotifierWatcher", - ]; - - for name in candidates { - let bus_name = match BusName::try_from(name) { - Ok(n) => n, - Err(_) => continue, - }; - if dbus.name_has_owner(bus_name).unwrap_or(false) { - // Try to read the property; if it fails but watcher exists, assume true - if let Ok(proxy) = Proxy::new(&conn, name, "/StatusNotifierWatcher", "org.kde.StatusNotifierWatcher") { - if let Ok(registered) = proxy.get_property::("IsStatusNotifierHostRegistered") { - return registered; - } - } - return true; - } - } - } - } - - // legacy XEmbed tray (X11 _NET_SYSTEM_TRAY_Sn) - { - use std::env; - use x11rb::protocol::xproto::ConnectionExt; - - // If Wayland-only (no X server), no legacy tray will exist - if env::var_os("WAYLAND_DISPLAY").is_some() && env::var_os("DISPLAY").is_none() { - return false; - } - - if let Ok((conn, screen_num)) = x11rb::connect(None) { - let sel_name = format!("_NET_SYSTEM_TRAY_S{}", screen_num); - if let Ok(atom_cookie) = conn.intern_atom(false, sel_name.as_bytes()) { - if let Ok(atom_reply) = atom_cookie.reply() { - if let Ok(owner_cookie) = conn.get_selection_owner(atom_reply.atom) { - if let Ok(owner_reply) = owner_cookie.reply() { - return owner_reply.owner != 0; - } - } - } - } - } - } - - false - } +#[tauri::command] +fn is_flathub() -> bool { + std::path::Path::new("/.flatpak-info").exists() || std::env::var_os("FLATPAK_ID").is_some() } #[tauri::command] @@ -179,39 +134,19 @@ async fn stop_pid(pid: u32, timeout_ms: Option) -> Result<(), String> { let pid_str = pid.to_string(); - // Try graceful termination first - let _ = std::process::Command::new("taskkill") - .args(&["/PID", &pid_str]) - .status(); - - let deadline = Instant::now() + Duration::from_millis(timeout); - while Instant::now() < deadline { - let output = std::process::Command::new("tasklist") - .args(&["/FI", &format!("PID eq {}", pid), "/FO", "CSV", "/NH"]) - .output() - .map_err(|e| e.to_string())?; - let stdout = String::from_utf8_lossy(&output.stdout).to_string(); - if stdout.trim().is_empty() - || stdout.contains("No tasks are running") - || !stdout.contains(&pid_str) - { - return Ok(()); - } - std::thread::sleep(Duration::from_millis(100)); - } - - // Force kill let _ = std::process::Command::new("taskkill") .args(&["/PID", &pid_str, "/F", "/T"]) .status(); - // Final check let output = std::process::Command::new("tasklist") .args(&["/FI", &format!("PID eq {}", pid), "/FO", "CSV", "/NH"]) .output() .map_err(|e| e.to_string())?; let stdout = String::from_utf8_lossy(&output.stdout).to_string(); - if !stdout.trim().is_empty() && stdout.contains(&pid_str) && !stdout.contains("No tasks are running") { + if !stdout.trim().is_empty() + && stdout.contains(&pid_str) + && !stdout.contains("No tasks are running") + { return Err("Failed to terminate process".to_string()); } @@ -248,10 +183,8 @@ fn get_uid() -> String { return machine_uid::get().unwrap(); } - #[tauri::command] fn is_rclone_running(port: Option) -> bool { - if let Some(port) = port { use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, TcpStream}; use std::time::Duration; @@ -309,12 +242,10 @@ async fn stop_rclone_processes(timeout_ms: Option) -> Result { Ok(stopped) } -#[tauri::command] async fn prompt_password(title: String, message: String) -> Result, String> { prompt_text(title, message, None, Some(true)).await } -#[tauri::command] async fn prompt_text( title: String, message: String, @@ -432,7 +363,13 @@ async fn prompt_text( ); let output = Command::new("powershell") - .args(&["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &powershell_script]) + .args(&[ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + &powershell_script, + ]) .output() .map_err(|e| e.to_string())?; @@ -470,7 +407,10 @@ async fn prompt_text( } // For other errors, fall through to try kdialog let stderr = String::from_utf8_lossy(&output.stderr); - eprintln!("zenity password dialog failed (exit {}): {}", exit_code, stderr); + eprintln!( + "zenity password dialog failed (exit {}): {}", + exit_code, stderr + ); } } Err(e) => { @@ -495,7 +435,10 @@ async fn prompt_text( return Ok(None); } let stderr = String::from_utf8_lossy(&output.stderr); - eprintln!("kdialog password dialog failed (exit {}): {}", exit_code, stderr); + eprintln!( + "kdialog password dialog failed (exit {}): {}", + exit_code, stderr + ); } } Err(e) => { @@ -503,11 +446,21 @@ async fn prompt_text( } } - return Err("No suitable password dialog found. Please install zenity or kdialog.".to_string()); + return Err( + "No suitable password dialog found. Please install zenity or kdialog.".to_string(), + ); } else { // Try zenity text entry first match std::process::Command::new("zenity") - .args(&["--entry", "--title", &title, "--text", &message, "--entry-text", &default_value]) + .args(&[ + "--entry", + "--title", + &title, + "--text", + &message, + "--entry-text", + &default_value, + ]) .output() { Ok(output) => { @@ -521,7 +474,10 @@ async fn prompt_text( return Ok(None); } let stderr = String::from_utf8_lossy(&output.stderr); - eprintln!("zenity entry dialog failed (exit {}): {}", exit_code, stderr); + eprintln!( + "zenity entry dialog failed (exit {}): {}", + exit_code, stderr + ); } } Err(e) => { @@ -553,7 +509,9 @@ async fn prompt_text( } } - return Err("No suitable input dialog found. Please install zenity or kdialog.".to_string()); + return Err( + "No suitable input dialog found. Please install zenity or kdialog.".to_string(), + ); } } @@ -563,6 +521,31 @@ async fn prompt_text( } } +#[tauri::command] +async fn tiny_prompt_text( + title: String, + message: String, + default: Option, + sensitive: Option, +) -> Result, String> { + let is_sensitive = sensitive.unwrap_or(false); + let default_value = default.unwrap_or_default(); + let title_clone = title.clone(); + let message_clone = message.clone(); + + let result = tauri::async_runtime::spawn_blocking(move || { + if is_sensitive { + tfd::password_box(&title_clone, &message_clone) + } else { + tfd::input_box(&title_clone, &message_clone, &default_value) + } + }) + .await + .map_err(|error| error.to_string())?; + + Ok(result) +} + #[tauri::command] async fn test_proxy_connection(proxy_url: String) -> Result { use std::time::Duration; @@ -574,8 +557,7 @@ async fn test_proxy_connection(proxy_url: String) -> Result { } // Build client with proxy - let proxy = reqwest::Proxy::all(proxy_url) - .map_err(|e| format!("Invalid proxy URL: {}", e))?; + let proxy = reqwest::Proxy::all(proxy_url).map_err(|e| format!("Invalid proxy URL: {}", e))?; let client = reqwest::Client::builder() .proxy(proxy) .timeout(Duration::from_secs(10)) @@ -597,10 +579,15 @@ async fn test_proxy_connection(proxy_url: String) -> Result { if resp.status().is_success() { match resp.text().await { Ok(body) => { - return Ok(format!("Connected via proxy. Endpoint: {}. Snippet: {}", url, body.chars().take(200).collect::())) + return Ok(format!( + "Connected via proxy. Endpoint: {}. Snippet: {}", + url, + body.chars().take(200).collect::() + )) } Err(e) => { - last_error = Some(format!("Failed to read response from {}: {}", url, e)); + last_error = + Some(format!("Failed to read response from {}: {}", url, e)); continue; } } @@ -630,7 +617,8 @@ async fn update_system_rclone() -> Result { format!("'{}'", escaped) } - let mut cmdline = String::from("PATH=/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH; "); + let mut cmdline = + String::from("PATH=/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH; "); cmdline.push_str("e_posix("rclone")); cmdline.push(' '); cmdline.push_str("e_posix("selfupdate")); @@ -656,7 +644,8 @@ async fn update_system_rclone() -> Result { { use std::process::Command as SysCommand; - let path_env = "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin"; + let path_env = + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin"; // Try PolicyKit first (graphical auth prompt on most desktops) let mut pkexec_args: Vec = Vec::new(); @@ -673,7 +662,7 @@ async fn update_system_rclone() -> Result { // Fallback to sudo with custom prompt (works if the user has NOPASSWD or cached credentials) let mut sudo_env = std::collections::HashMap::new(); sudo_env.insert("SUDO_PROMPT", "Rclone UI needs permission to run rclone selfupdate. Please enter your password: "); - + let mut sudo_args: Vec = Vec::new(); sudo_args.push("-n".to_string()); sudo_args.push("env".to_string()); @@ -712,7 +701,13 @@ async fn update_system_rclone() -> Result { ); let status = SysCommand::new("powershell") - .args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &ps_script]) + .args([ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + &ps_script, + ]) .status() .map_err(|e| e.to_string())?; return Ok(status.code().unwrap_or(0)); @@ -726,7 +721,7 @@ async fn update_system_rclone() -> Result { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - let client = sentry::init(( + let client = sentry::init(( "https://7c7c55918ff850112780d2b2b29121a6@o4508503751983104.ingest.de.sentry.io/4508739164110928", sentry::ClientOptions { release: sentry::release_name!(), @@ -735,105 +730,76 @@ pub fn run() { )); let _guard = tauri_plugin_sentry::minidump::init(&client); - + let mut app = tauri::Builder::default() - .plugin(tauri_plugin_sentry::init_with_no_injection(&client)) + .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| { + if let Some(window) = app + .webview_windows() + .values() + .find(|w| w.label() != "main") + { + let _ = window.set_focus(); + } + })) + .plugin(tauri_plugin_deep_link::init()) + .plugin(tauri_plugin_sentry::init_with_no_injection(&client)) .plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_process::init()) - .plugin(tauri_plugin_autostart::init(tauri_plugin_autostart::MacosLauncher::LaunchAgent, Some(vec![]))) - // .plugin(tauri_plugin_single_instance::init(|app, argv, cwd| { - // println!("{}, {argv:?}, {cwd}", app.package_info().name); - // // app.emit("single-instance", Payload { args: argv, cwd }).unwrap(); - // })) - // .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| { - // let _ = app - // .get_webview_window("main") - // .expect("no main window") - // .set_focus(); - // })) - .plugin(tauri_plugin_store::Builder::new().build()) - .plugin(tauri_plugin_http::init()) - .plugin(tauri_plugin_fs::init()) - .plugin(tauri_plugin_dialog::init()) - .plugin(tauri_plugin_log::Builder::new().build()) - .plugin(tauri_plugin_shell::init()) - .plugin(tauri_plugin_opener::init()) - .plugin(tauri_plugin_prevent_default::debug()) - .invoke_handler(tauri::generate_handler![unzip_file, get_arch, get_uid, is_rclone_running, stop_rclone_processes, prompt_password, prompt_text, stop_pid, update_system_rclone, test_proxy_connection, is_tray_supported]) - .setup(|_app| Ok(())) - // .setup(|app| { - // if cfg!(debug_assertions) { - // app.handle().plugin( - // tauri_plugin_log::Builder::default() - // .level(log::LevelFilter::Info) - // .build(), - // )?; - // } - // Ok(()) - // }) - // .setup(|app| { - // let win_builder = - // tauri::WebviewWindowBuilder::new(app, "main", tauri::WebviewUrl::default()) - // .title("Transparent Titlebar Window") - // .inner_size(800.0, 600.0); - // // set transparent title bar only when building for macOS - // #[cfg(target_os = "macos")] - // let win_builder = win_builder.title_bar_style(tauri::TitleBarStyle::Transparent); - // let window = win_builder.build().unwrap(); - // // set background color only when building for macOS - // #[cfg(target_os = "macos")] - // { - // use cocoa::appkit::{NSColor, NSWindow}; - // use cocoa::base::{id, nil}; - // let ns_window = window.ns_window().unwrap() as id; - // unsafe { - // let bg_color = NSColor::colorWithRed_green_blue_alpha_( - // nil, - // 138.0 / 255.0, - // 43.0 / 255.0, - // 226.0 / 255.0, - // 1.0, - // ); - // ns_window.setBackgroundColor_(bg_color); - // } - // } - // Ok(()) - // }) + .plugin(tauri_plugin_autostart::init( + tauri_plugin_autostart::MacosLauncher::LaunchAgent, + Some(vec![]), + )) + .plugin(tauri_plugin_store::Builder::new().build()) + .plugin(tauri_plugin_http::init()) + .plugin(tauri_plugin_fs::init()) + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_log::Builder::new().build()) + .plugin(tauri_plugin_shell::init()) + .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_prevent_default::debug()) + .plugin(tauri_plugin_global_shortcut::Builder::new().build()) + .invoke_handler(tauri::generate_handler![ + unzip_file, + get_arch, + get_uid, + is_rclone_running, + stop_rclone_processes, + tiny_prompt_text, + stop_pid, + update_toolbar_shortcut, + show_toolbar, + update_system_rclone, + test_proxy_connection, + is_flathub, + open_full_window, + open_window, + open_small_window, + lock_windows, + unlock_windows + ]) + .setup(|app| { + #[cfg(any(windows, target_os = "linux"))] + { + use tauri_plugin_deep_link::DeepLinkExt; + app.deep_link().register_all()?; + } + + if let Err(err) = ensure_toolbar_window(&app.handle()) { + log::warn!("failed to prepare toolbar window: {}", err); + } + if let Err(err) = set_toolbar_shortcut(&app.handle(), Some(DEFAULT_TOOLBAR_SHORTCUT)) { + log::error!("failed to update default toolbar shortcut: {}", err); + } + + Ok(()) + }) .build(tauri::generate_context!()) .expect("error while running tauri application"); #[cfg(target_os = "macos")] app.set_activation_policy(tauri::ActivationPolicy::Accessory); - - // prevents only app close - // app.run(|app_handle, e| { - // if let tauri::RunEvent::ExitRequested { api, .. } = &e { - // // Keep the event loop running even if all windows are closed - // // This allow us to catch system tray events when there is no window - // api.prevent_exit(); - // } - // }); - - // also prevents window close - // RS: https://github.com/tauri-apps/tauri/issues/5500#issuecomment-1300258861 - // JS: https://github.com/tauri-apps/tauri/blob/4cbdf0fb1c0de5004eab51c36d5843a9816f18af/examples/api/src/App.svelte#L26 - // app.run(|app, event| match event { - // tauri::RunEvent::WindowEvent { - // label, - // event: win_event, - // .. - // } => match win_event { - // tauri::WindowEvent::CloseRequested { api, .. } => { - // let win = app.get_webview_window(label.as_str()).unwrap(); - // win.hide().unwrap(); - // api.prevent_close(); - // } - // _ => {} - // }, - // _ => {} - // }) app.run(|_app, _event| {}) } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index e4b1bd7..aebdb65 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,17 +1,21 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { - #[cfg(target_os = "linux")] + #[cfg(target_os = "linux")] + { + let is_dri_present = std::path::Path::new("/dev/dri").exists(); + let is_wayland = std::env::var("WAYLAND_DISPLAY").is_ok(); + let is_x11_session = std::env::var("XDG_SESSION_TYPE").unwrap_or_default() == "x11"; + if is_dri_present && !is_wayland && is_x11_session { + if std::env::var_os("__NV_DISABLE_EXPLICIT_SYNC").is_none() { + std::env::set_var("__NV_DISABLE_EXPLICIT_SYNC", "1"); + } + } + } + #[cfg(target_os = "windows")] { - let is_dri_present = std::path::Path::new("/dev/dri").exists(); - let is_wayland = std::env::var("WAYLAND_DISPLAY").is_ok(); - let is_x11_session = std::env::var("XDG_SESSION_TYPE").unwrap_or_default() == "x11"; - if is_dri_present && !is_wayland && is_x11_session { - if std::env::var_os("__NV_DISABLE_EXPLICIT_SYNC").is_none() { - std::env::set_var("__NV_DISABLE_EXPLICIT_SYNC", "1"); - } - } + std::env::set_var("WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS", "--ignore-gpu-blocklist"); } - let _ = fix_path_env::fix(); + let _ = fix_path_env::fix(); app_lib::run(); } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 933f067..d0a0fdd 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "Rclone UI", "mainBinaryName": "Rclone UI", - "version": "2.8.1", + "version": "3.0.0", "identifier": "com.rclone.ui", "build": { "frontendDist": "../dist", @@ -24,7 +24,6 @@ "decorations": false, "focus": true, "url": "tray.html", - "theme": "Dark", "alwaysOnTop": true, "backgroundThrottling": "disabled", "zoomHotkeysEnabled": false @@ -45,7 +44,7 @@ "icons/icon.icns", "icons/icon.ico" ], - "resources": ["icons/favicon/*", "icons/small/providers/*", "icons/small/backends/*"], + "resources": ["icons/favicon/*"], "shortDescription": "Cross-platform GUI for rclone & S3", "longDescription": "Cross-platform GUI for rclone & S3", "license": "Apache-2.0", @@ -97,6 +96,11 @@ "https://github.com/rclone-ui/rclone-ui/releases/latest/download/latest.json" ] }, + "deep-link": { + "desktop": { + "schemes": ["rclone"] + } + }, "fs": { "requireLiteralLeadingDot": false } diff --git a/src/components/CommandInfo.tsx b/src/components/CommandInfo.tsx deleted file mode 100644 index 7242b8d..0000000 --- a/src/components/CommandInfo.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { Accordion, AccordionItem } from '@heroui/react' -import { InfoIcon } from 'lucide-react' -import { startTransition, useState } from 'react' - -export default function CommandInfo({ content }: { content: string }) { - const [isOpen, setIsOpen] = useState(false) - - return ( - { - startTransition(() => { - setIsOpen((prev) => !prev) - }) - }} - > - } - > - {content} - - - ) -} diff --git a/src/components/CommandInfoButton.tsx b/src/components/CommandInfoButton.tsx new file mode 100644 index 0000000..d6b388c --- /dev/null +++ b/src/components/CommandInfoButton.tsx @@ -0,0 +1,52 @@ +import { Button, Drawer, DrawerBody, DrawerContent, DrawerHeader, Tooltip, cn } from '@heroui/react' +import { platform } from '@tauri-apps/plugin-os' +import { InfoIcon } from 'lucide-react' +import { startTransition, useState } from 'react' + +export default function CommandInfoButton({ content }: { content: string }) { + const [isOpen, setIsOpen] = useState(false) + + return ( + <> + + + + + { + startTransition(() => { + setIsOpen(false) + }) + }} + size="full" + placement={'bottom'} + > + + Documentation + + {content} + + + + + ) +} diff --git a/src/components/CommandsDropdown.tsx b/src/components/CommandsDropdown.tsx new file mode 100644 index 0000000..df03627 --- /dev/null +++ b/src/components/CommandsDropdown.tsx @@ -0,0 +1,56 @@ +import { + Button, + Dropdown, + DropdownItem, + DropdownMenu, + DropdownSection, + DropdownTrigger, +} from '@heroui/react' +import { platform } from '@tauri-apps/plugin-os' +import { CommandIcon } from 'lucide-react' +import { openWindow } from '../../lib/window' + +export default function CommandsDropdown({ + currentCommand, + title, +}: { currentCommand?: string; title?: string }) { + return ( + + + + + { + console.log(key) + const keyAsString = key as string + await openWindow({ + name: keyAsString.slice(0, 1).toUpperCase() + keyAsString.slice(1), + url: `/${keyAsString}`, + }) + }} + disabledKeys={currentCommand ? [currentCommand] : []} + color="primary" + > + + Download + Copy + Move + Delete + Sync + Bisync + Purge + Serve + Mount + + + + ) +} diff --git a/src/components/ConfigCreateDrawer.tsx b/src/components/ConfigCreateDrawer.tsx index 0815262..3e13936 100644 --- a/src/components/ConfigCreateDrawer.tsx +++ b/src/components/ConfigCreateDrawer.tsx @@ -7,16 +7,19 @@ import { Input, Switch, Textarea, + cn, } from '@heroui/react' import { Button } from '@heroui/react' +import { useMutation } from '@tanstack/react-query' import { sep } from '@tauri-apps/api/path' import { getCurrentWindow } from '@tauri-apps/api/window' import { message, open } from '@tauri-apps/plugin-dialog' import { mkdir, readTextFile, writeTextFile } from '@tauri-apps/plugin-fs' +import { platform } from '@tauri-apps/plugin-os' import { UploadIcon } from 'lucide-react' -import { useState } from 'react' +import { useMemo, useState } from 'react' import { getConfigPath } from '../../lib/rclone/common' -import { usePersistedStore } from '../../lib/store' +import { useHostStore } from '../../store/host' import type { ConfigFile } from '../../types/config' export default function ConfigCreateDrawer({ @@ -32,88 +35,65 @@ export default function ConfigCreateDrawer({ const [configContent, setConfigContent] = useState(null) const [isPasswordCommand, setIsPasswordCommand] = useState(false) - const [isSaving, setIsSaving] = useState(false) - const isEncrypted = configContent?.includes('RCLONE_ENCRYPT_V0:') + const isEncrypted = useMemo( + () => configContent?.includes('RCLONE_ENCRYPT_V0:'), + [configContent] + ) - async function handleCreate({ - label, - pass, - passCommand, - content, - isPasswordCommand, - isEncrypted, - }: { - label?: string - pass?: string - passCommand?: string - content: string | null - isPasswordCommand: boolean - isEncrypted: boolean - }) { - if (!label) { - await message('Label is required', { - title: 'Failed to save config', - kind: 'error', - okLabel: 'OK', - }) - return - } + const createConfigMutation = useMutation({ + mutationFn: async ({ + label, + pass, + passCommand, + content, + isPasswordCommand, + isEncrypted, + }: { + label: string + pass?: string + passCommand?: string + content: string + isPasswordCommand: boolean + isEncrypted: boolean + }) => { + const savedPass = isPasswordCommand ? undefined : pass + const savedPassCommand = isPasswordCommand ? passCommand : undefined - if (!content) { - await message('Content is required', { - title: 'Failed to save config', - kind: 'error', - okLabel: 'OK', - }) - return - } - - if (isEncrypted && isPasswordCommand && !passCommand) { - await message('Password command is required for encrypted configs', { - title: 'Failed to save config', - kind: 'error', - okLabel: 'OK', - }) - return - } - - setIsSaving(true) - - const savedPass = isPasswordCommand ? undefined : pass - const savedPassCommand = isPasswordCommand ? passCommand : undefined - - try { const generatedId = crypto.randomUUID() - const configPath = await getConfigPath({ id: generatedId, validate: false }) await mkdir(configPath.replace(sep() + 'rclone.conf', ''), { recursive: true, }) await writeTextFile(configPath, content) - console.log('[handleCreate] saved config to', configPath) + console.log('[createConfig] saved config to', configPath) - usePersistedStore.getState().addConfigFile({ + const newConfig = { id: generatedId, label, pass: savedPass, passCommand: savedPassCommand, - isEncrypted: isEncrypted, + isEncrypted, sync: undefined, - }) + } + useHostStore.getState().addConfigFile(newConfig) + + return true + }, + onSuccess: () => { onClose() - } catch (error) { - console.error('[handleCreate] failed to save config', error) + }, + onError: async (error) => { + console.error('[createConfig] failed to save config', error) await message(error instanceof Error ? error.message : 'An unknown error occurred', { title: 'Failed to save config', kind: 'error', okLabel: 'OK', }) - } - setIsSaving(false) - } + }, + }) return ( - + {(close) => ( <> Import Config -
    { - e.preventDefault() - handleCreate({ - label: config.label, - pass: config.pass, - passCommand: config.passCommand, - content: configContent, - isPasswordCommand: isPasswordCommand, - isEncrypted: - configContent?.includes('RCLONE_ENCRYPT_V0:') || false, - }) - }} - > +
    { setConfig({ ...config, label: value }) }} @@ -280,21 +251,6 @@ export default function ConfigCreateDrawer({ console.log(value) setConfigContent(value) }} - onKeyDown={(e) => { - //if it's tab key, add 2 spaces at the current text cursor position - if (e.key === 'Tab') { - e.preventDefault() - const text = e.currentTarget.value - const cursorPosition = e.currentTarget.selectionStart - const newText = - text.slice(0, cursorPosition) + - ' ' + - text.slice(cursorPosition) - e.currentTarget.value = newText - e.currentTarget.selectionStart = cursorPosition + 2 - e.currentTarget.selectionEnd = cursorPosition + 2 - } - }} autoCapitalize="off" autoComplete="off" autoCorrect="off" @@ -309,7 +265,7 @@ export default function ConfigCreateDrawer({ }} data-focus-visible="false" /> - +
    diff --git a/src/components/ConfigEditDrawer.tsx b/src/components/ConfigEditDrawer.tsx index 53e356b..e34d3d3 100644 --- a/src/components/ConfigEditDrawer.tsx +++ b/src/components/ConfigEditDrawer.tsx @@ -7,13 +7,16 @@ import { Input, Switch, Textarea, + cn, } from '@heroui/react' import { Button } from '@heroui/react' +import { useMutation } from '@tanstack/react-query' import { message } from '@tauri-apps/plugin-dialog' import { readTextFile, writeTextFile } from '@tauri-apps/plugin-fs' -import { useEffect, useState } from 'react' +import { platform } from '@tauri-apps/plugin-os' +import { startTransition, useCallback, useEffect, useMemo, useState } from 'react' import { getConfigPath } from '../../lib/rclone/common' -import { usePersistedStore } from '../../lib/store' +import { useHostStore } from '../../store/host' export default function ConfigEditDrawer({ id, @@ -24,7 +27,7 @@ export default function ConfigEditDrawer({ onClose: () => void isOpen: boolean }) { - const configFiles = usePersistedStore((state) => state.configFiles) + const configFiles = useHostStore((state) => state.configFiles) const initialConfig = configFiles.find((c) => c.id === id) const [configLabel, setConfigLabel] = useState(null) @@ -33,95 +36,74 @@ export default function ConfigEditDrawer({ const [configContent, setConfigContent] = useState(null) const [isPasswordCommand, setIsPasswordCommand] = useState(false) - const [isSaving, setIsSaving] = useState(false) - const isEncrypted = configContent?.includes('RCLONE_ENCRYPT_V0:') + const isEncrypted = useMemo( + () => configContent?.includes('RCLONE_ENCRYPT_V0:'), + [configContent] + ) - async function handleUpdate({ - label, - pass, - content, - passCommand, - isPasswordCommand, - isEncrypted, - }: { - label?: string - pass?: string - content?: string - passCommand?: string - isPasswordCommand?: boolean - isEncrypted?: boolean - }) { - if (!id) return + const updateConfigMutation = useMutation({ + mutationFn: async ({ + label, + pass, + content, + passCommand, + isPasswordCommand, + isEncrypted, + }: { + label: string + pass?: string + content: string + passCommand?: string + isPasswordCommand?: boolean + isEncrypted?: boolean + }) => { + if (!id) throw new Error('Config ID not found') - if (!label) { - await message('Label is required', { - title: 'Failed to save config', - kind: 'error', - okLabel: 'OK', - }) - return - } + const savedPass = isPasswordCommand ? undefined : pass + const savedPassCommand = isPasswordCommand ? passCommand : undefined - if (!content) { - await message('Content is required', { - title: 'Failed to save config', - kind: 'error', - okLabel: 'OK', - }) - return - } - - if (isEncrypted && isPasswordCommand && !passCommand) { - await message('Password command is required for encrypted configs', { - title: 'Failed to save config', - kind: 'error', - okLabel: 'OK', - }) - return - } - - setIsSaving(true) - - const savedPass = isPasswordCommand ? undefined : pass - const savedPassCommand = isPasswordCommand ? passCommand : undefined - - try { const configPath = await getConfigPath({ id: id, validate: true }) await writeTextFile(configPath, content) - usePersistedStore.getState().updateConfigFile(id, { - label, + useHostStore.getState().updateConfigFile(id, { + label: label, pass: savedPass, passCommand: savedPassCommand, - isEncrypted: isEncrypted, + isEncrypted: !!isEncrypted, }) + return true + }, + onSuccess: () => { onClose() - } catch (error) { - console.error('[handleUpdate] failed to save config', error) + }, + onError: async (error) => { + console.error('[updateConfig] failed to save config', error) await message(error instanceof Error ? error.message : 'An unknown error occurred', { title: 'Failed to save config', kind: 'error', okLabel: 'OK', }) - } - setIsSaving(false) - } + }, + }) - async function initializeConfig() { + const initializeConfig = useCallback(async () => { if (!initialConfig) { return } const configPath = await getConfigPath({ id: initialConfig.id!, validate: true }) const text = await readTextFile(configPath) - setConfigContent(text) - setConfigLabel(initialConfig.label) - setConfigPass(initialConfig.pass || null) - setConfigPassCommand(initialConfig.passCommand || null) - setIsPasswordCommand(initialConfig.passCommand !== null) - } + + startTransition(() => { + setConfigContent(text) + setConfigLabel(initialConfig.label) + setConfigPass(initialConfig.pass || null) + setConfigPassCommand(initialConfig.passCommand || null) + setIsPasswordCommand(initialConfig.passCommand !== null) + }) + }, [initialConfig]) useEffect(() => { if (isOpen && configContent === null && configLabel === null) { @@ -129,13 +111,14 @@ export default function ConfigEditDrawer({ } if (!isOpen) { - setConfigContent(null) - setConfigLabel(null) - setConfigPass(null) - setConfigPassCommand(null) - setIsPasswordCommand(false) + startTransition(() => { + setConfigContent(null) + setConfigLabel(null) + setConfigPass(null) + setConfigPassCommand(null) + setIsPasswordCommand(false) + }) } - // biome-ignore lint/correctness/useExhaustiveDependencies: }, [isOpen, configLabel, initializeConfig, configContent]) if (!id) { @@ -150,29 +133,19 @@ export default function ConfigEditDrawer({ onClose={onClose} hideCloseButton={true} > - + {(close) => ( <> Edit {configLabel} -
    { - e.preventDefault() - handleUpdate({ - label: configLabel || undefined, - pass: configPass || undefined, - content: configContent || undefined, - passCommand: configPassCommand || undefined, - isPasswordCommand: isPasswordCommand, - isEncrypted: - configContent?.includes('RCLONE_ENCRYPT_V0:') || false, - }) - }} - > +
    { setConfigLabel(value) }} @@ -259,21 +233,6 @@ export default function ConfigEditDrawer({ console.log(value) setConfigContent(value) }} - onKeyDown={(e) => { - //if it's tab key, add 2 spaces at the current text cursor position - if (e.key === 'Tab') { - e.preventDefault() - const text = e.currentTarget.value - const cursorPosition = e.currentTarget.selectionStart - const newText = - text.slice(0, cursorPosition) + - ' ' + - text.slice(cursorPosition) - e.currentTarget.value = newText - e.currentTarget.selectionStart = cursorPosition + 2 - e.currentTarget.selectionEnd = cursorPosition + 2 - } - }} autoCapitalize="off" autoComplete="off" autoCorrect="off" @@ -288,7 +247,7 @@ export default function ConfigEditDrawer({ }} data-focus-visible="false" /> - +
    diff --git a/src/components/ConfigSyncDrawer.tsx b/src/components/ConfigSyncDrawer.tsx index 68409d5..1f01f50 100644 --- a/src/components/ConfigSyncDrawer.tsx +++ b/src/components/ConfigSyncDrawer.tsx @@ -6,15 +6,18 @@ import { DrawerHeader, Input, Switch, + cn, } from '@heroui/react' import { Button } from '@heroui/react' +import { useMutation } from '@tanstack/react-query' import { sep } from '@tauri-apps/api/path' import { getCurrentWindow } from '@tauri-apps/api/window' import { message, open } from '@tauri-apps/plugin-dialog' import { exists, readTextFile } from '@tauri-apps/plugin-fs' +import { platform } from '@tauri-apps/plugin-os' import { UploadIcon } from 'lucide-react' import { useState } from 'react' -import { usePersistedStore } from '../../lib/store' +import { useHostStore } from '../../store/host' import type { ConfigFile } from '../../types/config' export default function ConfigSyncDrawer({ @@ -30,79 +33,52 @@ export default function ConfigSyncDrawer({ const [isPasswordCommand, setIsPasswordCommand] = useState(false) - const [isSaving, setIsSaving] = useState(false) - - async function handleCreate({ - label, - pass, - isEncrypted, - passCommand, - sync, - isPasswordCommand, - }: { - label?: string - sync?: string - isEncrypted?: boolean - pass?: string - passCommand?: string - isPasswordCommand?: boolean - }) { - if (!label) { - await message('Label is required', { - title: 'Failed to save config', - kind: 'error', - okLabel: 'OK', - }) - return - } - - if (!sync) { - await message('Path is required', { - title: 'Failed to save config', - kind: 'error', - okLabel: 'OK', - }) - return - } - - if (isEncrypted && isPasswordCommand && !passCommand) { - await message('Password command is required for encrypted configs', { - title: 'Failed to save config', - kind: 'error', - okLabel: 'OK', - }) - return - } - - setIsSaving(true) - - const savedPass = isPasswordCommand ? undefined : pass - const savedPassCommand = isPasswordCommand ? passCommand : undefined - const savedIsEncrypted = isEncrypted || false - - try { + const createSyncConfigMutation = useMutation({ + mutationFn: async ({ + label, + pass, + isEncrypted, + passCommand, + sync, + isPasswordCommand, + }: { + label: string + sync: string + isEncrypted?: boolean + pass?: string + passCommand?: string + isPasswordCommand?: boolean + }) => { + const savedPass = isPasswordCommand ? undefined : pass + const savedPassCommand = isPasswordCommand ? passCommand : undefined + const savedIsEncrypted = isEncrypted || false const generatedId = crypto.randomUUID() - usePersistedStore.getState().addConfigFile({ + const newConfig = { id: generatedId, label, isEncrypted: savedIsEncrypted, pass: savedPass, passCommand: savedPassCommand, sync, - }) + } + useHostStore.getState().addConfigFile(newConfig) + + return true + }, + onSuccess: () => { onClose() - } catch (error) { - console.error('[handleCreate] failed to save config', error) + }, + onError: async (error) => { + console.error('[createSyncConfig] failed to save config', error) await message(error instanceof Error ? error.message : 'An unknown error occurred', { title: 'Failed to save config', kind: 'error', okLabel: 'OK', }) - } - setIsSaving(false) - } + }, + }) return ( - + {(close) => ( <> Sync Config -
    { - e.preventDefault() - handleCreate({ - label: config.label, - pass: config.pass, - isEncrypted: config.isEncrypted, - passCommand: config.passCommand, - sync: config.sync, - isPasswordCommand: isPasswordCommand, - }) - }} - > +
    - +
    diff --git a/src/components/CronEditor.tsx b/src/components/CronEditor.tsx index c0e5f11..627c67f 100644 --- a/src/components/CronEditor.tsx +++ b/src/components/CronEditor.tsx @@ -2,7 +2,7 @@ import { Button, Input, Select, SelectItem } from '@heroui/react' import cronstrue from 'cronstrue' import { ClockIcon, XIcon } from 'lucide-react' import type React from 'react' -import { useEffect, useState } from 'react' +import { startTransition, useCallback, useEffect, useMemo, useState } from 'react' interface CronEditorProps { expression: string | null @@ -21,29 +21,35 @@ const DEFAULT_OPTIONS = ['*', '*/5', '*/10', '*/15', '*/30'] export default function CronEditor({ expression, onChange }: CronEditorProps) { const [cronExpression, setCronExpression] = useState(expression) - const [minute, hour, dayOfMonth, month, dayOfWeek] = (cronExpression || '* * * * *').split(' ') + const [minute, hour, dayOfMonth, month, dayOfWeek] = useMemo( + () => (cronExpression || '* * * * *').split(' '), + [cronExpression] + ) - const readableDescription = (() => { + const readableDescription = useMemo(() => { if (!cronExpression) return 'This task is not scheduled' let description: string try { description = cronstrue.toString(cronExpression) description += - '. Jobs trigger when the UI is running, if the active config is the same.' + '. Tasks are triggered when the UI is running, if the active config is the same.' } catch { description = 'Invalid cron expression' } return description - })() + }, [cronExpression]) - function handleFieldChange(field: string, value: string) { - const parts = (cronExpression || '* * * * *').split(' ') - const index = ['minute', 'hour', 'dayOfMonth', 'month', 'dayOfWeek'].indexOf(field) - if (index !== -1) { - parts[index] = value - setCronExpression(parts.join(' ')) - } - } + const handleFieldChange = useCallback( + (field: string, value: string) => { + const parts = (cronExpression || '* * * * *').split(' ') + const index = ['minute', 'hour', 'dayOfMonth', 'month', 'dayOfWeek'].indexOf(field) + if (index !== -1) { + parts[index] = value + setCronExpression(parts.join(' ')) + } + }, + [cronExpression] + ) useEffect(() => { onChange(cronExpression) @@ -67,6 +73,10 @@ export default function CronEditor({ expression, onChange }: CronEditorProps) { startContent={} isClearable={true} onClear={() => setCronExpression(null)} + autoCapitalize="off" + autoComplete="off" + autoCorrect="off" + spellCheck="false" />
    @@ -116,7 +126,10 @@ function CronField({ label, value, onChange, options }: CronFieldProps) { !options.includes(value) && value !== '*' && value !== 'custom' - setIsCustom(isCustom) + + startTransition(() => { + setIsCustom(isCustom) + }) }, [value, options]) const handleCustomChange = (e: React.ChangeEvent) => { @@ -141,6 +154,10 @@ function CronField({ label, value, onChange, options }: CronFieldProps) { } + autoCapitalize="off" + autoComplete="off" + autoCorrect="off" + spellCheck="false" /> ) : ( + setForm((prev) => ({ ...prev, name: value })) + } + isRequired={true} + autoCapitalize="off" + autoComplete="off" + autoCorrect="off" + spellCheck="false" + /> + + setForm((prev) => ({ ...prev, url: value })) + } + isRequired={true} + autoCapitalize="off" + autoComplete="off" + autoCorrect="off" + spellCheck="false" + type="url" + /> + + +
    +

    + Auth +

    + + setForm((prev) => ({ ...prev, authUser: value })) + } + autoComplete="off" + autoCapitalize="none" + autoCorrect="off" + spellCheck="false" + /> + + setForm((prev) => ({ ...prev, authPassword: value })) + } + type="password" + autoComplete="off" + autoCapitalize="none" + autoCorrect="off" + spellCheck="false" + /> +
    +
    + + + + + + + )} +
    +
    + ) +} diff --git a/src/components/JobDetailsDrawer.tsx b/src/components/JobDetailsDrawer.tsx new file mode 100644 index 0000000..3b0220f --- /dev/null +++ b/src/components/JobDetailsDrawer.tsx @@ -0,0 +1,255 @@ +import { + Alert, + Button, + Chip, + Drawer, + DrawerBody, + DrawerContent, + DrawerHeader, + Progress, + Spinner, + Tooltip, + cn, +} from '@heroui/react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { message } from '@tauri-apps/plugin-dialog' +import { platform } from '@tauri-apps/plugin-os' +import { SquareIcon } from 'lucide-react' +import { useMemo } from 'react' +import { formatBytes } from '../../lib/format' +import notify from '../../lib/notify' +import rclone from '../../lib/rclone/client' +import type { JobItem } from '../../types/jobs' + +export default function JobDetailsDrawer({ + isOpen, + onClose, + selectedJob, +}: { + isOpen: boolean + onClose: () => void + selectedJob: JobItem +}) { + const queryClient = useQueryClient() + + const jobStatusQuery = useQuery({ + queryKey: ['jobs', 'status', selectedJob.id], + queryFn: async () => + rclone('/job/status', { + params: { + query: { + jobid: selectedJob.id, + }, + }, + }), + enabled: !!selectedJob.id, + refetchInterval: 1000, + }) + + const mainError = useMemo(() => jobStatusQuery.data?.error || null, [jobStatusQuery.data]) + + const otherErrors = useMemo( + () => + !!jobStatusQuery.data?.output && + typeof jobStatusQuery.data?.output === 'object' && + 'results' in jobStatusQuery.data.output && + Array.isArray(jobStatusQuery.data.output.results) + ? (jobStatusQuery.data.output.results + .map((item: any) => { + if (!item.error) return null + return { error: item.error, input: item.input } + }) + .filter(Boolean) as { error: string; input: object }[]) + : [], + [jobStatusQuery.data] + ) + + const transferredQuery = useQuery({ + queryKey: ['jobs', 'transferring', selectedJob.id], + queryFn: async () => + rclone('/core/transferred', { + params: { + query: { + group: `job/${selectedJob.id}`, + }, + }, + }), + enabled: !!selectedJob.id, + refetchInterval: 1000, + }) + + const jobGroupStatsQuery = useQuery({ + queryKey: ['jobs', 'group', 'stats', selectedJob.id], + queryFn: async () => + rclone('/core/stats', { + params: { + query: { + group: `job/${selectedJob.id}`, + }, + }, + }), + enabled: !!selectedJob.id, + refetchInterval: 1000, + }) + + const stopJobMutation = useMutation({ + mutationFn: async (jobId: number) => { + await rclone('/job/stopgroup', { + params: { + query: { + group: `job/${jobId}`, + }, + }, + }) + }, + onSuccess: async () => { + queryClient.refetchQueries({ queryKey: ['transfers', 'list', 'all'] }) + await notify({ + title: 'Job stopped', + body: 'It will still appear as active, with declining transfer speeds, until rclone cleans it up', + }) + }, + onError: async (error) => { + console.error('Failed to stop job:', error) + await message(error instanceof Error ? error.message : 'Unknown error occurred', { + title: 'Could not stop job', + kind: 'error', + }) + }, + }) + + const transferred = useMemo( + () => transferredQuery.data?.transferred || [], + [transferredQuery.data] + ) + const transferring = useMemo( + () => jobGroupStatsQuery.data?.transferring || [], + [jobGroupStatsQuery.data] + ) + + return ( + + + + Job Details #{selectedJob.id}{' '} + {selectedJob.type === 'inactive' ? ( + + FINISHED + + ) : ( + + + + )} + + + {mainError || otherErrors.length > 0 ? ( + + {mainError} + {otherErrors.length > 0 ? ( +
    +                                    {JSON.stringify(otherErrors, null, 2)}
    +                                
    + ) : null} +
    + ) : null} + +
    +
    +

    Transferring

    + {jobGroupStatsQuery.isLoading ? ( + + ) : ( + `${transferring.length} items` + )} +
    + + {!jobGroupStatsQuery.isLoading && transferring.length === 0 ? ( +

    No items transferring

    + ) : null} + + {transferring.map((item, itemIndex) => { + // biome-ignore lint/style/useExplicitLengthCheck: + const size = item.size ? formatBytes(item.size) : '0 B' + const bytes = item.bytes ? formatBytes(item.bytes) : '0 B' + const speed = item.speed ? formatBytes(item.speed) : '0 B/s' + return ( +
    +

    {item.name}

    +

    + {speed}/s +

    + + + +
    + ) + })} +
    + +
    +
    +
    +

    Transferred

    + {transferredQuery.isLoading ? ( + + ) : ( + `${transferred.length} items` + )} +
    + + {!transferredQuery.isLoading && transferred.length === 0 ? ( +

    No items transferred

    + ) : null} + + {transferred.map((item, itemIndex) => { + return ( +
    +

    {item.name}

    + {item.error ? ( +

    {item.error}

    + ) : item.completed_at ? ( +

    {new Date(item.completed_at).toLocaleString()}

    + ) : null} + {/*

    {item.what}

    */} +
    + ) + })} +
    +
    +
    +
    +
    + ) +} diff --git a/src/components/OperationWindowContent.tsx b/src/components/OperationWindowContent.tsx new file mode 100644 index 0000000..fc4aed1 --- /dev/null +++ b/src/components/OperationWindowContent.tsx @@ -0,0 +1,19 @@ +import { cn } from '@heroui/react' +import { platform } from '@tauri-apps/plugin-os' + +export default function OperationWindowContent({ + children, + className, +}: { children: React.ReactNode; className?: string }) { + return ( +
    + {children} +
    + ) +} diff --git a/src/components/OperationWindowFooter.tsx b/src/components/OperationWindowFooter.tsx new file mode 100644 index 0000000..708af1a --- /dev/null +++ b/src/components/OperationWindowFooter.tsx @@ -0,0 +1,7 @@ +export default function OperationWindowFooter({ children }: { children: React.ReactNode }) { + return ( +
    + {children} +
    + ) +} diff --git a/src/components/OptionsSection.tsx b/src/components/OptionsSection.tsx index 3f7ed7f..659aa81 100644 --- a/src/components/OptionsSection.tsx +++ b/src/components/OptionsSection.tsx @@ -1,40 +1,26 @@ import { Chip, Textarea, Tooltip } from '@heroui/react' import { LockKeyholeIcon, LockOpenIcon } from 'lucide-react' -import { startTransition, useEffect, useState } from 'react' +import { startTransition, useCallback, useEffect, useState } from 'react' import { replaceSmartQuotes } from '../../lib/format' export default function OptionsSection({ optionsJson, setOptionsJson, globalOptions, - getAvailableOptions, + availableOptions, isLocked, setIsLocked, }: { optionsJson: string setOptionsJson: (value: string) => void - globalOptions: any[] - getAvailableOptions: () => Promise + globalOptions: Record + availableOptions: any[] isLocked?: boolean setIsLocked?: (value: boolean) => void }) { - const [availableOptions, setAvailableOptions] = useState([]) - const [options, setOptions] = useState({}) const [isJsonValid, setIsJsonValid] = useState(true) - useEffect(() => { - getAvailableOptions() - .then((flags) => { - return flags - }) - .then((flags) => { - startTransition(() => { - setAvailableOptions(flags) - }) - }) - }, [getAvailableOptions]) - useEffect(() => { startTransition(() => { try { @@ -47,16 +33,19 @@ export default function OptionsSection({ }) }, [optionsJson]) - function isOptionAdded(option: string) { - return options[option] !== undefined - } + const isOptionAdded = useCallback( + (option: string) => { + return options[option] !== undefined + }, + [options] + ) return ( -
    +