Skip to content
Merged

Dev #335

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 22 additions & 18 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,30 +4,34 @@ All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]
## [0.3.5] - 2026-10-29

### Changed
### Added

- **Per-Backend Quick Runs and Workflows**: Quick Runs and Workflows now belong to the selected backend, like its remotes and backend settings. Previously a single list was shared by every backend, so cron, watch, and auto-start tasks ran on whichever backend happened to be active and could point at remotes that backend does not have. Existing Quick Runs and Workflows become the Local backend's. A new backend starts empty unless **Copy Quick Runs & Workflows** names a source in the Add Backend dialog, and existing remote backends start empty on their first switch. The Quick Run and Workflow lists reload when the backend changes.
- **Auto-Start Delay**: Profiles and Quick Runs with auto-start on can now wait a set number of seconds after the app starts, which helps when the network, a VPN, or another mount is not ready yet. Off by default.

### Changed

- **Workflow Engine Structure**: The workflow engine, previously one 4,800-line file, is split into modules for scheduling, per-run state, cancellation, active runs, job-event triggers, and one handler per node kind. Nodes are dispatched on a `NodeKind` enum instead of matching type strings, so a missing handler is a compile error; stored workflows keep their `type` string, so newer node types from other versions still round-trip. Two engine tests that re-implemented the code they claimed to test now call the real functions.
- **Event Delivery**: Backend event subscribers now share one copy of each event instead of the bridge deep-copying its payload on every emit, which mattered most for large workflow outputs.
- **Workflow Engine Performance**: Running a workflow no longer copies the whole definition, its nodes, and its edges for every node; nodes and edges are looked up by index, progress is built from the running steps only, and node outputs are kept once and sent to the interface without extra copies. Waiting on a job reads a light status instead of copying its statistics every 250 ms, delays and schedule waits sleep instead of polling a flag (a schedule wait still rechecks the clock every 30 seconds so it survives suspend), `file_exists` conditions no longer block the runtime, and finishing jobs no longer reread every workflow from storage to find job-event triggers.
- **Faster Startup**: Removed repeated loading of caches and settings at startup. The wait for the rclone engine now starts at 25 ms and backs off instead of always sleeping 500 ms.
- **Per-Backend Quick Runs and Workflows**: Quick Runs and Workflows now belong to the selected backend, like its remotes and settings. Existing ones become the Local backend's; a new backend starts empty unless **Copy Quick Runs & Workflows** names a source in the Add Backend dialog.
- **Workflow Engine**: Split the 4,800-line workflow engine into modules with one handler per node kind, and reduced copying and polling while a workflow runs. Stored workflows are unchanged.

### Fixed

- **Workflow Messages Are Translated**: Workflow run messages, progress titles, step errors, and the execution log lines are now translated in all ten languages instead of always appearing in English. Step errors reach the interface as translation keys and are translated when displayed, so they follow the language setting. Check and transfer summaries and the Check report (`{{prev.summary}}`, `{{prev.report}}`) are rendered in the current language for use in notifications. Starting a workflow that is already running now shows a readable message in the execution log. Workflow validation messages, default texts of Stop, Audit Log, and Notification steps, and dry-run simulation messages follow the language setting as well.
- **Workflow Run Reliability**: A workflow can only run once at a time on its backend (the same id on another backend is a separate workflow); a second start (for example a cron tick while a long run is still waiting) is refused instead of overwriting the first run's tracking, which made it unstoppable and invisible to the vault-lock check. A node that crashes now fails its own step instead of leaving the run waiting forever. Finishing a run no longer rewrites the whole stored workflow, so edits made while it was running are kept and a workflow deleted meanwhile stays deleted. Very large delay values no longer overflow. Fixes #312.
- **Workflow Stop and Cancel Semantics**: A Stop node now only ends scheduling; steps already running in parallel finish instead of being aborted and failing the run. Stopping a workflow interrupts waits and delays immediately rather than within a polling interval, and a run you stopped is reported as stopped rather than failed.
- **Workflows Across a Backend Switch**: A running workflow keeps the Quick Runs it names even if the selected backend changes before they start, with or without the configuration vault.
- **Workflow Mount Steps**: A Mount step only reuses an existing mount that is its own or the same remote at the same mount point; a different remote already mounted there is now reported as a conflict instead of being treated as success. Mount and Serve steps record a per-run execution id instead of the workflow id.
- **Workflow Failures No Longer Pass Silently**: An unsupported node type, an unknown condition operator, or a job that disappeared from the job list now fail the step instead of reporting success or comparing for equality.
- **Workflow Job Event Triggers**: Only jobs of the selected backend start workflows, since each backend has its own. Jobs are now recognized by their start time as well as their id, so job ids reused after an rclone restart still trigger workflows, and the oldest entries are forgotten first instead of clearing the whole list at once, which could trigger a workflow twice.
- **Damaged Workflow or Quick Run Records**: One unreadable stored workflow or Quick Run no longer hides all the others or breaks scheduling; it is skipped and logged.
- **Backend Names and Profile Collisions**: Adding a backend whose name differs from an existing backend only by case, or that matches Local's `default` profile, is now rejected. On Windows and macOS those names share one settings folder, so removing one backend could delete the other's settings. Saved backends that collide are skipped at startup with a warning instead of being silently ignored.

