can’t innovate my ass 

Signed-off-by: FTCHD <144691102+FTCHD@users.noreply.github.com>
This commit is contained in:
FTCHD
2025-12-07 18:42:44 +01:00
committed by GitHub
parent 4254efe732
commit 7a62659323
243 changed files with 20668 additions and 11553 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 887 KiB

+6 -13
View File
@@ -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: "<img width=\"1645\" height=\"626\" alt=\"Rclone UI\" src=\"https://github.com/user-attachments/assets/539c0b90-fee1-487e-879a-6a9168a44508\" />\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) &nbsp; [AppImage](https://github.com/rclone-ui/rclone-ui/releases/download/v__VERSION__/Rclone.UI_amd64.AppImage) &nbsp; [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) &nbsp; [AppImage](https://github.com/rclone-ui/rclone-ui/releases/download/v__VERSION__/Rclone.UI_aarch64.AppImage) &nbsp; [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: "<img width=\"1645\" height=\"626\" alt=\"Rclone UI\" src=\"https://github.com/user-attachments/assets/539c0b90-fee1-487e-879a-6a9168a44508\" />\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) &nbsp; [AppImage](https://github.com/rclone-ui/rclone-ui/releases/download/v__VERSION__/Rclone.UI_amd64.AppImage) &nbsp; [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) &nbsp; [AppImage](https://github.com/rclone-ui/rclone-ui/releases/download/v__VERSION__/Rclone.UI_aarch64.AppImage) &nbsp; [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
+56 -20
View File
@@ -1,3 +1,8 @@
<img src=".github/rclone-mobile.png" alt="Rclone Mobile" width="100%">
<br />
<br />
<h1 align="center">
<a href="https://rcloneui.com">
<img src="./public/banner.png" alt="Rclone UI" width="100%">
@@ -50,28 +55,50 @@
<br />
## 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
<a href="https://get.rcloneui.com/showcase">
<img src=".github/rclone-video.png" alt="The GUI for Rclone">
</a>
<img src=".github/rclone-1.png" alt="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.
<img src=".github/rclone-2.png" alt="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 @@
</a>
## 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. Weve 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!**
<br />
+50 -20
View File
@@ -50,28 +50,49 @@
<br />
## 功能
### 🖥️ 跨平台:Windows、Linux、macOSx64、Arm、Apple Silicon
### ✅ 可配合你现有的 rclone 安装使用(也可为你安装),并在你确认后保持 up-to-date
### 🔗 添加 / 编辑 / 移除 Remotes
### ⚡️ Sync、Copy、Move、Delete、Purge、Serve、Mount 等
### ⭐️ 为每个 Remote、每种操作(Mount、Sync、Copy 等)分别设置默认参数(flags),节省时间
### 🎛️ 按需为每次操作自定义参数(flags)
### 📚 内置每个参数的说明,无需再 Google 查询!
### 📂 文件浏览器
### 📡 代理
<a href="https://get.rcloneui.com/showcase">
<img src=".github/rclone-video.png" alt="The GUI for Rclone">
</a>
<img src=".github/rclone-1.png" alt="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
```
<img src=".github/rclone-2.png" alt="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 @@
</a>
## 参与贡献
欢迎你的贡献!请提交一个 [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 可获终身许可证!**
<br />
+50 -20
View File
@@ -50,28 +50,49 @@
<br />
## 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
<a href="https://get.rcloneui.com/showcase">
<img src=".github/rclone-video.png" alt="The GUI for Rclone">
</a>
<img src=".github/rclone-1.png" alt="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 <tt>pass</tt> oder <tt>command</tt>)
### ✍️ 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
```
<img src=".github/rclone-2.png" alt="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 @@
</a>
## 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!**
<br />
+50 -20
View File
@@ -50,28 +50,49 @@
<br />
## 機能
### 🖥️ Cross-platformWindows・Linux・macOSx64 / Arm / Apple Silicon
### ✅ 既存の rclone 環境でそのまま動作(必要ならインストールも可能)。アップデートはあなたの承認のもとで適用
### 🔗 Remote の追加/編集/削除
### ⚡️ Sync・Copy・Move・Delete・Purge・Serve・Mount など
### ⭐️ 操作ごと(Mount/Sync/Copy など)に Remote 単位で default flags を設定して時短
### 🎛️ 実行時にケースバイケースで flags(パラメータ)をカスタマイズ
### 📚 各 flag のドキュメントを内蔵。ググる手間はもう不要!
### 📂 File Browser
### 📡 Proxy
<a href="https://get.rcloneui.com/showcase">
<img src=".github/rclone-video.png" alt="The GUI for Rclone">
</a>
<img src=".github/rclone-1.png" alt="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
```
<img src=".github/rclone-2.png" alt="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 @@
</a>
## コントリビューション
貢献は大歓迎です!気軽に [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 にはライフタイムライセンスを贈呈!**
<br />
+51 -21
View File
@@ -50,28 +50,49 @@
<br />
## Features
### 🖥️ Runs on all decks — Windows, Linux, macOS (x64, Arm, an Apple Silicon)
### ✅ Sails with yer existing rclone (or well 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
<a href="https://get.rcloneui.com/showcase">
<img src=".github/rclone-video.png" alt="The GUI for Rclone">
</a>
<img src=".github/rclone-1.png" alt="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)
### 📊 Ships Log fer Jobs (spy all background maneuvers)
### 🚀 Weigh anchor on boot (autostart) an auto-mount the drives
### 🔒 Guard the Captains Quarters (Settings) with a secret word
### 👁️ Keep commands or Remotes hidden from the crows-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
```
<img src=".github/rclone-2.png" alt="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 @@
</picture>
</a>
## 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 lets 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!**
<br />
+50 -20
View File
@@ -50,28 +50,49 @@
<br />
## 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 Remotea 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
<a href="https://get.rcloneui.com/showcase">
<img src=".github/rclone-video.png" alt="The GUI for Rclone">
</a>
<img src=".github/rclone-1.png" alt="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
```
<img src=".github/rclone-2.png" alt="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 @@
</a>
## 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ą!**
<br />
+50 -20
View File
@@ -50,28 +50,49 @@
<br />
## 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
<a href="https://get.rcloneui.com/showcase">
<img src=".github/rclone-video.png" alt="The GUI for Rclone">
</a>
<img src=".github/rclone-1.png" alt="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
```
<img src=".github/rclone-2.png" alt="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 @@
</a>
## 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!**
<br />
+50 -20
View File
@@ -50,28 +50,49 @@
<br />
## 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
<a href="https://get.rcloneui.com/showcase">
<img src=".github/rclone-video.png" alt="The GUI for Rclone">
</a>
<img src=".github/rclone-1.png" alt="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
```
<img src=".github/rclone-2.png" alt="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 @@
</a>
## 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!**
<br />
+4 -2
View File
@@ -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
+42
View File
@@ -0,0 +1,42 @@
<h1 align="center">
<a href="https://rcloneui.com">
<img src="https://raw.githubusercontent.com/rclone-ui/rclone-ui/main/public/banner.png" alt="Rclone UI" width="100%">
</a>
<br>
<a href="https://rcloneui.com">
The cross-platform GUI for Rclone
</a>
</h1>
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)
+124
View File
@@ -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)
})
+37
View File
@@ -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;
}
}
+163
View File
@@ -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)
}
+40
View File
@@ -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);
});
}
+89
View File
@@ -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'
}
+23
View File
@@ -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"
}
}
+17 -1
View File
@@ -1,10 +1,26 @@
<!doctype html>
<html lang="en" class="bg-neutral-900 dark">
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Rclone UI</title>
<script>
document.documentElement.classList.toggle(
"dark",
localStorage.theme === "dark" ||
(!("theme" in localStorage) && window.matchMedia("(prefers-color-scheme: dark)").matches),
);
</script>
<script>
if (
window.location.pathname.startsWith('/toolbar') ||
window.location.pathname.startsWith('/startup') ||
window.location.pathname.startsWith('/onboarding')
) {
document.documentElement.classList.add('!bg-transparent')
}
</script>
</head>
<body class="select-none">
+19
View File
@@ -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' })
}
}
+127
View File
@@ -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<string, { Name: string; Groups?: string }[]>
) {
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<string, unknown>
return Object.keys(parsed).length
} catch {
return 0
}
}
export function groupByCategory(
flags: Record<string, FlagValue>,
allFlags: Record<string, { Name: string; Groups?: string }[]>
) {
const collectedFlags = {
mount: {} as Record<string, FlagValue>,
config: {} as Record<string, FlagValue>,
vfs: {} as Record<string, FlagValue>,
filter: {} as Record<string, FlagValue>,
copy: {} as Record<string, FlagValue>,
sync: {} as Record<string, FlagValue>,
serve: {
...SERVE_TYPES.reduce(
(acc, type) => {
acc[type] = {}
return acc
},
{} as Record<(typeof SERVE_TYPES)[number], Record<string, FlagValue>>
),
},
}
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<typeof collectedFlags, 'serve'>] = {
...collectedFlags[category.category as keyof Omit<typeof collectedFlags, 'serve'>],
[k]: v,
}
}
return collectedFlags
}
+122 -4
View File
@@ -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',
}
}
+81
View File
@@ -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,
}
}
+87
View File
@@ -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
}
+3 -3
View File
@@ -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))
-707
View File
@@ -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,
})
}
+6
View File
@@ -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',
})
}
}
+27
View File
@@ -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
+1097 -936
View File
File diff suppressed because it is too large Load Diff
+382
View File
@@ -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<string>
activeConfig: ConfigFile
configPath: string
configDirectory: string
env: Record<string, string>
flavour: 'system' | 'internal'
}
export async function promptForConfigPassword(message: string) {
console.log('[promptForConfigPassword] message:', message)
try {
const result = await invoke<string | null>('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<string, string>
): 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<string, string>,
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<string, string>,
autoPromptForPassword = true
): Promise<RcloneCliCommandContext> {
console.log('[createRcloneCliCommand] creating rclone CLI command with args:', args)
const env: Record<string, string> = {}
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<void>((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)
}
}
+110
View File
@@ -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> = T extends OpenApiClient<infer P, any> ? P : never
type Paths = ClientPaths<RCDClient>
type InitParam<Init> = OpenApiRequiredKeysOf<Init> extends never
? [(Init & { [key: string]: unknown })?]
: [Init & { [key: string]: unknown }]
export default async function rclone<
Path extends OpenApiClientPathsWithMethod<RCDClient, 'post'>,
Init extends OpenApiMaybeOptionalInit<Paths[Path], 'post'> = OpenApiMaybeOptionalInit<
Paths[Path],
'post'
>,
>(
path: Path,
...init: InitParam<Init>
): Promise<OpenApiMethodResponse<RCDClient, 'post', Path, Init>> {
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<OpenApiMaybeOptionalInit<Paths[Path], 'post'>>)
)
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<typeof client, 'post', Path, Init>
}
+19 -17
View File
@@ -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<string, string | number | boolean | string[]>) {
export function parseRcloneOptions(options: Record<string, FlagValue>) {
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
}
+160 -66
View File
@@ -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: <FileIcon />,
// titleColor: 'text-foreground',
// indicatorColor: 'text-foreground-500',
// },
// {
// id: 'copy',
// name: 'Copy',
// icon: <CopyIcon />,
// titleColor: 'text-primary-400',
// indicatorColor: 'text-primary-300',
// },
// {
// id: 'move',
// name: 'Move',
// icon: <MoveIcon />,
// titleColor: 'text-primary-400',
// indicatorColor: 'text-primary-300',
// },
// {
// id: 'delete',
// name: 'Delete',
// icon: <TrashIcon />,
// titleColor: 'text-danger-400',
// indicatorColor: 'text-danger-300',
// },
// {
// id: 'sync',
// name: 'Sync',
// icon: <ArrowRightLeftIcon />,
// titleColor: 'text-success-300',
// indicatorColor: 'text-success-300',
// },
// {
// id: 'bisync',
// name: 'Bisync',
// icon: <ArrowRightLeftIcon />,
// titleColor: 'text-primary-400',
// indicatorColor: 'text-primary-300',
// },
// {
// id: 'mount',
// name: 'Mount',
// icon: <HardDriveIcon />,
// titleColor: 'text-secondary-500',
// indicatorColor: 'text-secondary-400',
// },
// {
// id: 'purge',
// name: 'Purge',
// icon: <Trash2Icon />,
// titleColor: 'text-warning-300',
// indicatorColor: 'text-warning-300',
// },
// {
// id: 'serve',
// name: 'Serve',
// icon: <ServerIcon />,
// titleColor: 'text-cyan-500',
// indicatorColor: 'text-cyan-300',
// },
// ] as const
+99 -102
View File
@@ -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<boolean>('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<string>('test_proxy_connection', { proxy_url: persistedState.proxy.url })
await invoke<string>('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<string | null> - The password entered by the user, or null if cancelled
*/
async function promptForConfigPassword(configLabel: string): Promise<string | null> {
console.log('[promptForConfigPassword] prompting for config:', configLabel)
try {
const result = await invoke<string | null>('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
}
}
-34
View File
@@ -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
}
+59
View File
@@ -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',
},
}
-283
View File
@@ -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<string, any>
vfsDefaults?: Record<string, any>
filterDefaults?: Record<string, any>
copyDefaults?: Record<string, any>
moveDefaults?: Record<string, any>
syncDefaults?: Record<string, any>
configDefaults?: Record<string, any>
serveDefaults?: Record<(typeof SERVE_TYPES)[number], Record<string, any>>
bisyncDefaults?: Record<string, any>
remoteDefaults?: Record<string, any>
}
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<string, RemoteConfig>
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<ScheduledTask>) => 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<ConfigFile>) => void
lastSkippedVersion: string | undefined
hideStartup: boolean
themeV2: {
tray: 'light' | 'dark' | undefined
}
}
const getStorage = (store: LazyStore): StateStorage => ({
getItem: async (name: string): Promise<string | null> => {
console.log('getItem', { name })
return (await store.get(name)) || null
},
setItem: async (name: string, value: string): Promise<void> => {
console.log('setItem', { name, value })
await store.set(name, value)
await store.save()
},
removeItem: async (name: string): Promise<void> => {
console.log('removeItem', { name })
await store.delete(name)
await store.save()
},
})
export const useStore = create<State>()(
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<PersistedState>()(
persist(
(set) => ({
remoteConfigList: {},
setRemoteConfig: (remote: string, config: Record<string, any>) =>
set((state) => ({
remoteConfigList: { ...state.remoteConfigList, [remote]: config },
})),
mergeRemoteConfig: (remote: string, config: Record<string, any>) =>
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<ScheduledTask>) =>
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<ConfigFile>) =>
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()
})
+146 -128
View File
@@ -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()
}
},
})
+9 -145
View File
@@ -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 })
}
+445 -231
View File
@@ -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<void> {
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<boolean> => {
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<ReturnType<typeof initRclone>> | 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<boolean>('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)
+504 -17
View File
@@ -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",
+24 -4
View File
@@ -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",
+44 -17
View File
@@ -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 = '<div>Error: No URL provided.</div>';
}
@@ -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);
}
});
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 955 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 836 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Vendored
+1
View File
@@ -0,0 +1 @@
import '@total-typescript/ts-reset'
+8
View File
@@ -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
])
+204
View File
@@ -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] <path>
Options:
-X, --method <method> HTTP method to use (default: POST)
-b, --base <url> Base URL (default: http://localhost:5572)
-q, --query <key=value> Query parameter to append (repeatable)
-d, --data <json> JSON payload string
--data-file <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)
})
+230 -92
View File
@@ -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",
]
+9 -2
View File
@@ -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" }
+10 -42
View File
@@ -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"
]
}
+184
View File
@@ -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<WebviewWindow, tauri::Error> {
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::<f64>(scale_factor);
let logical_position = physical_position.to_logical::<f64>(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, String> {
shortcut.parse::<Shortcut>().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(())
}
+250
View File
@@ -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<f64>,
height: Option<f64>,
) -> 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<Vec<String>>) -> 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<Vec<String>>) -> 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(())
}
+4 -4
View File
@@ -41,7 +41,7 @@
<li>✅ File Browser 2.0</li>
<li>✅ Proxy support</li>
<li>✅ Sync, Download, Copy, Mount, Serve, Move, and more</li>
<li>Jobs (see all background operations)</li>
<li>Transfers (see all background operations)</li>
<li>✅ Set default flags for each remote separately, based on the operation (mount, copy, etc)</li>
<li>✅ Customize the flags when running an operation on a case by case basis (on top of defaults)</li>
<li>✅ Set a password for the Settings panel</li>
@@ -114,10 +114,10 @@
</screenshots>
<releases>
<release version="2.8.1" date="2025-11-11">
<url type="details">https://github.com/rclone-ui/rclone-ui/releases/tag/v2.8.1</url>
<release version="3.0.0" date="2025-12-06">
<url type="details">https://github.com/rclone-ui/rclone-ui/releases/tag/v3.0.0</url>
<description>
<p>2.8.1 Release</p>
<p>3.0.0 Release</p>
<ul>
<li>Improved Linux stability and overall performance.</li>
</ul>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 431 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 531 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 656 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 818 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 946 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 848 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 677 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 665 B

Some files were not shown because too many files have changed in this diff Show More