- **Legacy Default Profile References**: Mount, sync, copy, and other operations no longer fail with `Invalid rclone flags detected: default` for remotes saved by 0.3.3 or earlier. Those versions stored the lowercase `default` profile name, and an absent Default profile (with no saved options) is now matched case-insensitively for runtime remote, VFS, filter, and backend profiles. Fixes #330.
- **Profile Resolution Consistency**: Scheduled and workflow runs now resolve a legacy `default` profile the same way as saving and manual runs, instead of silently dropping its options. Malformed `app` settings in a profile (for example a non-numeric `watchDelay`) are now reported as errors rather than silently discarding the profile's schedule and linked profiles. The Remote detail panel also finds a legacy lowercase Default profile.
- **Startup Stopped by a Missing Watch Folder**: A file watcher that could not start, such as one on an unplugged drive, or a cron scheduler error no longer aborts the rest of app startup.
- **Android Background Crash**: The app no longer crashes after about six hours in the background. The background service now uses the special-use foreground type, which Android 15 does not limit.
- **Android Background Reliability**: Added a banner to exempt the app from battery optimization and a notification when Android ends the app while keep-alive was running. The wake lock is renewed every 30 minutes, and automatic cloud backup no longer uploads `rclone.conf`.
- **Android Background Service and Updates**: With "Run in Background" off, the service now stays up while jobs, mounts, or serves are running. In-app updates work again on Android 9, 10, and some Android 13 builds.
- **Android File Manager**: Saving shorter content over a file no longer leaves the old ending, and creating a name that already exists picks "name (1)". Folders that fail to load show an error, browsing remotes no longer blocks the system, and mounted remotes are no longer polled in the background. Shared text is saved as a file instead of being dropped.
- **Android Release Build**: Release shrinking removed methods Rust calls through JNI, so the file manager roots, metered-connection banner, `content://` downloads, and opening folders in the system file browser did not work in the shipped APK.
- **Lower Background Activity**: While the window is hidden, transfer statistics are requested every 11 seconds instead of every second.
- **Workflow Messages Are Translated**: Workflow run messages, step errors, and execution log lines now follow the language setting in all ten languages.
- **Workflow Run Reliability**: A workflow can only run once at a time on its backend, a crashing node fails its own step, and finishing a run no longer overwrites edits made meanwhile. Fixes #312.
- **Workflow Stop and Cancel**: A Stop node only ends scheduling, stopping interrupts waits immediately, and a stopped run is reported as stopped rather than failed.
- **Workflow Steps and Triggers**: Mount steps report a conflict when a different remote is mounted at the same point, unsupported nodes and unknown operators fail the step instead of passing, and only jobs of the selected backend start workflows. A running workflow keeps its Quick Runs across a backend switch.
- **Damaged Workflow or Quick Run Records**: One unreadable stored record is skipped and logged instead of hiding all the others.
- **Backend Names and Profile Collisions**: Adding a backend whose name differs from another only by case, or matches Local's `default` profile, is rejected, since removing one could delete the other's settings on Windows and macOS.
- **Legacy Default Profile References**: Operations no longer fail with `Invalid rclone flags detected: default` for remotes saved by 0.3.3 or earlier, and scheduled and workflow runs resolve the legacy `default` profile like manual runs. Fixes #330.

## [0.3.4] - 2026-10-26

Expand Down
8 changes: 4 additions & 4 deletions backend/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion backend/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ edition = "2024"

[workspace.dependencies]
# Versions shared by core, server and desktop; members add their own features.
rcman = { version = "0.2.6", default-features = false }
rcman = { version = "0.2", default-features = false }
async-trait = "0.1"
base64 = "0.23"
clap = { version = "4", features = ["derive", "env"] }
Expand Down
136 changes: 38 additions & 98 deletions backend/core/librclone_patches/saf_vfs_patch.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ import "C"

import (
"context"
"crypto/x509"
"encoding/base64"
"errors"
"fmt"
"io"
"os"
"path"
"path/filepath"
"strings"
"sync"
Expand All @@ -28,10 +28,12 @@ import (
"github.com/rclone/rclone/vfs/vfscommon"
)

// Handles live until the documents provider closes them, which Android guarantees once the
// client's file descriptor is closed; closing an idle one early broke paused media and could
// upload a half-written file.
type vfsHandleEntry struct {
handle vfs.Handle
vfs *vfs.VFS
lastAccess time.Time
handle vfs.Handle
vfs *vfs.VFS
}

var (
Expand All @@ -42,8 +44,6 @@ var (
vfsNextHandle int64
)

const defaultHandleIdleTimeout = 5 * time.Minute

func ensureWritableCacheDir() {
currentCache := config.GetCacheDir()
if currentCache == "" || strings.HasPrefix(currentCache, "/data/local/tmp") || strings.HasPrefix(currentCache, "/tmp") {
Expand Down Expand Up @@ -212,12 +212,9 @@ func wrapBisyncEndpoint() {
}

func init() {
initAndroidTrustStore()
ensureWritableCacheDir()
wrapBisyncEndpoint()

go startHandleSweeper()

rc.Add(rc.Call{
Path: "config/setcachedir",
Fn: rcSetCacheDir,
Expand Down Expand Up @@ -280,30 +277,6 @@ func init() {
})
}

func startHandleSweeper() {
ticker := time.NewTicker(1 * time.Minute)
for range ticker.C {
cleanupIdleHandles(defaultHandleIdleTimeout)
}
}

func cleanupIdleHandles(timeout time.Duration) {
vfsHandleMutex.Lock()
now := time.Now()
var toClose []vfs.Handle
for id, entry := range vfsHandles {
if now.Sub(entry.lastAccess) > timeout {
toClose = append(toClose, entry.handle)
delete(vfsHandles, id)
}
}
vfsHandleMutex.Unlock()

for _, h := range toClose {
_ = h.Close()
}
}

func rcVfsMount(ctx context.Context, in rc.Params) (out rc.Params, err error) {
fsName, err := in.GetString("fs")
if err != nil || fsName == "" {
Expand All @@ -317,6 +290,7 @@ func rcVfsMount(ctx context.Context, in rc.Params) (out rc.Params, err error) {

// 1. Parse vfsOpt from parameters
_ = rc.ParseOptions(in, "vfsOpt", &vfsOpt)
vfsOpt.PollInterval = 0 // see markDirStale

// 2. Parse backend config options (_config) and filters (_filter)
ctx, err = rc.AddConfig(ctx, in)
Expand Down Expand Up @@ -393,6 +367,7 @@ func createDefaultVFS(fsName string) (*vfs.VFS, error) {
opts.CacheMode = vfscommon.CacheModeWrites
opts.ChunkSize = 2 * fs.Mebi
opts.ChunkSizeLimit = 8 * fs.Mebi
opts.PollInterval = 0 // see markDirStale

ensureWritableCacheDir()

Expand All @@ -401,6 +376,19 @@ func createDefaultVFS(fsName string) (*vfs.VFS, error) {
return instance, nil
}

// markDirStale makes the next read of dirPath list the remote again, as a change notification
// would, while keeping the cached subtree. The documents provider only lists while someone
// browses, so the VFS does not poll the remote for changes in the background, which kept the
// network busy every minute for as long as a remote was mounted. ForgetPath marks the parent
// of an object path stale, so it gets a path inside the directory.
func markDirStale(v *vfs.VFS, dirPath string) {
root, err := v.Root()
if err != nil {
return
}
root.ForgetPath(path.Join(dirPath, "entry"), fs.EntryObject)
}

func readDir(v *vfs.VFS, path string) ([]os.FileInfo, error) {
f, err := v.Open(path)
if err != nil {
Expand All @@ -427,6 +415,7 @@ func rcVfsList(ctx context.Context, in rc.Params) (out rc.Params, err error) {
return nil, err
}

markDirStale(v, remotePath)
entries, err := readDir(v, remotePath)
if err != nil {
return nil, err
Expand Down Expand Up @@ -486,12 +475,7 @@ func rcVfsOpen(ctx context.Context, in rc.Params) (out rc.Params, err error) {
return nil, err
}

flags := os.O_RDONLY
if modeStr == "w" || modeStr == "rw" {
flags = os.O_RDWR | os.O_CREATE
}

handle, err := v.OpenFile(remotePath, flags, 0644)
handle, err := v.OpenFile(remotePath, vfsOpenFlags(modeStr), 0644)
if err != nil {
return nil, err
}
Expand All @@ -500,9 +484,8 @@ func rcVfsOpen(ctx context.Context, in rc.Params) (out rc.Params, err error) {

vfsHandleMutex.Lock()
vfsHandles[handleID] = &vfsHandleEntry{
handle: handle,
vfs: v,
lastAccess: time.Now(),
handle: handle,
vfs: v,
}
vfsHandleMutex.Unlock()

Expand All @@ -518,6 +501,19 @@ func rcVfsOpen(ctx context.Context, in rc.Params) (out rc.Params, err error) {
}, nil
}

// vfsOpenFlags maps the Storage Access Framework open mode. A "t" truncates: without it,
// overwriting a file with shorter content would keep the old trailing bytes.
func vfsOpenFlags(mode string) int {
if !strings.ContainsAny(mode, "wa") {
return os.O_RDONLY
}
flags := os.O_RDWR | os.O_CREATE
if strings.Contains(mode, "t") {
flags |= os.O_TRUNC
}
return flags
}

func rcVfsRead(ctx context.Context, in rc.Params) (out rc.Params, err error) {
handleID, err := in.GetInt64("handle_id")
if err != nil {
Expand All @@ -531,9 +527,6 @@ func rcVfsRead(ctx context.Context, in rc.Params) (out rc.Params, err error) {

vfsHandleMutex.Lock()
entry, ok := vfsHandles[handleID]
if ok {
entry.lastAccess = time.Now()
}
vfsHandleMutex.Unlock()
if !ok {
return nil, errors.New("invalid handle id")
Expand Down Expand Up @@ -570,9 +563,6 @@ func rcVfsWrite(ctx context.Context, in rc.Params) (out rc.Params, err error) {

vfsHandleMutex.Lock()
entry, ok := vfsHandles[handleID]
if ok {
entry.lastAccess = time.Now()
}
vfsHandleMutex.Unlock()
if !ok {
return nil, errors.New("invalid handle id")
Expand Down Expand Up @@ -638,49 +628,6 @@ func rcVfsForget(ctx context.Context, in rc.Params) (out rc.Params, err error) {
return rc.Params{"success": true}, nil
}

func initAndroidTrustStore() {
if _, err := os.Stat("/system/etc/security/cacerts"); err != nil {
return
}

addDirs := []string{
"/apex/com.android.conscrypt/cacerts",
"/system/etc/security/cacerts",
}

uid := os.Getuid() / 100000
userCaDir := fmt.Sprintf("/data/misc/user/%d/cacerts-added", uid)
if _, err := os.Stat(userCaDir); err == nil {
addDirs = append(addDirs, userCaDir)
}

pool := x509.NewCertPool()
loadedCount := 0

for _, dir := range addDirs {
entries, err := os.ReadDir(dir)
if err != nil {
continue
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
path := filepath.Join(dir, entry.Name())
data, err := os.ReadFile(path)
if err != nil {
continue
}
if pool.AppendCertsFromPEM(data) {
loadedCount++
}
}
}
if loadedCount > 0 {
fs.Infof(nil, "Loaded %d Android CA certificates into trust pool", loadedCount)
}
}

//export RcloneVfsRead
func RcloneVfsRead(handleID int64, offset int64, count int32, outPtr *byte) int32 {
if count <= 0 || outPtr == nil {
Expand All @@ -689,9 +636,6 @@ func RcloneVfsRead(handleID int64, offset int64, count int32, outPtr *byte) int3

vfsHandleMutex.Lock()
entry, ok := vfsHandles[handleID]
if ok {
entry.lastAccess = time.Now()
}
vfsHandleMutex.Unlock()
if !ok {
return -1
Expand All @@ -713,9 +657,6 @@ func RcloneVfsWrite(handleID int64, offset int64, count int32, inPtr *byte) int3

vfsHandleMutex.Lock()
entry, ok := vfsHandles[handleID]
if ok {
entry.lastAccess = time.Now()
}
vfsHandleMutex.Unlock()
if !ok {
return -1
Expand All @@ -728,4 +669,3 @@ func RcloneVfsWrite(handleID int64, offset int64, count int32, inPtr *byte) int3
}
return int32(n)
}

Loading
Loading