scheduler

This commit is contained in:
FTCHD
2026-07-11 21:25:10 +03:00
parent 5fe76f45f4
commit 234fa93e9a
46 changed files with 9498 additions and 646 deletions
+571
View File
@@ -0,0 +1,571 @@
import { useQuery } from '@tanstack/react-query'
import { invoke } from '@tauri-apps/api/core'
import { message } from '@tauri-apps/plugin-dialog'
import {
isPermissionGranted,
requestPermission,
sendNotification,
} from '@tauri-apps/plugin-notification'
import { type WatchedJob, useStore } from '../store/memory'
import { usePersistedStore } from '../store/persisted'
import type {
NotificationCatalog,
NotificationEventId,
NotificationProvider,
NotificationTarget,
} from '../types/notifications'
import rclone from './rclone/client'
// The TS face of the notification system. The engine lives in Rust
// (src-tauri/src/notifications/): webhook dispatch, target storage (targets.json — NOT the
// zustand store), delivery-outcome recording, and the event catalog are shared with the
// headless scheduler runner, so webhooks behave identically whether the app is open or not.
// This file keeps the thin invoke wrappers plus the parts that must stay in the webview:
// GUI OS toasts (the notification plugin), the job watcher, and provider form helpers.
// ---------------------------------------------------------------------------
// OS toasts (GUI only — the headless runner posts its own via Rust notify-rust)
// ---------------------------------------------------------------------------
export async function notify({ title, body }: { title: string; body: string }) {
let permissionGranted = await isPermissionGranted()
if (!permissionGranted) {
const permission = await requestPermission()
permissionGranted = permission === 'granted'
}
if (permissionGranted) {
sendNotification({
title,
body,
})
} else {
await message(body, {
title,
kind: 'info',
})
}
}
// ---------------------------------------------------------------------------
// Webhook engine wrappers
// ---------------------------------------------------------------------------
export type NewNotificationTarget = Omit<
NotificationTarget,
'id' | 'createdAt' | 'lastSentAt' | 'lastError'
>
export async function getNotificationsCatalog(): Promise<NotificationCatalog> {
return await invoke<NotificationCatalog>('notifications_catalog')
}
export async function listNotificationTargets(): Promise<NotificationTarget[]> {
return await invoke<NotificationTarget[]>('notifications_list_targets')
}
/** Throws with a user-facing message (e.g. duplicate URL — re-checked race-safely in Rust). */
export async function addNotificationTarget(
target: NewNotificationTarget
): Promise<NotificationTarget> {
return await invoke<NotificationTarget>('notifications_add_target', { target })
}
export async function updateNotificationTarget(
id: string,
patch: Partial<Omit<NotificationTarget, 'id' | 'createdAt' | 'lastSentAt' | 'lastError'>>
): Promise<void> {
await invoke('notifications_update_target', { id, patch })
}
export async function removeNotificationTarget(id: string): Promise<void> {
await invoke('notifications_remove_target', { id })
}
/**
* Sends `eventId` to every enabled webhook target that subscribed to it. Fire-and-forget for
* callers (never throws); delivery happens in Rust, which records lastSentAt/lastError per
* target and reads targets at fire time.
*/
export async function dispatchNotification(
eventId: NotificationEventId,
payload: { title: string; body: string; data?: Record<string, unknown> }
): Promise<void> {
try {
await invoke('notifications_dispatch', {
eventId,
title: payload.title,
body: payload.body,
data: payload.data,
})
} catch (error) {
console.error('[dispatchNotification] failed', eventId, error)
}
}
/**
* Sends a test payload directly to the given target (which may be unsaved drawer values).
* Throws on failure so the UI can surface the error; Rust records the outcome when the target
* already exists (`id` set).
*/
export async function sendTestNotification(
target: Pick<NotificationTarget, 'provider' | 'url'> & { id?: string; name?: string }
): Promise<void> {
await invoke('notifications_send_test', {
provider: target.provider,
url: target.url,
targetId: target.id,
name: target.name,
})
}
export function useNotificationTargets() {
return useQuery({
queryKey: ['notifications', 'targets'],
queryFn: listNotificationTargets,
// Dispatches (and their outcome recording) happen in the hidden main window and the
// headless runner — separate queryClients that can't invalidate this window's cache.
// Polling is what keeps lastSentAt/lastError chips honest.
refetchInterval: 10_000,
refetchOnWindowFocus: true,
})
}
export function useNotificationsCatalog() {
return useQuery({
queryKey: ['notifications', 'catalog'],
queryFn: getNotificationsCatalog,
// Default staleTime, NOT Infinity: lib/query.ts persists the cache to localStorage for
// 30 days, and a frozen catalog would hide events added by app updates.
})
}
// ---------------------------------------------------------------------------
// Free-tier limit
// ---------------------------------------------------------------------------
/** Free (community) limit — a license removes the cap. Enforced at creation in the UI, and by
* the launch reconcile below when a license lapses. */
export const FREE_MAX_TARGETS = 5
/**
* License enforcement at launch: without a valid license, only the 5 oldest enabled targets
* stay enabled — the rest are turned off (never auto-re-enabled; the user flips them back on
* after re-activating). Runs in the hidden main window after validateInstance(); never throws.
*/
export async function reconcileNotificationTargets(): Promise<void> {
try {
if (usePersistedStore.getState().licenseValid) {
return
}
const targets = await listNotificationTargets()
const extras = targets
.filter((target) => target.isEnabled)
.sort((a, b) => a.createdAt - b.createdAt)
.slice(FREE_MAX_TARGETS)
for (const target of extras) {
await updateNotificationTarget(target.id, { isEnabled: false })
console.warn('[reconcileNotificationTargets] disabled over-limit target', target.name)
}
} catch (error) {
console.error('[reconcileNotificationTargets] failed', error)
}
}
// ---------------------------------------------------------------------------
// Provider form helpers
// ---------------------------------------------------------------------------
export const NOTIFICATION_PROVIDERS: Record<
NotificationProvider,
{
label: string
// Noun used in drawer titles/buttons, e.g. "Add Discord Webhook" / "Add Telegram Bot".
titleLabel: string
description: string
urlPlaceholder: string
accentClass: string
}
> = {
discord: {
label: 'Discord',
titleLabel: 'Discord Webhook',
description: 'Post to a Discord channel',
urlPlaceholder: 'https://discord.com/api/webhooks/1234567890/AbCdEf...',
accentClass: 'text-indigo-500',
},
slack: {
label: 'Slack',
titleLabel: 'Slack Webhook',
description: 'Post to a Slack channel',
urlPlaceholder: 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXX',
accentClass: 'text-emerald-500',
},
telegram: {
label: 'Telegram',
titleLabel: 'Telegram Bot',
description: 'Message a chat via your bot',
urlPlaceholder: 'https://api.telegram.org/bot123456:ABC-DEF.../sendMessage',
accentClass: 'text-sky-500',
},
webhook: {
label: 'Webhook',
titleLabel: 'Webhook',
description: 'POST JSON to any endpoint',
urlPlaceholder: 'https://example.com/hooks/rclone',
accentClass: 'text-primary',
},
}
// Accepts discord.com, legacy discordapp.com, and the ptb./canary. test clients.
const RE_DISCORD_WEBHOOK =
/^https:\/\/(?:(?:ptb|canary)\.)?discord(?:app)?\.com\/api\/webhooks\/\d+\/[\w-]+$/
const RE_SLACK_WEBHOOK = /^https:\/\/hooks\.slack\.com\/services\/T[A-Z0-9]+\/B[A-Z0-9]+\/\w+$/
// Standard Bot API endpoint: the path carries "bot<botid>:<token>"; chat_id rides the query.
const RE_TELEGRAM_SEND_MESSAGE = /^https:\/\/api\.telegram\.org\/bot\d+:[\w-]+\/sendMessage(\?.*)?$/
export function validateWebhookUrl(provider: NotificationProvider, url: string): string | null {
const trimmed = url.trim()
if (!trimmed) {
return 'A webhook URL is required'
}
if (provider === 'discord') {
if (!RE_DISCORD_WEBHOOK.test(trimmed)) {
return "This doesn't look like a Discord webhook URL — expected https://discord.com/api/webhooks/…"
}
return null
}
if (provider === 'slack') {
if (!RE_SLACK_WEBHOOK.test(trimmed)) {
return "This doesn't look like a Slack webhook URL — expected https://hooks.slack.com/services/…"
}
return null
}
if (provider === 'telegram') {
if (!RE_TELEGRAM_SEND_MESSAGE.test(trimmed)) {
return "This doesn't look like a Telegram Bot API URL — expected https://api.telegram.org/bot<token>/sendMessage"
}
const { chatId } = splitTelegramUrl(trimmed)
if (validateTelegramChatId(chatId)) {
return 'The Telegram URL is missing a valid chat_id'
}
return null
}
let parsed: URL
try {
parsed = new URL(trimmed)
} catch {
return 'This is not a valid URL'
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return 'The URL must use http:// or https://'
}
return null
}
// Integer chat id (negative for groups/supergroups) or a public @channelusername.
const RE_TELEGRAM_CHAT_ID = /^(-?\d+|@\w{5,})$/
export const TELEGRAM_CHAT_ID_HELP =
'Message @userinfobot on Telegram for your own ID, add @getidsbot to a group for its ID, or use @channelname for a public channel.'
/**
* The Telegram form collects the bot endpoint and the chat id separately; they are merged into
* one stored URL (…/sendMessage?chat_id=…) so the dispatcher and the NotificationTarget shape
* stay provider-agnostic. The UI never lets users type query params directly.
*/
export function buildTelegramUrl(baseUrl: string, chatId: string): string {
const parsed = new URL(baseUrl.trim())
parsed.search = ''
return `${parsed.toString()}?chat_id=${encodeURIComponent(chatId.trim())}`
}
/** Inverse of buildTelegramUrl, for seeding the edit form from a stored URL. */
export function splitTelegramUrl(url: string): { baseUrl: string; chatId: string } {
try {
const parsed = new URL(url)
const chatId = parsed.searchParams.get('chat_id') ?? ''
parsed.search = ''
return { baseUrl: parsed.toString(), chatId }
} catch {
return { baseUrl: url, chatId: '' }
}
}
/** Validates the drawer's Telegram URL field: base sendMessage endpoint, no query params. */
export function validateTelegramBotUrl(url: string): string | null {
const trimmed = url.trim()
if (!trimmed) {
return 'A bot URL is required'
}
if (trimmed.includes('?')) {
return "Don't include query parameters — enter the Chat ID in its own field below"
}
if (!RE_TELEGRAM_SEND_MESSAGE.test(trimmed)) {
return "This doesn't look like a Telegram Bot API URL — expected https://api.telegram.org/bot<token>/sendMessage"
}
return null
}
export function validateTelegramChatId(chatId: string): string | null {
const trimmed = chatId.trim()
if (!trimmed) {
return 'A chat ID is required'
}
if (!RE_TELEGRAM_CHAT_ID.test(trimmed)) {
return 'Enter a numeric chat ID (negative for groups) or a public @channelname'
}
return null
}
const MAX_MASK_SEGMENT_LENGTH = 10
// Webhook URLs are credentials (Telegram's first path segment IS the bot token) — the list view
// renders this instead of the full URL.
export function maskWebhookUrl(url: string): string {
try {
const parsed = new URL(url)
const segments = parsed.pathname.split('/').filter(Boolean)
if (segments.length === 0) {
return parsed.host
}
const firstSegment =
segments[0].length > MAX_MASK_SEGMENT_LENGTH
? `${segments[0].slice(0, MAX_MASK_SEGMENT_LENGTH)}`
: segments[0]
if (segments.length === 1 && !parsed.search) {
return `${parsed.host}/${firstSegment}`
}
return `${parsed.host}/${firstSegment}/…${url.slice(-4)}`
} catch {
return url.length > 24 ? `${url.slice(0, 24)}` : url
}
}
// ---------------------------------------------------------------------------
// Job watcher
// ---------------------------------------------------------------------------
const POLL_INTERVAL_MS = 5000
// A jobid that can't be fetched this many consecutive ticks is gone (daemon restarted and
// forgot it) — drop it silently instead of emitting a bogus outcome. This is also what bounds
// the watch list: rclone expires finished jobs after 24h, and a still-running job stays
// legitimately watchable for as long as it runs.
const MAX_STATUS_FAILURES = 6
let initialized = false
let pollTimer: ReturnType<typeof setInterval> | null = null
let ticking = false
// Guards against a broadcast echo re-adding a job we already handled. Module-local, so a main
// window reload can re-emit job.started for still-running jobs — acceptable edge case.
const seenJobIds = new Set<number>()
const handledJobIds = new Set<number>()
const statusFailures = new Map<number, number>()
/**
* Observes watchedJobs (registered by lib/rclone/api.ts from any window) and emits the
* job.started/completed/failed notifications. Must be initialized
* ONLY in the hidden main window — a second watcher would double-post webhooks.
*/
export function initJobWatcher() {
if (initialized) {
return
}
initialized = true
console.log('[jobWatcher] initialized')
useStore.subscribe((state) => onWatchedJobsChange(state.watchedJobs))
// The rclone client is bound to the current host, so after a host switch the watched jobids
// belong to a daemon we can no longer (safely) query — polling the new host could even match
// an unrelated job with the same id. Drop them.
let lastHostId = usePersistedStore.getState().currentHostId
usePersistedStore.subscribe((state) => {
if (state.currentHostId !== lastHostId) {
console.warn('[jobWatcher] host changed, dropping watched jobs')
lastHostId = state.currentHostId
clearWatchedJobs()
}
})
onWatchedJobsChange(useStore.getState().watchedJobs)
}
/**
* Forget all watched jobs — jobids do not survive a daemon restart or crash. The dedupe sets
* must go too: a fresh daemon issues jobids from 1 again, guaranteed to collide with old ones.
*/
export function clearWatchedJobs() {
statusFailures.clear()
seenJobIds.clear()
handledJobIds.clear()
useStore.setState({ watchedJobs: {} })
}
function onWatchedJobsChange(watchedJobs: Record<number, WatchedJob>) {
for (const job of Object.values(watchedJobs)) {
if (seenJobIds.has(job.jobid) || handledJobIds.has(job.jobid)) {
continue
}
seenJobIds.add(job.jobid)
dispatchNotification('job.started', {
title: 'Transfer started',
body: describeJob(job),
data: baseJobData(job),
})
}
const hasJobs = Object.keys(watchedJobs).length > 0
if (hasJobs && !pollTimer) {
pollTimer = setInterval(tick, POLL_INTERVAL_MS)
} else if (!hasJobs && pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
async function tick() {
if (ticking) {
return
}
ticking = true
try {
for (const job of Object.values(useStore.getState().watchedJobs)) {
await checkJob(job)
}
} finally {
ticking = false
}
}
async function checkJob(job: WatchedJob) {
// A broadcast echo can resurrect a job another write raced with — never process one twice.
if (handledJobIds.has(job.jobid)) {
unwatch(job.jobid)
return
}
let jobStatus: any
try {
jobStatus = await rclone('/job/status', {
params: {
query: {
jobid: job.jobid,
},
},
})
statusFailures.delete(job.jobid)
} catch (error) {
const failures = (statusFailures.get(job.jobid) ?? 0) + 1
statusFailures.set(job.jobid, failures)
if (failures >= MAX_STATUS_FAILURES) {
console.warn('[jobWatcher] dropping unreachable job', job.jobid, error)
unwatch(job.jobid)
}
return
}
// Re-check after the await: JobDetailsDrawer un-watches user-stopped jobs so the forced
// "context canceled" finish must not surface as a bogus failure notification.
if (!useStore.getState().watchedJobs[job.jobid]) {
return
}
if (!jobStatus?.finished) {
return
}
unwatch(job.jobid)
if (useStore.getState().dryRunJobIds.includes(job.jobid)) {
console.log('[jobWatcher] skipping dry run job', job.jobid)
return
}
// Same failure detection as fetchJob: the top-level error plus per-result errors, which is
// where batch partial failures live.
let failedResults = 0
let totalResults = 0
if (
jobStatus.output &&
typeof jobStatus.output === 'object' &&
'results' in jobStatus.output &&
Array.isArray(jobStatus.output.results)
) {
totalResults = jobStatus.output.results.length
failedResults = jobStatus.output.results.filter((result: any) => !!result?.error).length
}
const errorMessage: string =
jobStatus.error ||
(failedResults > 0 ? `${failedResults} of ${totalResults} operations failed` : '')
const data = {
...baseJobData(job),
durationSeconds:
typeof jobStatus.duration === 'number' ? Math.round(jobStatus.duration) : undefined,
...(errorMessage ? { error: errorMessage } : {}),
}
console.log('[jobWatcher] job finished', job.jobid, errorMessage || 'success')
if (errorMessage) {
dispatchNotification('job.failed', {
title: 'Transfer failed',
body: `${describeJob(job)}${errorMessage}`,
data,
})
} else {
dispatchNotification('job.completed', {
title: 'Transfer completed',
body: describeJob(job),
data,
})
}
}
function unwatch(jobid: number) {
handledJobIds.add(jobid)
statusFailures.delete(jobid)
useStore.setState((state) => {
const watchedJobs = { ...state.watchedJobs }
delete watchedJobs[jobid]
return { watchedJobs }
})
}
function describeJob(job: WatchedJob): string {
const sources = job.sources ?? []
const sourceLabel =
sources.length > 2
? `${sources.slice(0, 2).join(', ')} and ${sources.length - 2} more`
: sources.join(', ')
let description = job.operation
if (sourceLabel) {
description += ` of ${sourceLabel}`
}
if (job.destination) {
description += ` to ${job.destination}`
}
return description
}
function baseJobData(job: WatchedJob) {
return {
jobid: job.jobid,
operation: job.operation,
sources: job.sources,
destination: job.destination,
}
}
+465
View File
@@ -0,0 +1,465 @@
import type { FlagValue } from '../../types/rclone'
import { getFsInfo } from '../format'
// Pure serialization of operation args into ready-to-POST rclone RC requests. This is the
// single source for BOTH the live start* path (lib/rclone/api.ts) and the scheduler's job
// specs — they can never diverge. No HTTP, no store access: paths are machine-local strings and
// save/run always happen on the same machine.
const RE_WINDOWS_DRIVE_ROOT = /^:local:[a-zA-Z]:\/$/
export interface CopyArgs {
sources: string[]
destination: string
options: {
copy?: Record<string, FlagValue>
config?: Record<string, FlagValue>
filter?: Record<string, FlagValue>
remotes?: Record<string, Record<string, FlagValue>>
}
}
export interface MoveArgs {
sources: string[]
destination: string
options: {
move?: Record<string, FlagValue>
config?: Record<string, FlagValue>
filter?: Record<string, FlagValue>
remotes?: Record<string, Record<string, FlagValue>>
}
}
export interface SyncArgs {
source: string
destination: string
options: {
config?: Record<string, FlagValue>
sync?: Record<string, FlagValue>
filter?: Record<string, FlagValue>
remotes?: Record<string, Record<string, FlagValue>>
}
}
export interface BisyncArgs {
source: string
destination: string
options: {
config?: Record<string, FlagValue>
bisync?: Record<string, FlagValue>
filter?: Record<string, FlagValue>
remotes?: Record<string, Record<string, FlagValue>>
outer?: Record<string, FlagValue>
}
}
export interface DeleteArgs {
sources: string[]
options: {
filter?: Record<string, FlagValue>
config?: Record<string, FlagValue>
remotes?: Record<string, Record<string, FlagValue>>
}
}
export interface PurgeArgs {
sources: string[]
options: {
config?: Record<string, FlagValue>
remotes?: Record<string, Record<string, FlagValue>>
}
}
export type BatchInput = { _path: string } & Record<string, any>
export interface RcRequest {
endpoint: '/job/batch' | '/sync/sync' | '/sync/bisync'
// Always body-form with `_async: true`: the headless runner POSTs these verbatim (rclone's
// RC treats body and query parameters identically). The live path converts back to the
// query form its client uses.
body: Record<string, any>
}
// Encodes a path as an rclone connection string with inlined per-remote and global options:
// "<remoteName>,<k>=\"v\",global.<gk>=\"gv\":<path>".
export function serializeOptions(
remotePath: string,
options: {
remote?: Record<string, FlagValue>
global?: Record<string, FlagValue>
}
) {
console.log('[serializeRemoteOptions] ', remotePath)
const { remoteName, filePath, dirPath, type, root } = getFsInfo(remotePath)
console.log('[serializeRemoteOptions] ', remotePath, 'remoteName', remoteName)
console.log('[serializeRemoteOptions] ', remotePath, 'filePath', filePath)
console.log('[serializeRemoteOptions] ', remotePath, 'dirPath', dirPath)
console.log('[serializeRemoteOptions] ', remotePath, 'type', type)
console.log('[serializeRemoteOptions] ', remotePath, 'root', root)
let serialized = `${remoteName}`
if (
Object.keys(options.remote || {}).length > 0 ||
Object.keys(options.global || {}).length > 0
) {
serialized += ','
}
if (options.remote && Object.keys(options.remote).length > 0) {
serialized += Object.entries(options.remote)
.map(([key, value]) => `${key}="${value}"`)
.join(',')
}
if (options.global && Object.keys(options.global).length > 0) {
serialized += Object.entries(options.global)
.map(([key, value]) => `global.${key}="${value}"`)
.join(',')
}
serialized += ':'
if (remoteName === ':local') {
if (RE_WINDOWS_DRIVE_ROOT.test(root)) {
const driveLetter = root.slice(7)
console.log(
'[serializeRemoteOptions] ',
remotePath,
'adding Windows drive',
driveLetter
)
serialized += driveLetter
} else {
console.log('[serializeRemoteOptions] ', remotePath, 'adding / for Unix local')
serialized += '/'
}
}
if (type === 'folder') {
serialized += dirPath
} else {
serialized += filePath
}
console.log('[serializeRemoteOptions] ', remotePath, 'serialized', serialized)
return serialized
}
function assertIncludeRules(sources: string[], filter?: Record<string, FlagValue>) {
if (sources.length > 1 && filter && ('include' in filter || 'include_from' in filter)) {
throw new Error('Include rules are not supported with multiple sources')
}
}
function remoteOptionsFor(
remotes: Record<string, Record<string, FlagValue>> | undefined,
remoteName: string | undefined
) {
return remotes && remoteName && remoteName in remotes ? remotes[remoteName] : undefined
}
// Shared fan-out for copy/move: one batch input per source, de-duping repeats and children of
// folder sources, with the folder/file split deciding the RC method.
function buildTransferInputs(
args: CopyArgs | MoveArgs,
paths: { folder: string; file: string },
mergedOptions: Record<string, FlagValue>
): BatchInput[] {
const { sources, destination, options } = args
const inputs: BatchInput[] = []
const handledSourcePaths: Record<string, true> = {}
const folderSources = sources.filter((path) => path.endsWith('/') || path.endsWith('\\'))
const {
root: dstRoot,
dirPath: dstDirPath,
fullDirPath: dstFullDirPath,
remoteName: dstRemoteName,
} = getFsInfo(destination)
const dstOptions = remoteOptionsFor(options.remotes, dstRemoteName)
for (const source of sources) {
if (handledSourcePaths[source]) {
console.log('[buildTransferInputs] skipping already handled source', source)
continue
}
handledSourcePaths[source] = true
const {
root: srcRoot,
filePath: srcFilePath,
fullDirPath: srcFullDirPath,
type: srcType,
name: srcName,
remoteName: srcRemoteName,
} = getFsInfo(source)
const srcOptions = remoteOptionsFor(options.remotes, srcRemoteName)
if (srcType === 'folder') {
inputs.push({
_path: paths.folder,
srcFs: serializeOptions(srcFullDirPath, {
remote: srcOptions,
global: mergedOptions,
}),
dstFs: serializeOptions(`${dstFullDirPath}${srcName}`, {
remote: dstOptions,
}),
createEmptySrcDirs: true,
})
continue
}
if (folderSources.some((folder) => source.startsWith(folder))) {
console.log('[buildTransferInputs] skipping child of handled folder', source)
continue
}
inputs.push({
_path: paths.file,
srcFs: serializeOptions(srcRoot, {
remote: srcOptions,
global: mergedOptions,
}),
srcRemote: srcFilePath,
dstFs: serializeOptions(dstRoot, {
remote: dstOptions,
}),
dstRemote: `${dstDirPath === '/' ? '' : dstDirPath}${srcName}`,
})
}
return inputs
}
export function buildCopyRequests(args: CopyArgs): RcRequest[] {
assertIncludeRules(args.sources, args.options.filter)
const mergedOptions = {
...(args.options.config || {}),
...(args.options.copy || {}),
...(args.options.filter || {}),
}
const inputs = buildTransferInputs(
args,
{ folder: 'sync/copy', file: 'operations/copyfile' },
mergedOptions
)
return [{ endpoint: '/job/batch', body: { inputs, _async: true } }]
}
export function buildMoveRequests(args: MoveArgs): RcRequest[] {
assertIncludeRules(args.sources, args.options.filter)
const mergedOptions = {
...(args.options.config || {}),
...(args.options.move || {}),
...(args.options.filter || {}),
}
const inputs = buildTransferInputs(
args,
{ folder: 'sync/move', file: 'operations/movefile' },
mergedOptions
)
return [{ endpoint: '/job/batch', body: { inputs, _async: true } }]
}
export function buildSyncRequests(args: SyncArgs): RcRequest[] {
const { source, destination, options } = args
const mergedOptions = {
...(options.config || {}),
...(options.sync || {}),
...(options.filter || {}),
}
const { fullDirPath: srcFullDirPath, remoteName: srcRemoteName } = getFsInfo(source)
const { fullDirPath: dstFullDirPath, remoteName: dstRemoteName } = getFsInfo(destination)
return [
{
endpoint: '/sync/sync',
body: {
srcFs: serializeOptions(srcFullDirPath, {
global: mergedOptions,
remote: remoteOptionsFor(options.remotes, srcRemoteName),
}),
dstFs: serializeOptions(dstFullDirPath, {
remote: remoteOptionsFor(options.remotes, dstRemoteName),
}),
createEmptySrcDirs: true,
_async: true,
},
},
]
}
export function buildBisyncRequests(args: BisyncArgs): RcRequest[] {
const { source, destination, options } = args
const mergedOptions = {
...(options.config || {}),
...(options.bisync || {}),
...(options.filter || {}),
}
const { fullDirPath: srcFullDirPath, remoteName: srcRemoteName } = getFsInfo(source)
const { fullDirPath: dstFullDirPath, remoteName: dstRemoteName } = getFsInfo(destination)
return [
{
endpoint: '/sync/bisync',
body: {
path1: serializeOptions(srcFullDirPath, {
global: mergedOptions,
remote: remoteOptionsFor(options.remotes, srcRemoteName),
}),
path2: serializeOptions(dstFullDirPath, {
remote: remoteOptionsFor(options.remotes, dstRemoteName),
}),
...(options.outer && Object.keys(options.outer).length > 0
? Object.fromEntries(
Object.entries(options.outer).map(([key, value]) => [
key,
Array.isArray(value) ? value.join(',') : value,
])
)
: {}),
_async: true,
},
},
]
}
export function buildDeleteRequests(args: DeleteArgs): RcRequest[] {
const { sources, options } = args
assertIncludeRules(sources, options.filter)
const mergedOptions = {
...(options.config || {}),
...(options.filter || {}),
}
const inputs: BatchInput[] = []
const handledSourcePaths: Record<string, true> = {}
const folderSources = sources.filter((path) => path.endsWith('/') || path.endsWith('\\'))
for (const source of sources) {
if (handledSourcePaths[source]) {
console.log('[buildDeleteRequests] skipping already handled source', source)
continue
}
handledSourcePaths[source] = true
const {
root: srcRoot,
filePath: srcFilePath,
type: srcType,
remoteName: srcRemoteName,
} = getFsInfo(source)
const srcOptions = remoteOptionsFor(options.remotes, srcRemoteName)
if (srcType === 'folder') {
inputs.push({
_path: 'operations/delete',
fs: serializeOptions(source, {
global: mergedOptions,
remote: srcOptions,
}),
})
continue
}
if (folderSources.some((folder) => source.startsWith(folder))) {
console.log('[buildDeleteRequests] skipping child of handled folder', source)
continue
}
inputs.push({
_path: 'operations/deletefile',
fs: serializeOptions(srcRoot, {
global: mergedOptions,
remote: srcOptions,
}),
remote: srcFilePath,
})
}
return [{ endpoint: '/job/batch', body: { inputs, _async: true } }]
}
export function buildPurgeRequests(args: PurgeArgs): RcRequest[] {
const { sources, options } = args
const inputs: BatchInput[] = []
const handledSourcePaths: Record<string, true> = {}
for (const source of sources) {
if (handledSourcePaths[source]) {
console.log('[buildPurgeRequests] skipping already handled source', source)
continue
}
handledSourcePaths[source] = true
const {
root: srcRoot,
dirPath: srcDirPath,
type: srcType,
remoteName: srcRemoteName,
} = getFsInfo(source)
if (srcType !== 'folder') {
throw new Error('Only folders can be purged')
}
inputs.push({
_path: 'operations/purge',
fs: serializeOptions(srcRoot, {
global: options.config,
remote: remoteOptionsFor(options.remotes, srcRemoteName),
}),
remote: srcDirPath,
})
}
return [{ endpoint: '/job/batch', body: { inputs, _async: true } }]
}
/** Discriminated operation/args pair — ScheduledTask satisfies this. */
export type TaskRequestInput =
| { operation: 'copy'; args: CopyArgs }
| { operation: 'move'; args: MoveArgs }
| { operation: 'sync'; args: SyncArgs }
| { operation: 'bisync'; args: BisyncArgs }
| { operation: 'delete'; args: DeleteArgs }
| { operation: 'purge'; args: PurgeArgs }
/** Builds the RC requests for a scheduled task — throws when the args can't serialize. */
export function buildTaskRequests(task: TaskRequestInput): RcRequest[] {
switch (task.operation) {
case 'copy':
return buildCopyRequests(task.args)
case 'move':
return buildMoveRequests(task.args)
case 'sync':
return buildSyncRequests(task.args)
case 'bisync':
return buildBisyncRequests(task.args)
case 'delete':
return buildDeleteRequests(task.args)
case 'purge':
return buildPurgeRequests(task.args)
default:
throw new Error(`Unknown operation: ${(task as { operation: string }).operation}`)
}
}
+466
View File
@@ -0,0 +1,466 @@
import * as Sentry from '@sentry/browser'
import { useQuery } from '@tanstack/react-query'
import { invoke } from '@tauri-apps/api/core'
import { initHostStore, useHostStore } from '../store/host'
import { usePersistedStore } from '../store/persisted'
import type { ScheduledTask } from '../types/schedules'
import { LOCAL_HOST_ID } from './hosts'
import { type RcRequest, type TaskRequestInput, buildTaskRequests } from './rclone/requests'
// Orchestration between the zustand host store (task definitions — the source of truth) and the
// Rust OS scheduler (registration reality). Registration is always an upsert, so the startup
// reconcile() self-heals deleted OS artifacts, moved app bundles, and restored backups.
//
// Scheduling is LOCAL-HOST-ONLY: tasks stored under remote hosts stay inert.
export interface SchedulerSupport {
supported: boolean
reason?: string
}
export interface SchedulerJobSpec {
schemaVersion: 1
taskId: string
hostId: string
name: string
operation: ScheduledTask['operation']
cron: string
configId: string
binary: 'app-default' | string
maxRunSeconds: number
verboseLogging: boolean
runMode: 'system' | 'user'
requests: RcRequest[]
}
export interface SchedulerTaskStatus {
taskId: string
installed: boolean
enabled: boolean
running: boolean
lastFinished?: {
runId: string
ts: string
success: boolean
error?: string
durationMs: number
jobids?: number[]
stats?: { bytes?: number; transfers?: number; errors?: number }
/** Synthesized: the run left a started event but no finished one (crash/power loss). */
interrupted?: boolean
}
/** Backend health warning — installed+enabled but the OS won't fire it (e.g. the macOS
* background item was toggled off in System Settings). */
warning?: string
}
export type SchedulerHistoryLine =
| { event: 'started'; runId: string; ts: string; pid: number; hostId: string }
| {
event: 'finished'
runId: string
ts: string
success: boolean
error?: string
durationMs: number
jobids?: number[]
stats?: { bytes?: number; transfers?: number; errors?: number }
}
| { event: 'skipped'; ts: string; reason: string }
/** Max run time bounds, in hours. The wire format (JobSpec.maxRunSeconds) stays in seconds. */
export const DEFAULT_MAX_RUN_HOURS = 24
export const MAX_RUN_HOURS_LIMIT = 120
function clampMaxRunHours(hours: number | undefined): number {
if (!Number.isFinite(hours)) {
return DEFAULT_MAX_RUN_HOURS
}
return Math.min(Math.max(Math.round(hours as number), 1), MAX_RUN_HOURS_LIMIT)
}
let cachedSupport: SchedulerSupport | null = null
export async function schedulerSupported(): Promise<SchedulerSupport> {
if (!cachedSupport) {
cachedSupport = await invoke<SchedulerSupport>('scheduler_supported')
}
return cachedSupport
}
export function useSchedulerSupported() {
return useQuery({
queryKey: ['scheduler', 'supported'],
queryFn: schedulerSupported,
staleTime: Number.POSITIVE_INFINITY,
})
}
export interface CronValidation {
valid: boolean
error?: string
/** Next local fire times (RFC3339), computed by the same Rust matcher the runner uses —
* the only preview source that can't disagree with what the OS schedule will do. */
nextRuns: string[]
}
export async function schedulerValidateCron(cron: string) {
return invoke<CronValidation>('scheduler_validate_cron', { cron })
}
export async function schedulerStatus(hostId: string) {
return invoke<SchedulerTaskStatus[]>('scheduler_status', { hostId })
}
export async function schedulerReadHistory(taskId: string, limit?: number) {
return invoke<SchedulerHistoryLine[]>('scheduler_read_history', { taskId, limit })
}
export async function schedulerRunNow(taskId: string) {
return invoke('scheduler_run_now', { taskId })
}
export async function schedulerReadLog(taskId: string, which: 'runner' | 'daemon') {
return invoke<{ content: string; truncated: boolean }>('scheduler_read_log', {
taskId,
which,
})
}
export interface SchedulerDoctorCheck {
name: string
ok: boolean
detail: string
fix?: string
}
export async function schedulerDoctor() {
return invoke<SchedulerDoctorCheck[]>('scheduler_doctor')
}
function buildJobSpec(task: ScheduledTask): SchedulerJobSpec {
return {
schemaVersion: 1,
taskId: task.id,
hostId: LOCAL_HOST_ID,
name: task.name ?? task.operation,
operation: task.operation,
cron: task.cron,
configId: task.configId,
binary: task.binaryPath,
maxRunSeconds: clampMaxRunHours(task.maxRunHours) * 3600,
verboseLogging: task.verboseLogging ?? false,
runMode: task.runMode ?? 'user',
// Pre-serialized here, at save time, by the exact same builders the live start* path
// uses — the runner just POSTs them. Throws when the args can't serialize.
requests: buildTaskRequests(task),
}
}
async function registerTask(task: ScheduledTask): Promise<void> {
const spec = buildJobSpec(task)
// One command: the artifact is installed directly in the target enabled state. A separate
// set_enabled step used to leave disabled tasks briefly armed (and, when it failed, running
// against the user's intent — or flagged as unregistered although active).
await invoke('scheduler_register', { spec, enabled: task.isEnabled })
}
function isCurrentHostLocal() {
return (usePersistedStore.getState().currentHostId ?? LOCAL_HOST_ID) === LOCAL_HOST_ID
}
function assertLocalHost() {
if (!isCurrentHostLocal()) {
throw new Error('Scheduling is only available on your local machine')
}
}
async function assertSupported() {
const support = await schedulerSupported()
if (!support.supported) {
throw new Error(support.reason ?? 'Scheduling is not available on this system')
}
}
/**
* Creates a task, registers it with the OS scheduler, and returns its id. On registration
* failure the task is kept (with the error stored on it) — never silently lost; the startup
* reconcile retries. isEnabled always reflects user intent, never system state.
*/
export async function createScheduledTask(input: {
name: string
operation: ScheduledTask['operation']
cron: string
args: ScheduledTask['args']
/** Defaults to the active config when omitted. */
configId?: string
/** Defaults to 'app-default' when omitted. */
binaryPath?: string
/** Defaults to 'user' (only runs while logged in) when omitted. */
runMode?: 'system' | 'user'
}): Promise<string> {
assertLocalHost()
await assertSupported()
const validation = await schedulerValidateCron(input.cron)
if (!validation.valid) {
throw new Error(validation.error ?? 'Invalid cron expression')
}
const hostState = useHostStore.getState()
const configId = input.configId ?? hostState.activeConfigId
if (!configId) {
throw new Error('No active config file')
}
if (!hostState.configFiles.some((config) => config.id === configId)) {
throw new Error('The selected config file no longer exists')
}
const task = {
name: input.name,
operation: input.operation,
cron: input.cron,
args: input.args,
isEnabled: true,
configId,
binaryPath: input.binaryPath ?? 'app-default',
runMode: input.runMode ?? 'user',
} as Omit<ScheduledTask, 'id'>
// Serialization must succeed before anything persists. (Callers guarantee the operation/args
// correlation via useScheduleTask's generic; Omit<> flattens the discriminated union, hence
// the cast.)
buildTaskRequests({ operation: input.operation, args: input.args } as TaskRequestInput)
const id = hostState.addScheduledTask(task)
const stored = useHostStore.getState().scheduledTasks.find((t) => t.id === id)
if (!stored) {
throw new Error('Failed to save the scheduled task')
}
try {
await registerTask(stored)
} catch (error) {
const registrationError = error instanceof Error ? error.message : String(error)
useHostStore.getState().updateScheduledTask(id, { registrationError })
throw new Error(
`The schedule was saved but could not be registered with the system: ${registrationError}`
)
}
return id
}
/**
* Updates a task and re-registers it (upsert). On a remote host this is a store-only edit —
* remote tasks are inert in v1 and must never touch the local OS scheduler.
*/
export async function updateScheduledTask(
id: string,
patch: Partial<ScheduledTask>
): Promise<void> {
if (!isCurrentHostLocal()) {
useHostStore.getState().updateScheduledTask(id, patch)
return
}
await assertSupported()
if (patch.cron) {
const validation = await schedulerValidateCron(patch.cron)
if (!validation.valid) {
throw new Error(validation.error ?? 'Invalid cron expression')
}
}
const store = useHostStore.getState()
store.updateScheduledTask(id, { ...patch, registrationError: undefined })
const merged = useHostStore.getState().scheduledTasks.find((t) => t.id === id)
if (!merged) {
throw new Error('Task not found')
}
try {
await registerTask(merged)
} catch (error) {
const registrationError = error instanceof Error ? error.message : String(error)
useHostStore.getState().updateScheduledTask(id, { registrationError })
throw new Error(`The task was saved but could not be registered: ${registrationError}`)
}
}
/**
* Removes the task. The OS unregister removes the job file even when the OS-level uninstall
* fails, so a surviving trigger self-heals on its next fire (the runner finds no job file,
* removes the trigger, and exits). Remote-host tasks are store-only.
*/
export async function removeScheduledTask(id: string): Promise<void> {
if (isCurrentHostLocal()) {
try {
await invoke('scheduler_unregister', { taskId: id, hostId: LOCAL_HOST_ID })
} catch (error) {
console.error(
'[scheduler] unregister failed; the trigger self-heals on next fire',
error
)
}
}
useHostStore.getState().removeScheduledTask(id)
}
export async function setScheduledTaskEnabled(id: string, enabled: boolean): Promise<void> {
const task = useHostStore.getState().scheduledTasks.find((t) => t.id === id)
if (!task) {
throw new Error('Task not found')
}
// Remote-host tasks are inert — the toggle is a definition-only edit.
if (!isCurrentHostLocal()) {
useHostStore.getState().updateScheduledTask(id, { isEnabled: enabled })
return
}
// Enabling a task whose registration previously failed retries the full registration.
if (enabled && task.registrationError) {
await updateScheduledTask(id, { isEnabled: true })
return
}
// Disabling a task whose registration failed: an OS artifact may STILL exist (a failed
// edit leaves the previous artifact active; a failed mode flip can leave one in the other
// backend). The Rust side sweeps every backend and treats "no artifact anywhere" as
// success, so always ask it — a real disable failure must surface rather than leave the
// task firing while the UI says paused.
if (!enabled && task.registrationError) {
await invoke('scheduler_set_enabled', { taskId: id, enabled: false })
useHostStore.getState().updateScheduledTask(id, { isEnabled: false })
return
}
// OS first, store second — a failed OS call must not leave the UI claiming a state the
// scheduler doesn't have.
await invoke('scheduler_set_enabled', { taskId: id, enabled })
useHostStore.getState().updateScheduledTask(id, { isEnabled: enabled })
}
/**
* Startup/host-switch reconciliation — idempotent, runs on EVERY start. Re-registers every
* local task (heals exe-path drift, deleted OS artifacts, and performs the one-time migration
* registration after the v3 store migrate) and unregisters strays the store no longer knows.
*/
export async function reconcile(): Promise<void> {
const support = await schedulerSupported()
if (!support.supported) {
console.log('[scheduler] unsupported, skipping reconcile:', support.reason)
return
}
// Never reconcile against a non-local host store: the stray sweep would treat every real
// local registration as unknown and destroy it.
if (!isCurrentHostLocal()) {
console.log('[scheduler] current host is remote, skipping reconcile')
return
}
const taskIds = useHostStore.getState().scheduledTasks.map((task) => task.id)
for (const id of taskIds) {
// Re-read at registration time: the UI is usable while reconcile runs, so a user edit
// mid-loop must win — registering a stale snapshot would silently revert it in the job
// file and OS artifact while the store shows the new definition.
const task = useHostStore.getState().scheduledTasks.find((t) => t.id === id)
if (!task) {
continue
}
try {
await registerTask(task)
if (task.registrationError) {
useHostStore.getState().updateScheduledTask(task.id, {
registrationError: undefined,
})
}
} catch (error) {
// registrationError only — isEnabled stays user intent, so a transient failure
// (user bus not ready yet, launchctl hiccup) heals on the next reconcile instead of
// permanently pausing the task.
const registrationError = error instanceof Error ? error.message : String(error)
console.error('[scheduler] failed to register task', task.id, registrationError)
useHostStore.getState().updateScheduledTask(task.id, { registrationError })
}
}
// Strays: OS registrations whose task no longer exists in the store.
try {
const statuses = await schedulerStatus(LOCAL_HOST_ID)
const known = new Set(useHostStore.getState().scheduledTasks.map((t) => t.id))
for (const status of statuses) {
if (!known.has(status.taskId)) {
console.log('[scheduler] unregistering stray task', status.taskId)
await invoke('scheduler_unregister', {
taskId: status.taskId,
hostId: LOCAL_HOST_ID,
})
}
}
} catch (error) {
console.error('[scheduler] stray sweep failed', error)
}
// Artifact-only leftovers: an OS artifact whose job file is gone (a failed uninstall after
// the job file was already removed). A DISABLED leftover never fires, so the runner's
// fire-time self-heal can never reach it — this sweep is the only cleanup path. Runs last:
// the loop above just re-registered every stored task, so their job files protect them.
try {
const swept = await invoke<number>('scheduler_sweep_orphans')
if (swept > 0) {
console.log('[scheduler] swept orphaned artifacts:', swept)
}
} catch (error) {
console.error('[scheduler] orphan sweep failed', error)
}
}
let initialized = false
/**
* Called ONLY from the hidden main window (single writer, like initJobWatcher): reconciles at
* startup and again whenever the user switches back to the local host.
*/
export async function initScheduler(): Promise<void> {
if (initialized) {
return
}
initialized = true
const isLocal = () =>
(usePersistedStore.getState().currentHostId ?? LOCAL_HOST_ID) === LOCAL_HOST_ID
let lastHostId = usePersistedStore.getState().currentHostId
usePersistedStore.subscribe((state) => {
if (state.currentHostId !== lastHostId) {
lastHostId = state.currentHostId
if (isLocal()) {
// This window's useHostStore must be re-pointed at the local host BEFORE
// reconciling — nothing else in the hidden main window re-inits it on host
// switch, and reconciling against a stale (remote) host store would sweep away
// every genuine local registration as a stray.
initHostStore(LOCAL_HOST_ID)
.then(() => reconcile())
.catch((error) => {
console.error('[scheduler] reconcile after host switch failed', error)
})
}
}
})
if (!isLocal()) {
return
}
try {
await reconcile()
} catch (error) {
console.error('[scheduler] startup reconcile failed', error)
Sentry.captureException(error)
}
}
+8 -302
View File
@@ -9,34 +9,23 @@ import { debug, error, info, trace, warn } from '@tauri-apps/plugin-log'
import { platform } from '@tauri-apps/plugin-os' import { platform } from '@tauri-apps/plugin-os'
import { exit, relaunch } from '@tauri-apps/plugin-process' import { exit, relaunch } from '@tauri-apps/plugin-process'
import { type Update, check } from '@tauri-apps/plugin-updater' import { type Update, check } from '@tauri-apps/plugin-updater'
import { CronExpressionParser } from 'cron-parser'
import { defaultOptions } from 'tauri-plugin-sentry-api' import { defaultOptions } from 'tauri-plugin-sentry-api'
import { getDeepLinkUrl, handleDeepLinkUrl } from './lib/deep' import { getDeepLinkUrl, handleDeepLinkUrl } from './lib/deep'
import { CLOSE_APP, RELAUNCH_APP, RESTART_RCLONE, type RestartRclonePayload } from './lib/events' import { CLOSE_APP, RELAUNCH_APP, RESTART_RCLONE, type RestartRclonePayload } from './lib/events'
import { LOCAL_HOST_ID, RC_PORT, getHostInfo, makeLocalHost } from './lib/hosts' import { LOCAL_HOST_ID, RC_PORT, getHostInfo, makeLocalHost } from './lib/hosts'
import { validateLicense } from './lib/license' import { validateLicense } from './lib/license'
import notify from './lib/notify'
import queryClient from './lib/query' import queryClient from './lib/query'
import { import { listTransfers, startMount } from './lib/rclone/api'
listTransfers,
startBisync,
startCopy,
startDelete,
startMount,
startMove,
startPurge,
startSync,
} from './lib/rclone/api'
import rcloneClient from './lib/rclone/client' import rcloneClient from './lib/rclone/client'
import { compareVersions } from './lib/rclone/common' import { compareVersions } from './lib/rclone/common'
import { initRclone } from './lib/rclone/init' import { initRclone } from './lib/rclone/init'
import { initScheduler } from './lib/scheduler'
import { initTray } from './lib/tray' import { initTray } from './lib/tray'
import { openSmallWindow } from './lib/window' import { openSmallWindow } from './lib/window'
import { initHostStore, useHostStore } from './store/host' import { initHostStore, useHostStore } from './store/host'
import { waitForStoreHydration } from './store/lib' import { waitForStoreHydration } from './store/lib'
import { useStore } from './store/memory' import { useStore } from './store/memory'
import { selectCurrentHost, usePersistedStore } from './store/persisted' import { selectCurrentHost, usePersistedStore } from './store/persisted'
import type { ScheduledTask } from './types/schedules'
let rcloneListenersRegistered = false let rcloneListenersRegistered = false
@@ -101,8 +90,11 @@ async function checkFlatpakPermissions() {
const hasPermissions = await invoke<boolean>('has_flatpak_permissions') const hasPermissions = await invoke<boolean>('has_flatpak_permissions')
if (hasPermissions) return if (hasPermissions) return
const overrideCommand =
'flatpak override --user --filesystem=host --talk-name=org.freedesktop.Flatpak com.rcloneui.RcloneUI'
const copyCommand = await ask( const copyCommand = await ask(
'You are running the flatpak version of Rclone UI, which is sandboxed.\n\nrclone is a file management utility that needs disk access in order to run. Please allow it using the following command (copy paste in your terminal):\n\nflatpak override --user --filesystem=host com.rcloneui.RcloneUI\n\nRestart Rclone UI afterwards.', `You are running the flatpak version of Rclone UI, which is sandboxed.\n\nRclone UI needs disk access to manage your files, and host access to schedule tasks. Please grant both using the following command (copy paste in your terminal):\n\n${overrideCommand}\n\nRestart Rclone UI afterwards.`,
{ {
title: 'Flatpak Permissions Required', title: 'Flatpak Permissions Required',
kind: 'warning', kind: 'warning',
@@ -112,7 +104,7 @@ async function checkFlatpakPermissions() {
) )
if (copyCommand) { if (copyCommand) {
await writeText('flatpak override --user --filesystem=host com.rcloneui.RcloneUI') await writeText(overrideCommand)
} }
await exit() await exit()
@@ -623,292 +615,6 @@ async function showStartup() {
console.log('[showStartup] startup hidden') console.log('[showStartup] startup hidden')
} }
const MAX_INT_MS = 2_147_483_647
let hasScheduledTasks = false
async function resumeTasks() {
console.log('[resumeTasks] resuming tasks')
if (hasScheduledTasks) {
console.log('[resumeTasks] already called, skipping (hasScheduledTasks=true)')
return
}
const scheduledTasks = useHostStore.getState().scheduledTasks
const activeConfigId = useHostStore.getState().activeConfigId
console.log('[resumeTasks] found', scheduledTasks.length, 'scheduled tasks')
console.log('[resumeTasks] activeConfigId:', activeConfigId)
if (!activeConfigId) {
console.log('[resumeTasks] no active config id, cannot schedule tasks')
return
}
hasScheduledTasks = true
console.log('[resumeTasks] processing tasks for config:', activeConfigId)
let scheduledCount = 0
let skippedRunning = 0
let skippedConfigMismatch = 0
let skippedTimingIssue = 0
for (const task of scheduledTasks) {
console.log('[resumeTasks] processing task:', {
id: task.id,
operation: task.operation,
cron: task.cron,
configId: task.configId,
isRunning: task.isRunning,
isEnabled: task.isEnabled,
})
if (!task.isEnabled) {
console.log('[resumeTasks] task', task.id, 'is disabled, skipping')
continue
}
if (task.isRunning) {
console.log('[resumeTasks] task', task.id, 'was marked as running, resetting state')
useHostStore.getState().updateScheduledTask(task.id, {
isRunning: false,
currentRunId: undefined,
lastRunError: 'Task closed prematurely',
})
skippedRunning++
continue
}
if (task.configId !== activeConfigId) {
console.log(
'[resumeTasks] task',
task.id,
'belongs to different config:',
task.configId,
'!==',
activeConfigId
)
skippedConfigMismatch++
continue
}
try {
console.log('[resumeTasks] parsing cron expression:', task.cron)
const cronInterval = CronExpressionParser.parse(task.cron)
const nextRun = cronInterval.next().toDate()
const now = Date.now()
const difference = nextRun.getTime() - now
console.log('[resumeTasks] task', task.id, 'timing:', {
nextRun: nextRun.toISOString(),
now: new Date(now).toISOString(),
differenceMs: difference,
differenceMinutes: Math.round(difference / 60000),
maxAllowedMs: MAX_INT_MS,
withinLimit: difference <= MAX_INT_MS,
isPositive: difference > 0,
})
if (difference <= MAX_INT_MS && difference > 0) {
console.log(
'[resumeTasks] scheduling task',
task.id,
'to run in',
Math.round(difference / 60000),
'minutes'
)
setTimeout(async () => {
console.log(
'[resumeTasks] timer fired for task',
task.id,
'at',
new Date().toISOString()
)
notify({
title: 'Task Started',
body: `Task ${task.operation} (${task.id}) started`,
})
await handleTask(task)
}, difference)
scheduledCount++
console.log(
'[resumeTasks] task',
task.id,
'scheduled successfully for',
nextRun.toISOString()
)
} else {
console.log(
'[resumeTasks] task',
task.id,
'NOT scheduled:',
difference > MAX_INT_MS
? 'next run too far in future'
: 'next run is in the past or now'
)
skippedTimingIssue++
}
} catch (error) {
console.error('[resumeTasks] error scheduling task', task.id, ':', error)
console.error('[resumeTasks] task details:', JSON.stringify(task, null, 2))
Sentry.captureException(error)
}
}
console.log('[resumeTasks] summary:', {
totalTasks: scheduledTasks.length,
scheduledCount,
skippedRunning,
skippedConfigMismatch,
skippedTimingIssue,
})
}
async function handleTask(task: ScheduledTask) {
console.log('[handleTask] starting execution for task:', task.id, task.operation)
const currentTask = useHostStore.getState().scheduledTasks.find((t) => t.id === task.id)
if (!currentTask) {
console.log('[handleTask] task', task.id, 'not found in store, aborting')
return
}
console.log('[handleTask] found task in store:', {
id: currentTask.id,
isRunning: currentTask.isRunning,
currentRunId: currentTask.currentRunId,
isEnabled: currentTask.isEnabled,
})
if (currentTask.isRunning) {
console.log(
'[handleTask] task',
task.id,
'already running (runId:',
currentTask.currentRunId,
'), aborting'
)
return
}
const freshRunId = crypto.randomUUID()
console.log('[handleTask] generated freshRunId:', freshRunId)
useHostStore.getState().updateScheduledTask(task.id, {
isRunning: true,
currentRunId: freshRunId,
lastRun: new Date().toISOString(),
})
console.log('[handleTask] running task', task.operation, task.id)
console.log('[handleTask] updated task state, isRunning=true, runId:', freshRunId)
const currentRunId = useHostStore
.getState()
.scheduledTasks.find((t) => t.id === task.id)?.currentRunId
console.log('[handleTask] verifying runId - expected:', freshRunId, 'actual:', currentRunId)
if (currentRunId !== freshRunId) {
console.log('[handleTask] runId mismatch, another execution may have started, aborting')
return
}
console.log(
'[handleTask] executing operation:',
task.operation,
'with args:',
JSON.stringify(task.args, null, 2)
)
try {
switch (task.operation) {
case 'copy': {
console.log('[handleTask] starting copy operation')
const { sources, options, destination } = task.args
await startCopy({
sources,
destination,
options,
})
console.log('[handleTask] copy operation completed')
break
}
case 'move': {
console.log('[handleTask] starting move operation')
const { sources, options, destination } = task.args
await startMove({
sources,
destination,
options,
})
console.log('[handleTask] move operation completed')
break
}
case 'sync': {
console.log('[handleTask] starting sync operation')
const { source, destination, options } = task.args
await startSync({
source,
destination,
options,
})
console.log('[handleTask] sync operation completed')
break
}
case 'bisync': {
console.log('[handleTask] starting bisync operation')
const { source, destination, options } = task.args
await startBisync({
source,
destination,
options,
})
console.log('[handleTask] bisync operation completed')
break
}
case 'delete': {
console.log('[handleTask] starting delete operation')
const { sources, options } = task.args
await startDelete({
sources,
options,
})
console.log('[handleTask] delete operation completed')
break
}
case 'purge': {
console.log('[handleTask] starting purge operation')
const { sources, options } = task.args
await startPurge({
sources,
options,
})
console.log('[handleTask] purge operation completed')
break
}
default:
console.log('[handleTask] unknown operation encountered')
break
}
console.log('[handleTask] task', task.id, 'completed successfully')
} catch (err) {
Sentry.captureException(err)
console.error('[handleTask] task', task.id, 'failed with error:', err)
console.error('[handleTask] task args were:', JSON.stringify(task.args, null, 2))
useHostStore.getState().updateScheduledTask(task.id, {
lastRunError: err instanceof Error ? err.message : 'Unknown error',
})
} finally {
console.log('[handleTask] cleaning up task', task.id, 'state')
useHostStore.getState().updateScheduledTask(task.id, {
isRunning: false,
currentRunId: undefined,
})
}
}
async function installUpdate(update: Update, required: boolean) { async function installUpdate(update: Update, required: boolean) {
const confirmed = await ask( const confirmed = await ask(
'You are running an outdated version of Rclone UI. Please update to the latest version.', 'You are running an outdated version of Rclone UI. Please update to the latest version.',
@@ -1104,6 +810,6 @@ waitForHydration()
.then(() => handleDeepLink()) .then(() => handleDeepLink())
.then(() => showStartup()) .then(() => showStartup())
.then(() => startupMounts()) .then(() => startupMounts())
.then(() => resumeTasks()) .then(() => initScheduler())
.then(() => initTray()) .then(() => initTray())
.catch(console.error) .catch(console.error)
-22
View File
@@ -32,7 +32,6 @@
"@tauri-apps/plugin-store": "^2.4.3", "@tauri-apps/plugin-store": "^2.4.3",
"@tauri-apps/plugin-updater": "^2.10.1", "@tauri-apps/plugin-updater": "^2.10.1",
"@tauri-apps/plugin-window-state": "^2.4.1", "@tauri-apps/plugin-window-state": "^2.4.1",
"cron-parser": "^5.6.1",
"cronstrue": "^3.24.0", "cronstrue": "^3.24.0",
"date-fns": "^4.4.0", "date-fns": "^4.4.0",
"framer-motion": "^12.42.2", "framer-motion": "^12.42.2",
@@ -6522,18 +6521,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cron-parser": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.6.1.tgz",
"integrity": "sha512-QBm4o1PwZiuY7KFbVvW7FLC8bozy7YWzv+Fz6KRS7sQghzcbDZCGxr/Bc5b6TQreAoSwuWVP491dIcK0THCX6A==",
"license": "MIT",
"dependencies": {
"luxon": "^3.7.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/cronstrue": { "node_modules/cronstrue": {
"version": "3.24.0", "version": "3.24.0",
"resolved": "https://registry.npmjs.org/cronstrue/-/cronstrue-3.24.0.tgz", "resolved": "https://registry.npmjs.org/cronstrue/-/cronstrue-3.24.0.tgz",
@@ -7235,15 +7222,6 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
} }
}, },
"node_modules/luxon": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/media-chrome": { "node_modules/media-chrome": {
"version": "4.19.0", "version": "4.19.0",
"resolved": "https://registry.npmjs.org/media-chrome/-/media-chrome-4.19.0.tgz", "resolved": "https://registry.npmjs.org/media-chrome/-/media-chrome-4.19.0.tgz",
-1
View File
@@ -55,7 +55,6 @@
"@tauri-apps/plugin-store": "^2.4.3", "@tauri-apps/plugin-store": "^2.4.3",
"@tauri-apps/plugin-updater": "^2.10.1", "@tauri-apps/plugin-updater": "^2.10.1",
"@tauri-apps/plugin-window-state": "^2.4.1", "@tauri-apps/plugin-window-state": "^2.4.1",
"cron-parser": "^5.6.1",
"cronstrue": "^3.24.0", "cronstrue": "^3.24.0",
"date-fns": "^4.4.0", "date-fns": "^4.4.0",
"framer-motion": "^12.42.2", "framer-motion": "^12.42.2",
+5
View File
@@ -247,12 +247,16 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
name = "app" name = "app"
version = "3.6.1" version = "3.6.1"
dependencies = [ dependencies = [
"chrono",
"cocoa", "cocoa",
"dirs 6.0.0",
"fix-path-env", "fix-path-env",
"flate2", "flate2",
"gtk", "gtk",
"libc",
"log", "log",
"machine-uid", "machine-uid",
"notify-rust",
"objc", "objc",
"reqwest 0.13.3", "reqwest 0.13.3",
"sentry", "sentry",
@@ -281,6 +285,7 @@ dependencies = [
"tauri-plugin-store", "tauri-plugin-store",
"tauri-plugin-updater", "tauri-plugin-updater",
"tinyfiledialogs-rs", "tinyfiledialogs-rs",
"uuid",
"windows-sys 0.59.0", "windows-sys 0.59.0",
"winreg 0.52.0", "winreg 0.52.0",
"x11rb", "x11rb",
+21 -1
View File
@@ -50,6 +50,18 @@ tauri-plugin-deep-link = "2.4.9"
flate2 = "1.1.9" flate2 = "1.1.9"
tar = "0.4.45" tar = "0.4.45"
sha2 = "0.10" sha2 = "0.10"
dirs = "6"
# The headless runner's OS toast (notifications/os.rs) — this is tauri-plugin-notification's own
# desktop backend (same locked version), used directly because the plugin's API needs an
# AppHandle and scheduled-run mode never builds a Tauri app.
notify-rust = "4"
uuid = { version = "1", features = ["v4"] }
# Local wall-clock for the macOS launchd catch-up suppression (the runner skips a fire whose
# local minute doesn't match the cron — a wake-catch-up rather than an on-time fire).
chrono = { version = "0.4", default-features = false, features = ["clock"] }
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "macos")'.dependencies]
cocoa = "0.26" cocoa = "0.26"
@@ -62,4 +74,12 @@ gtk = "0.18"
[target.'cfg(target_os = "windows")'.dependencies] [target.'cfg(target_os = "windows")'.dependencies]
winreg = "0.52" winreg = "0.52"
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_UI_WindowsAndMessaging"] } # Job-object features: the scheduler runner ties the transient rclone daemon's lifetime to a
# KILL_ON_JOB_CLOSE job (scheduler/winjob.rs) so a hard-killed runner can't orphan it.
windows-sys = { version = "0.59", features = [
"Win32_Foundation",
"Win32_UI_WindowsAndMessaging",
"Win32_Security",
"Win32_System_JobObjects",
"Win32_System_Threading",
] }
+110 -2
View File
@@ -12,8 +12,21 @@ mod shortcut;
#[path = "../common/window.rs"] #[path = "../common/window.rs"]
mod window; mod window;
mod notifications;
mod scheduler;
mod zookeeper; mod zookeeper;
/// Entry point for the headless `run-task` mode (see main.rs). Never touches tauri::Builder.
pub fn run_scheduled_task(
task_id: &str,
host_id: &str,
forced: bool,
data_dir: Option<&str>,
local_data_dir: Option<&str>,
) -> i32 {
scheduler::runner::run(task_id, host_id, forced, data_dir, local_data_dir)
}
use shortcut::{ use shortcut::{
ensure_toolbar_window, set_toolbar_shortcut, show_toolbar_window, DEFAULT_TOOLBAR_SHORTCUT, ensure_toolbar_window, set_toolbar_shortcut, show_toolbar_window, DEFAULT_TOOLBAR_SHORTCUT,
}; };
@@ -63,13 +76,19 @@ fn is_linux_mint() -> bool {
} }
} }
/// The single Flatpak permission gate: the app quits at startup unless it holds BOTH writable
/// host filesystem access (rclone needs it) AND host-spawn access (the scheduler needs it). This
/// all-or-nothing check is why no other Flatpak permission checks exist elsewhere — any running
/// instance is guaranteed to have full permissions.
#[tauri::command] #[tauri::command]
fn has_flatpak_permissions() -> bool { fn has_flatpak_permissions() -> bool {
// Native app: no Flatpak permission needed.
if !is_flatpak() { if !is_flatpak() {
return true; return true;
} }
has_host_filesystem() && flatpak_can_spawn_host()
}
fn has_host_filesystem() -> bool {
let Ok(contents) = std::fs::read_to_string("/.flatpak-info") else { let Ok(contents) = std::fs::read_to_string("/.flatpak-info") else {
return false; return false;
}; };
@@ -127,6 +146,76 @@ fn has_flatpak_permissions() -> bool {
false false
} }
/// Whether the sandbox can spawn processes on the host (`flatpak-spawn --host`), which the
/// scheduler needs to register OS cron jobs. Always true off Flatpak. Granted by
/// `--talk-name=org.freedesktop.Flatpak`, which appears in /.flatpak-info under
/// `[Session Bus Policy]` as `org.freedesktop.Flatpak=talk` (or `own`).
pub(crate) fn flatpak_can_spawn_host() -> bool {
if !is_flatpak() {
return true;
}
let Ok(contents) = std::fs::read_to_string("/.flatpak-info") else {
return false;
};
flatpak_info_grants_host_spawn(&contents)
}
/// True when the parsed /.flatpak-info grants `org.freedesktop.Flatpak` in `[Session Bus Policy]`.
fn flatpak_info_grants_host_spawn(contents: &str) -> bool {
let mut in_session_bus = false;
for line in contents.lines() {
let line = line.trim();
if line.starts_with('[') && line.ends_with(']') {
in_session_bus = line == "[Session Bus Policy]";
continue;
}
if !in_session_bus {
continue;
}
if let Some((key, value)) = line.split_once('=') {
if key.trim() == "org.freedesktop.Flatpak" {
let policy = value.trim();
return policy == "talk" || policy == "own";
}
}
}
false
}
#[cfg(test)]
mod flatpak_tests {
use super::flatpak_info_grants_host_spawn;
#[test]
fn detects_granted_talk_permission() {
let info = "[Application]\nname=com.rcloneui.RcloneUI\n\n[Session Bus Policy]\norg.freedesktop.Flatpak=talk\norg.freedesktop.Notifications=talk\n";
assert!(flatpak_info_grants_host_spawn(info));
}
#[test]
fn own_policy_also_counts() {
let info = "[Session Bus Policy]\norg.freedesktop.Flatpak=own\n";
assert!(flatpak_info_grants_host_spawn(info));
}
#[test]
fn absent_or_other_sections_do_not_count() {
// Permission not listed at all.
let info = "[Session Bus Policy]\norg.freedesktop.Notifications=talk\n";
assert!(!flatpak_info_grants_host_spawn(info));
// Same key but in a different section must not match.
let wrong_section = "[System Bus Policy]\norg.freedesktop.Flatpak=talk\n";
assert!(!flatpak_info_grants_host_spawn(wrong_section));
// Explicit 'none' policy.
let none = "[Session Bus Policy]\norg.freedesktop.Flatpak=none\n";
assert!(!flatpak_info_grants_host_spawn(none));
}
}
pub(crate) async fn kill_pid(pid: u32, timeout_ms: Option<u64>) -> Result<(), String> { pub(crate) async fn kill_pid(pid: u32, timeout_ms: Option<u64>) -> Result<(), String> {
let timeout = timeout_ms.unwrap_or(5000); let timeout = timeout_ms.unwrap_or(5000);
@@ -786,7 +875,26 @@ pub fn run() {
zookeeper::download_rclone_version, zookeeper::download_rclone_version,
zookeeper::update_path_pointer, zookeeper::update_path_pointer,
zookeeper::get_rclone_path_integration, zookeeper::get_rclone_path_integration,
zookeeper::set_rclone_path_integration zookeeper::set_rclone_path_integration,
scheduler::scheduler_supported,
scheduler::scheduler_validate_cron,
scheduler::scheduler_register,
scheduler::scheduler_unregister,
scheduler::scheduler_set_enabled,
scheduler::scheduler_run_now,
scheduler::scheduler_status,
scheduler::scheduler_read_history,
scheduler::scheduler_read_log,
scheduler::scheduler_doctor,
scheduler::scheduler_unregister_all,
scheduler::scheduler_sweep_orphans,
notifications::notifications_catalog,
notifications::notifications_list_targets,
notifications::notifications_add_target,
notifications::notifications_update_target,
notifications::notifications_remove_target,
notifications::notifications_dispatch,
notifications::notifications_send_test
]) ])
.setup(|app| { .setup(|app| {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
+34
View File
@@ -1,6 +1,40 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() { fn main() {
// Headless scheduled-task mode: `"Rclone UI" run-task <taskId> [--host <hostId>]`, invoked
// by cron/Task Scheduler. Handled BEFORE app_lib::run() so no GUI, Sentry, or
// single-instance plugin ever initializes (the plugin would otherwise intercept this
// process and just pop the running app's toolbar).
let args: Vec<String> = std::env::args().collect();
if args.len() >= 3 && args[1] == "run-task" {
// Schedulers hand us a bare environment; passCommand helpers and rclone need the
// login-shell PATH.
let _ = fix_path_env::fix();
let flag_value = |flag: &str| {
args.iter()
.position(|a| a == flag)
.and_then(|i| args.get(i + 1))
.cloned()
};
let task_id = args[2].clone();
let host_id = flag_value("--host").unwrap_or_else(|| "local".to_string());
// Set by Run Now (a manual, off-schedule run) — bypasses the macOS launchd catch-up
// suppression so a manual trigger always executes.
let forced = args.iter().any(|a| a == "--forced");
// The GUI's resolved data roots, baked into the trigger at registration — a bare cron
// environment can re-derive different ones (session XDG_DATA_HOME). Absent on triggers
// registered by older versions; the runner then derives them itself.
let data_dir = flag_value("--data-dir");
let local_data_dir = flag_value("--local-data-dir");
std::process::exit(app_lib::run_scheduled_task(
&task_id,
&host_id,
forced,
data_dir.as_deref(),
local_data_dir.as_deref(),
));
}
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
{ {
let is_dri_present = std::path::Path::new("/dev/dri").exists(); let is_dri_present = std::path::Path::new("/dev/dri").exists();
+155
View File
@@ -0,0 +1,155 @@
//! The notification event catalog — the single source of truth for event ids, labels, and
//! severities. types/notifications.d.ts mirrors the id union and lib/notifications.ts renders
//! the drawer checkboxes from `notifications_catalog`; keep all three in sync.
//!
//! Event ids are stable wire strings: they are persisted per-target in targets.json and sent
//! verbatim to generic webhooks. Never rename an id after release.
use serde::Serialize;
#[derive(Debug, Clone, Copy, Serialize)]
pub struct EventMeta {
pub id: &'static str,
pub label: &'static str,
pub description: &'static str,
pub category: &'static str,
pub severity: &'static str,
}
#[derive(Debug, Clone, Copy, Serialize)]
pub struct CategoryMeta {
pub id: &'static str,
pub label: &'static str,
}
pub const CATEGORIES: [CategoryMeta; 3] = [
CategoryMeta {
id: "transfers",
label: "Transfers",
},
CategoryMeta {
id: "schedules",
label: "Scheduled Tasks",
},
CategoryMeta {
id: "system",
label: "System",
},
];
pub const EVENTS: [EventMeta; 10] = [
EventMeta {
id: "job.started",
label: "Transfer started",
description: "A copy, move, sync, bisync, delete or purge job was started manually",
category: "transfers",
severity: "info",
},
EventMeta {
id: "job.completed",
label: "Transfer completed",
description: "A manually started job finished successfully",
category: "transfers",
severity: "success",
},
EventMeta {
id: "job.failed",
label: "Transfer failed",
description: "A manually started job finished with errors",
category: "transfers",
severity: "error",
},
EventMeta {
id: "schedule.started",
label: "Scheduled task started",
description: "A scheduled task began running",
category: "schedules",
severity: "info",
},
EventMeta {
id: "schedule.completed",
label: "Scheduled task completed",
description: "A scheduled task finished successfully",
category: "schedules",
severity: "success",
},
EventMeta {
id: "schedule.failed",
label: "Scheduled task failed",
description: "A scheduled task failed to start or finished with errors",
category: "schedules",
severity: "error",
},
EventMeta {
id: "mount.failed",
label: "Mount failed",
description: "A remote could not be mounted",
category: "system",
severity: "error",
},
EventMeta {
id: "rclone.crashed",
label: "Rclone daemon crashed",
description: "The rclone daemon exited unexpectedly",
category: "system",
severity: "error",
},
EventMeta {
id: "rclone.update-available",
label: "Rclone update available",
description: "A new rclone version is available",
category: "system",
severity: "info",
},
EventMeta {
id: "app.update-available",
label: "App update available",
description: "A new Rclone UI version is available",
category: "system",
severity: "info",
},
];
/// The synthetic event used by "Send Test". Not part of EVENTS: it must never appear in the
/// drawer's checkbox list and no target can subscribe to it.
pub const TEST_EVENT: EventMeta = EventMeta {
id: "test",
label: "Test notification",
description: "A test notification sent from the settings screen",
category: "system",
severity: "info",
};
pub fn find(id: &str) -> Option<&'static EventMeta> {
EVENTS.iter().find(|e| e.id == id)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn catalog_has_the_ten_wire_stable_ids() {
let ids: Vec<&str> = EVENTS.iter().map(|e| e.id).collect();
assert_eq!(
ids,
vec![
"job.started",
"job.completed",
"job.failed",
"schedule.started",
"schedule.completed",
"schedule.failed",
"mount.failed",
"rclone.crashed",
"rclone.update-available",
"app.update-available",
]
);
for event in &EVENTS {
assert!(CATEGORIES.iter().any(|c| c.id == event.category));
assert!(matches!(event.severity, "info" | "success" | "error"));
}
assert!(find("test").is_none(), "test event must stay out of the catalog");
}
}
+124
View File
@@ -0,0 +1,124 @@
//! The notification engine: event catalog, webhook targets + dispatch, and the headless
//! runner's OS toast. The GUI drives it through the commands below (lib/notifications.ts);
//! the scheduler runner calls webhooks::dispatch / os::notify_headless directly. GUI OS toasts
//! are NOT here — JS uses @tauri-apps/plugin-notification for those.
pub mod catalog;
pub mod os;
pub mod targets;
pub mod webhooks;
use serde::Serialize;
use tauri::AppHandle;
use crate::scheduler::storeread;
#[derive(Serialize)]
pub struct Catalog {
pub categories: &'static [catalog::CategoryMeta],
pub events: &'static [catalog::EventMeta],
}
#[tauri::command]
pub fn notifications_catalog() -> Catalog {
Catalog {
categories: &catalog::CATEGORIES,
events: &catalog::EVENTS,
}
}
#[tauri::command]
pub async fn notifications_list_targets(
app: AppHandle,
) -> Result<Vec<targets::NotificationTarget>, String> {
// spawn_blocking: the cross-process store lock can wait up to ~10s under contention.
tauri::async_runtime::spawn_blocking(move || {
let dirs = storeread::app_dirs_from(&app)?;
targets::load(&dirs)
})
.await
.map_err(|e| format!("task failed: {}", e))?
}
#[tauri::command]
pub async fn notifications_add_target(
app: AppHandle,
target: targets::NewTarget,
) -> Result<targets::NotificationTarget, String> {
tauri::async_runtime::spawn_blocking(move || {
let dirs = storeread::app_dirs_from(&app)?;
targets::add(&dirs, target)
})
.await
.map_err(|e| format!("task failed: {}", e))?
}
#[tauri::command]
pub async fn notifications_update_target(
app: AppHandle,
id: String,
patch: targets::TargetPatch,
) -> Result<(), String> {
tauri::async_runtime::spawn_blocking(move || {
let dirs = storeread::app_dirs_from(&app)?;
targets::update(&dirs, &id, patch)
})
.await
.map_err(|e| format!("task failed: {}", e))?
}
#[tauri::command]
pub async fn notifications_remove_target(app: AppHandle, id: String) -> Result<(), String> {
tauri::async_runtime::spawn_blocking(move || {
let dirs = storeread::app_dirs_from(&app)?;
targets::remove(&dirs, &id)
})
.await
.map_err(|e| format!("task failed: {}", e))?
}
/// Fire-and-forget for the caller: delivery failures are recorded per target and logged, never
/// returned as an error (matching the old TS dispatchNotification contract).
#[tauri::command]
pub async fn notifications_dispatch(
app: AppHandle,
event_id: String,
title: String,
body: String,
data: Option<serde_json::Value>,
) -> Result<(), String> {
tauri::async_runtime::spawn_blocking(move || {
let dirs = storeread::app_dirs_from(&app)?;
let client = webhooks::http_client();
for line in webhooks::dispatch(
&dirs,
&client,
&event_id,
&title,
&body,
data.unwrap_or(serde_json::Value::Null),
) {
log::warn!("[notifications] {}", line);
}
Ok(())
})
.await
.map_err(|e| format!("task failed: {}", e))?
}
/// Errors propagate — the UI shows them in the "Test failed" dialog.
#[tauri::command]
pub async fn notifications_send_test(
app: AppHandle,
provider: String,
url: String,
target_id: Option<String>,
name: Option<String>,
) -> Result<(), String> {
tauri::async_runtime::spawn_blocking(move || {
let dirs = storeread::app_dirs_from(&app)?;
webhooks::send_test(&dirs, &provider, &url, target_id.as_deref(), name.as_deref())
})
.await
.map_err(|e| format!("task failed: {}", e))?
}
+52
View File
@@ -0,0 +1,52 @@
//! OS toast for the HEADLESS runner only. The GUI never calls this — it uses
//! @tauri-apps/plugin-notification from JS, whose AppHandle-bound Rust API cannot run in
//! scheduled-run mode (main.rs never builds a Tauri app there). This mirrors what that plugin's
//! desktop.rs does per platform (tauri-plugin-notification 2.3.3), minus the icon handling:
//! notify-rust is the plugin's own desktop backend, so behavior and attribution match.
/// Must match tauri.conf.json `identifier` — the macOS bundle id / Windows AUMID the bundler
/// registers, which is what makes the toast render under the app's name and icon.
const APP_IDENTIFIER: &str = "com.rclone.ui";
pub fn notify_headless(title: &str, body: &str) -> Result<(), String> {
#[cfg(target_os = "macos")]
{
// set_application errors if called twice in a process — guard like the plugin does.
// In dev the binary has no bundle, so borrow Terminal's identity (plugin parity).
static SET_APP: std::sync::Once = std::sync::Once::new();
SET_APP.call_once(|| {
let _ = notify_rust::set_application(if tauri::is_dev() {
"com.apple.Terminal"
} else {
APP_IDENTIFIER
});
});
}
let mut notification = notify_rust::Notification::new();
notification.summary(title).body(body);
#[cfg(windows)]
{
if !tauri::is_dev() {
notification.app_id(APP_IDENTIFIER);
}
}
notification.show().map(|_| ()).map_err(|e| e.to_string())
}
#[cfg(all(test, target_os = "macos"))]
mod tests {
/// Posts a REAL desktop notification (dev identity = Terminal). Ignored by default; run
/// explicitly with `cargo test e2e_macos_toast -- --ignored` and check the toast appears.
#[test]
#[ignore]
fn e2e_macos_toast() {
super::notify_headless(
"Scheduled task completed",
"rclone-ui notifications e2e — this toast is expected",
)
.expect("notify_headless failed");
}
}
+410
View File
@@ -0,0 +1,410 @@
//! The Rust-owned notification-target store: `<app_data>/notifications/targets.json`.
//!
//! Both the GUI (via commands) and the headless runner (recording delivery outcomes) write it,
//! so every read-modify-write cycle runs under a cross-process lock. The lock file is separate
//! from the data file so the atomic tmp+rename data writes never disturb the held lock fd.
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::scheduler::storeread::AppDirs;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NotificationTarget {
pub id: String,
pub provider: String,
pub name: String,
pub url: String,
pub is_enabled: bool,
pub events: Vec<String>,
pub created_at: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_sent_at: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_error: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NewTarget {
pub provider: String,
pub name: String,
pub url: String,
pub is_enabled: bool,
pub events: Vec<String>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TargetPatch {
pub name: Option<String>,
pub url: Option<String>,
pub events: Option<Vec<String>>,
pub is_enabled: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize)]
struct TargetsFile {
version: u32,
targets: Vec<NotificationTarget>,
}
fn notifications_dir(dirs: &AppDirs) -> PathBuf {
dirs.app_data.join("notifications")
}
fn targets_path(dirs: &AppDirs) -> PathBuf {
notifications_dir(dirs).join("targets.json")
}
fn lock_file_path(dirs: &AppDirs) -> PathBuf {
notifications_dir(dirs).join("targets.lock")
}
fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
}
/// Cross-process mutual exclusion for targets.json read-modify-write cycles. Held for
/// milliseconds (never across HTTP sends). Unix: kernel flock — released on crash, valid across
/// Flatpak sandbox PID namespaces; release truncates but never unlinks (an unlink/recreate race
/// would let two processes lock two inodes of the same path). Windows: create_new existence
/// with a stale break well above any real hold time.
pub struct StoreLock {
#[cfg(unix)]
_file: std::fs::File,
#[cfg(not(unix))]
path: PathBuf,
}
#[cfg(not(unix))]
impl Drop for StoreLock {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
const LOCK_ATTEMPTS: u32 = 40;
const LOCK_RETRY_MS: u64 = 250;
#[cfg(not(unix))]
const LOCK_STALE_MS: u64 = 30_000;
#[cfg(unix)]
fn acquire_store_lock(dirs: &AppDirs) -> Result<StoreLock, String> {
use std::os::unix::io::AsRawFd;
let path = lock_file_path(dirs);
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&path)
.map_err(|e| format!("failed to open notifications lock: {}", e))?;
for attempt in 0..LOCK_ATTEMPTS {
let locked = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0;
if locked {
return Ok(StoreLock { _file: file });
}
let err = std::io::Error::last_os_error();
if err.raw_os_error() != Some(libc::EWOULDBLOCK) {
return Err(format!("failed to lock {}: {}", path.display(), err));
}
if attempt + 1 < LOCK_ATTEMPTS {
std::thread::sleep(std::time::Duration::from_millis(LOCK_RETRY_MS));
}
}
Err("another notifications operation is still in progress".to_string())
}
#[cfg(not(unix))]
fn acquire_store_lock(dirs: &AppDirs) -> Result<StoreLock, String> {
let path = lock_file_path(dirs);
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
for attempt in 0..LOCK_ATTEMPTS {
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
{
Ok(mut file) => {
use std::io::Write;
let _ = write!(file, "{}", now_ms());
return Ok(StoreLock { path });
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
// Holds are milliseconds; anything older than the stale window is a crashed
// process that never got to its Drop.
let stale = std::fs::read_to_string(&path)
.ok()
.and_then(|raw| raw.trim().parse::<u64>().ok())
.map(|written| now_ms().saturating_sub(written) > LOCK_STALE_MS)
.unwrap_or(true);
if stale {
let _ = std::fs::remove_file(&path);
continue;
}
if attempt + 1 < LOCK_ATTEMPTS {
std::thread::sleep(std::time::Duration::from_millis(LOCK_RETRY_MS));
}
}
Err(e) => return Err(format!("failed to create notifications lock: {}", e)),
}
}
Err("another notifications operation is still in progress".to_string())
}
fn write_targets(dirs: &AppDirs, targets: &[NotificationTarget]) -> Result<(), String> {
let path = targets_path(dirs);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("failed to create notifications dir: {}", e))?;
}
let file = TargetsFile {
version: 1,
targets: targets.to_vec(),
};
let json = serde_json::to_string_pretty(&file)
.map_err(|e| format!("failed to serialize targets: {}", e))?;
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, json).map_err(|e| format!("failed to write targets: {}", e))?;
std::fs::rename(&tmp, &path).map_err(|e| format!("failed to save targets: {}", e))
}
fn load_locked(dirs: &AppDirs) -> Result<Vec<NotificationTarget>, String> {
let path = targets_path(dirs);
match std::fs::read_to_string(&path) {
Ok(raw) => {
let file: TargetsFile = serde_json::from_str(&raw)
.map_err(|e| format!("invalid targets file {}: {}", path.display(), e))?;
Ok(file.targets)
}
// Not created until the first add — a missing file simply means no targets.
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
Err(e) => Err(format!("failed to read {}: {}", path.display(), e)),
}
}
pub fn load(dirs: &AppDirs) -> Result<Vec<NotificationTarget>, String> {
let _lock = acquire_store_lock(dirs)?;
load_locked(dirs)
}
pub fn add(dirs: &AppDirs, new: NewTarget) -> Result<NotificationTarget, String> {
let _lock = acquire_store_lock(dirs)?;
let mut targets = load_locked(dirs)?;
// Re-checked here under the lock: the drawer's duplicate check reads a snapshot that
// another window (or a concurrent add) may have outdated.
let new_url = new.url.trim().to_lowercase();
if targets.iter().any(|t| t.url.trim().to_lowercase() == new_url) {
return Err("A webhook with this URL is already configured.".to_string());
}
let target = NotificationTarget {
id: uuid::Uuid::new_v4().to_string(),
provider: new.provider,
name: new.name,
url: new.url,
is_enabled: new.is_enabled,
events: new.events,
created_at: now_ms(),
last_sent_at: None,
last_error: None,
};
targets.push(target.clone());
write_targets(dirs, &targets)?;
Ok(target)
}
pub fn update(dirs: &AppDirs, id: &str, patch: TargetPatch) -> Result<(), String> {
let _lock = acquire_store_lock(dirs)?;
let mut targets = load_locked(dirs)?;
if let Some(new_url) = &patch.url {
let normalized = new_url.trim().to_lowercase();
if targets
.iter()
.any(|t| t.id != id && t.url.trim().to_lowercase() == normalized)
{
return Err("A webhook with this URL is already configured.".to_string());
}
}
let Some(target) = targets.iter_mut().find(|t| t.id == id) else {
return Err("Notification target not found.".to_string());
};
if let Some(name) = patch.name {
target.name = name;
}
if let Some(url) = patch.url {
target.url = url;
}
if let Some(events) = patch.events {
target.events = events;
}
if let Some(is_enabled) = patch.is_enabled {
target.is_enabled = is_enabled;
}
write_targets(dirs, &targets)
}
/// Idempotent: removing an id that's already gone is a success, not an error.
pub fn remove(dirs: &AppDirs, id: &str) -> Result<(), String> {
let _lock = acquire_store_lock(dirs)?;
let mut targets = load_locked(dirs)?;
let before = targets.len();
targets.retain(|t| t.id != id);
if targets.len() == before {
return Ok(());
}
write_targets(dirs, &targets)
}
/// Record per-target delivery results (`None` = success). Called after the HTTP sends finished,
/// so the lock was NOT held during them; targets deleted mid-send are skipped silently.
pub fn record_outcomes(dirs: &AppDirs, outcomes: &[(String, Option<String>)]) {
if outcomes.is_empty() {
return;
}
let Ok(_lock) = acquire_store_lock(dirs) else {
return;
};
let Ok(mut targets) = load_locked(dirs) else {
return;
};
let now = now_ms();
let mut changed = false;
for (id, error) in outcomes {
if let Some(target) = targets.iter_mut().find(|t| &t.id == id) {
target.last_sent_at = Some(now);
target.last_error = error.clone();
changed = true;
}
}
if changed {
let _ = write_targets(dirs, &targets);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_dirs(tag: &str) -> AppDirs {
let root = std::env::temp_dir().join(format!("rcloneui-targets-test-{}", tag));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
AppDirs {
app_data: root.clone(),
app_local_data: root,
}
}
#[test]
fn missing_file_means_no_targets_and_is_not_created_by_reads() {
let dirs = test_dirs("missing");
assert!(load(&dirs).unwrap().is_empty());
assert!(!dirs.app_data.join("notifications/targets.json").exists());
let _ = std::fs::remove_dir_all(&dirs.app_data);
}
#[test]
fn add_update_remove_lifecycle() {
let dirs = test_dirs("crud");
let added = add(
&dirs,
NewTarget {
provider: "webhook".into(),
name: "Hook".into(),
url: "https://example.com/hook".into(),
is_enabled: true,
events: vec!["schedule.completed".into()],
},
)
.unwrap();
assert!(!added.id.is_empty());
assert!(added.created_at > 0);
// The written file is camelCase — byte-compatible with the TS NotificationTarget shape.
let raw = std::fs::read_to_string(dirs.app_data.join("notifications/targets.json")).unwrap();
assert!(raw.contains("\"isEnabled\": true"));
assert!(raw.contains("\"createdAt\":"));
// Duplicate URL (case/whitespace-insensitive) is rejected under the lock.
let dup = add(
&dirs,
NewTarget {
provider: "webhook".into(),
name: "Other".into(),
url: " HTTPS://EXAMPLE.COM/HOOK ".into(),
is_enabled: true,
events: vec![],
},
);
assert!(dup.unwrap_err().contains("already configured"));
update(
&dirs,
&added.id,
TargetPatch {
name: Some("Renamed".into()),
is_enabled: Some(false),
..Default::default()
},
)
.unwrap();
let targets = load(&dirs).unwrap();
assert_eq!(targets[0].name, "Renamed");
assert!(!targets[0].is_enabled);
assert!(update(&dirs, "nope", TargetPatch::default())
.unwrap_err()
.contains("not found"));
remove(&dirs, &added.id).unwrap();
remove(&dirs, &added.id).unwrap(); // idempotent
assert!(load(&dirs).unwrap().is_empty());
let _ = std::fs::remove_dir_all(&dirs.app_data);
}
#[test]
fn record_outcomes_updates_existing_and_skips_deleted() {
let dirs = test_dirs("outcomes");
let added = add(
&dirs,
NewTarget {
provider: "slack".into(),
name: "S".into(),
url: "https://hooks.slack.com/services/T1/B1/y".into(),
is_enabled: true,
events: vec![],
},
)
.unwrap();
record_outcomes(
&dirs,
&[
(added.id.clone(), Some("status 502".into())),
("deleted-id".into(), None),
],
);
let targets = load(&dirs).unwrap();
assert_eq!(targets.len(), 1);
assert!(targets[0].last_sent_at.is_some());
assert_eq!(targets[0].last_error.as_deref(), Some("status 502"));
// Success clears the error.
record_outcomes(&dirs, &[(added.id.clone(), None)]);
let targets = load(&dirs).unwrap();
assert_eq!(targets[0].last_error, None);
let _ = std::fs::remove_dir_all(&dirs.app_data);
}
}
+471
View File
@@ -0,0 +1,471 @@
//! Webhook dispatch — the single engine behind both the GUI's `notifications_dispatch` command
//! and the headless scheduler runner. Replaces the old TS dispatcher (lib/notifications) and its
//! hand-synced Rust port (scheduler/notify.rs); payload shapes stay byte-compatible with what
//! the TS dispatcher sent, so existing webhook consumers see no change.
use serde_json::{json, Value};
use super::catalog::{self, EventMeta};
use super::targets::{self, NotificationTarget};
use crate::scheduler::history;
use crate::scheduler::storeread::AppDirs;
fn discord_color(severity: &str) -> u32 {
match severity {
"success" => 0x2ecc71,
"error" => 0xe74c3c,
_ => 0x3498db,
}
}
fn escape_telegram_html(text: &str) -> String {
text.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
struct OutboundRequest {
url: String,
body: Value,
event_header: bool,
}
fn build_request(
target: &NotificationTarget,
event: &EventMeta,
title: &str,
body: &str,
data: &Value,
timestamp: &str,
) -> Result<OutboundRequest, String> {
match target.provider.as_str() {
"slack" => Ok(OutboundRequest {
url: target.url.clone(),
body: json!({ "text": format!("*{}*\n{}", title, body) }),
event_header: false,
}),
"discord" => Ok(OutboundRequest {
url: target.url.clone(),
body: json!({
"username": "Rclone UI",
"embeds": [{
"title": title,
"description": body,
"color": discord_color(event.severity),
"timestamp": timestamp,
}],
}),
event_header: false,
}),
"telegram" => {
// Lift every query param the user configured (chat_id, message_thread_id, …) into
// the JSON body — the Bot API doesn't reliably merge query params with a JSON body.
let parsed = reqwest::Url::parse(&target.url)
.map_err(|e| format!("invalid telegram url: {}", e))?;
let mut payload = serde_json::Map::new();
for (key, value) in parsed.query_pairs() {
payload.insert(key.into_owned(), Value::String(value.into_owned()));
}
payload.insert(
"text".to_string(),
Value::String(format!(
"<b>{}</b>\n{}",
escape_telegram_html(title),
escape_telegram_html(body)
)),
);
payload.insert("parse_mode".to_string(), Value::String("HTML".to_string()));
let mut base = parsed.clone();
base.set_query(None);
Ok(OutboundRequest {
url: base.to_string(),
body: Value::Object(payload),
event_header: false,
})
}
_ => Ok(OutboundRequest {
url: target.url.clone(),
body: json!({
"source": "rclone-ui",
"version": env!("CARGO_PKG_VERSION"),
"event": event.id,
"label": event.label,
"severity": event.severity,
"title": title,
"body": body,
"timestamp": timestamp,
"data": data,
}),
event_header: true,
}),
}
}
pub fn http_client() -> reqwest::Client {
reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(10))
.timeout(std::time::Duration::from_secs(15))
.build()
.unwrap_or_default()
}
/// One retry after 2s, only on network error or 5xx — 4xx means the endpoint rejected the
/// request (bad URL, revoked webhook) and retrying only hammers it.
fn send_once(
client: &reqwest::Client,
request: &OutboundRequest,
event_id: &str,
) -> Result<(), String> {
let mut attempt = 0;
loop {
attempt += 1;
let result = tauri::async_runtime::block_on(async {
let mut req = client.post(&request.url).json(&request.body);
if request.event_header {
req = req.header("X-RcloneUI-Event", event_id);
}
req.send().await
});
match result {
Ok(response) if response.status().is_success() => return Ok(()),
Ok(response) => {
let status = response.status().as_u16();
if status >= 500 && attempt < 2 {
std::thread::sleep(std::time::Duration::from_secs(2));
continue;
}
return Err(format!("Webhook responded with status {}", status));
}
Err(e) => {
if attempt < 2 {
std::thread::sleep(std::time::Duration::from_secs(2));
continue;
}
return Err(format!("{}", e));
}
}
}
}
/// Sends `event_id` to every enabled target subscribed to it and records lastSentAt/lastError
/// per target. Never fails the caller — delivery errors come back as log lines. Targets are
/// read at fire time; the store lock is NOT held during the sends (up to ~17s each).
pub fn dispatch(
dirs: &AppDirs,
client: &reqwest::Client,
event_id: &str,
title: &str,
body: &str,
data: Value,
) -> Vec<String> {
let Some(event) = catalog::find(event_id) else {
return vec![format!("unknown notification event '{}'", event_id)];
};
let targets = match targets::load(dirs) {
Ok(t) => t,
Err(e) => return vec![format!("failed to load notification targets: {}", e)],
};
let timestamp = history::now_iso();
let mut log_lines = Vec::new();
let mut outcomes: Vec<(String, Option<String>)> = Vec::new();
for target in &targets {
if !target.is_enabled || !target.events.iter().any(|e| e == event_id) {
continue;
}
let request = match build_request(target, event, title, body, &data, &timestamp) {
Ok(r) => r,
Err(e) => {
log_lines.push(format!("webhook build failed ({}): {}", target.provider, e));
outcomes.push((target.id.clone(), Some(e)));
continue;
}
};
match send_once(client, &request, event_id) {
Ok(()) => outcomes.push((target.id.clone(), None)),
Err(e) => {
log_lines.push(format!(
"webhook delivery failed ({}): {}",
target.provider, e
));
outcomes.push((target.id.clone(), Some(e)));
}
}
}
targets::record_outcomes(dirs, &outcomes);
log_lines
}
/// Sends the synthetic test payload to one target — which may be unsaved drawer values (no
/// `target_id`). Propagates the delivery error so the UI can surface it; records the outcome
/// only when the target already exists.
pub fn send_test(
dirs: &AppDirs,
provider: &str,
url: &str,
target_id: Option<&str>,
name: Option<&str>,
) -> Result<(), String> {
let event = &catalog::TEST_EVENT;
let body = match name {
Some(n) if !n.is_empty() => {
format!("This is a test notification from Rclone UI for \"{}\".", n)
}
_ => "This is a test notification from Rclone UI.".to_string(),
};
// A throwaway shell: build_request only reads provider + url from the target.
let probe = NotificationTarget {
id: target_id.unwrap_or_default().to_string(),
provider: provider.to_string(),
name: name.unwrap_or_default().to_string(),
url: url.to_string(),
is_enabled: true,
events: Vec::new(),
created_at: 0,
last_sent_at: None,
last_error: None,
};
let timestamp = history::now_iso();
let request = build_request(&probe, event, "Test notification", &body, &Value::Null, &timestamp)?;
let client = http_client();
let result = send_once(&client, &request, event.id);
if let Some(id) = target_id.filter(|id| !id.is_empty()) {
targets::record_outcomes(dirs, &[(id.to_string(), result.clone().err())]);
}
result
}
#[cfg(test)]
mod tests {
use super::*;
fn test_dirs(tag: &str) -> AppDirs {
let root = std::env::temp_dir().join(format!("rcloneui-webhooks-test-{}", tag));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
AppDirs {
app_data: root.clone(),
app_local_data: root,
}
}
/// Minimal one-shot HTTP receiver: accepts a single request, captures head+body, replies 200.
fn local_receiver() -> (String, std::sync::mpsc::Receiver<String>) {
use std::io::{Read, Write};
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("http://{}/hook", listener.local_addr().unwrap());
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut buf = [0u8; 16384];
let mut captured = Vec::new();
// Read until the body announced by Content-Length is complete.
loop {
let n = stream.read(&mut buf).unwrap_or(0);
if n == 0 {
break;
}
captured.extend_from_slice(&buf[..n]);
let text = String::from_utf8_lossy(&captured);
if let Some(head_end) = text.find("\r\n\r\n") {
let content_length = text
.lines()
.find_map(|l| l.to_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0)))
.unwrap_or(0);
if captured.len() >= head_end + 4 + content_length {
break;
}
}
}
let _ = stream.write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 0\r\n\r\n");
let _ = tx.send(String::from_utf8_lossy(&captured).into_owned());
});
(url, rx)
}
/// The full runner-side chain: load targets → filter by event → POST with header → record
/// lastSentAt/lastError back into targets.json (the gap the TS dispatcher had for runs
/// fired while the app was closed).
#[test]
fn dispatch_posts_and_records_outcomes_end_to_end() {
let dirs = test_dirs("dispatch");
let (url, rx) = local_receiver();
let subscribed = targets::add(
&dirs,
targets::NewTarget {
provider: "webhook".into(),
name: "Receiver".into(),
url,
is_enabled: true,
events: vec!["schedule.completed".into()],
},
)
.unwrap();
// Not subscribed to this event — must not be contacted or get an outcome.
let unsubscribed = targets::add(
&dirs,
targets::NewTarget {
provider: "webhook".into(),
name: "Other".into(),
url: "http://127.0.0.1:9/never".into(),
is_enabled: true,
events: vec!["job.failed".into()],
},
)
.unwrap();
let client = http_client();
let lines = dispatch(
&dirs,
&client,
"schedule.completed",
"Scheduled task completed",
"backup completed successfully",
json!({ "scheduleId": "s1" }),
);
assert!(lines.is_empty(), "no delivery errors expected: {:?}", lines);
let raw = rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
assert!(raw.contains("POST /hook"));
assert!(raw.contains("x-rcloneui-event: schedule.completed") || raw.contains("X-RcloneUI-Event: schedule.completed"));
let body_json: Value =
serde_json::from_str(raw.split("\r\n\r\n").nth(1).unwrap()).unwrap();
assert_eq!(body_json["event"], "schedule.completed");
assert_eq!(body_json["severity"], "success");
assert_eq!(body_json["data"]["scheduleId"], "s1");
let after = targets::load(&dirs).unwrap();
let hit = after.iter().find(|t| t.id == subscribed.id).unwrap();
assert!(hit.last_sent_at.is_some());
assert_eq!(hit.last_error, None);
let missed = after.iter().find(|t| t.id == unsubscribed.id).unwrap();
assert_eq!(missed.last_sent_at, None, "unsubscribed target untouched");
let _ = std::fs::remove_dir_all(&dirs.app_data);
}
/// An unreachable endpoint surfaces as a log line and a recorded lastError — never a failure
/// of the dispatch call itself.
#[test]
fn dispatch_records_last_error_on_unreachable_endpoint() {
let dirs = test_dirs("dispatch-err");
// Reserve a port and close it immediately so the connection is refused fast.
let dead_url = {
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
format!("http://{}/hook", l.local_addr().unwrap())
};
let added = targets::add(
&dirs,
targets::NewTarget {
provider: "webhook".into(),
name: "Dead".into(),
url: dead_url,
is_enabled: true,
events: vec!["schedule.failed".into()],
},
)
.unwrap();
let client = http_client();
let lines = dispatch(&dirs, &client, "schedule.failed", "T", "B", Value::Null);
assert_eq!(lines.len(), 1);
assert!(lines[0].contains("webhook delivery failed"));
let after = targets::load(&dirs).unwrap();
let t = after.iter().find(|t| t.id == added.id).unwrap();
assert!(t.last_sent_at.is_some());
assert!(t.last_error.is_some());
let _ = std::fs::remove_dir_all(&dirs.app_data);
}
fn target(provider: &str, url: &str) -> NotificationTarget {
NotificationTarget {
id: "t1".into(),
provider: provider.into(),
name: "T".into(),
url: url.into(),
is_enabled: true,
events: vec![],
created_at: 0,
last_sent_at: None,
last_error: None,
}
}
#[test]
fn payloads_stay_byte_compatible_with_the_ts_dispatcher() {
let event = catalog::find("schedule.failed").unwrap();
let ts = "2026-01-01T00:00:00.000Z";
let slack = build_request(
&target("slack", "https://hooks.slack.com/services/T/B/x"),
event,
"Scheduled task failed",
"job failed: boom",
&Value::Null,
ts,
)
.unwrap();
assert_eq!(
slack.body,
json!({ "text": "*Scheduled task failed*\njob failed: boom" })
);
assert!(!slack.event_header);
let discord = build_request(
&target("discord", "https://discord.com/api/webhooks/1/x"),
event,
"T",
"B",
&Value::Null,
ts,
)
.unwrap();
assert_eq!(discord.body["username"], "Rclone UI");
assert_eq!(discord.body["embeds"][0]["color"], 0xe74c3c);
let telegram = build_request(
&target(
"telegram",
"https://api.telegram.org/bot1:x/sendMessage?chat_id=-100123",
),
event,
"A <b>",
"B & C",
&Value::Null,
ts,
)
.unwrap();
assert_eq!(telegram.url, "https://api.telegram.org/bot1:x/sendMessage");
assert_eq!(telegram.body["chat_id"], "-100123");
assert_eq!(telegram.body["parse_mode"], "HTML");
assert_eq!(telegram.body["text"], "<b>A &lt;b&gt;</b>\nB &amp; C");
let generic = build_request(
&target("webhook", "https://example.com/hook"),
event,
"T",
"B",
&json!({"scheduleId": "s1"}),
ts,
)
.unwrap();
assert!(generic.event_header);
assert_eq!(generic.body["source"], "rclone-ui");
assert_eq!(generic.body["event"], "schedule.failed");
assert_eq!(generic.body["label"], "Scheduled task failed");
assert_eq!(generic.body["severity"], "error");
assert_eq!(generic.body["timestamp"], ts);
assert_eq!(generic.body["data"]["scheduleId"], "s1");
}
}
+958
View File
@@ -0,0 +1,958 @@
//! 5-field cron parsing and conversion to the native scheduler formats: crontab entries on
//! macOS/Linux and Task Scheduler triggers on Windows.
//!
//! Every field is normalized to either a wildcard or an explicit sorted value set — emitting
//! explicit values is more verbose than structural mapping (steps/ranges) but is correct by
//! construction on every backend. Cron's dom/dow OR semantics (when BOTH are restricted, a time
//! matches if EITHER matches) are native to crontab and reproduced for schtasks.
//!
//! Both converters compile on every platform (only one is reachable from production code per
//! target, but the unit tests exercise both everywhere).
#![allow(dead_code)]
use std::collections::BTreeSet;
/// Our self-imposed ceiling on the launchd StartCalendarInterval dicts a single cron expands
/// into (one per firing point, since launchd has no value lists). `launchd.plist(5)` documents no
/// hard maximum; this is purely a guard so a fragmented schedule can't produce an unwieldy plist.
/// Comfortably above any reasonable schedule.
const MAX_SCHEDULE_ENTRIES: usize = 128;
/// Task Scheduler's hard limit: the task XML schema allows at most 48 triggers per task
/// (Task Scheduler schema docs, triggerGroup maxOccurs=48). Exceeding it fails at /Create, so
/// reject at conversion/validation time with an actionable message instead.
const SCHTASKS_MAX_TRIGGERS: usize = 48;
#[derive(Debug, Clone, PartialEq)]
pub struct Field {
pub wildcard: bool,
/// The raw field text STARTS with '*' (a `*/n` step, or `*` itself). Load-bearing for the
/// dom/dow rule: crontab(5) applies the either-field-matches OR only when BOTH day fields
/// are restricted, and Vixie/cronie implement "restricted" as a first-character test (the
/// DOM_STAR/DOW_STAR flags are set before parsing when the field begins with '*'). So `*/n`
/// counts as UNRESTRICTED (its values still constrain matching; the day fields AND), while
/// a mixed list like `1,*/5` counts as RESTRICTED (OR) — exactly as cron executes it.
pub star: bool,
pub values: BTreeSet<u16>,
/// Verbatim (trimmed) field text. Star-origin fields are emitted unchanged into crontab
/// entries — normalizing `*/5` to an explicit list would clear cron's own star flag and
/// silently flip its dom/dow AND semantics to OR.
pub raw: String,
}
impl Field {
fn any() -> Self {
Self {
wildcard: true,
star: true,
values: BTreeSet::new(),
raw: "*".to_string(),
}
}
fn expanded(&self, min: u16, max: u16) -> Vec<u16> {
if self.wildcard {
(min..=max).collect()
} else {
self.values.iter().copied().collect()
}
}
/// "Restricted" in the crontab(5) dom/dow sense: constrains days AND doesn't start with '*'.
fn restricted(&self) -> bool {
!self.wildcard && !self.star
}
}
/// Whether cron's dom/dow OR rule applies (both day fields restricted per crontab(5)). When it
/// doesn't — and neither field is a pure wildcard — the day fields intersect (AND).
fn day_fields_use_or(spec: &CronSpec) -> bool {
spec.dom.restricted() && spec.dow.restricted()
}
#[derive(Debug, Clone)]
pub struct CronSpec {
pub minute: Field,
pub hour: Field,
pub dom: Field,
pub month: Field,
/// 0-6, 0 = Sunday (cron's 7 is normalized to 0).
pub dow: Field,
}
const MONTH_NAMES: [&str; 12] = [
"JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC",
];
const DOW_NAMES: [&str; 7] = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"];
pub fn parse(expr: &str) -> Result<CronSpec, String> {
let trimmed = expr.trim();
if trimmed.starts_with('@') {
let hint = match trimmed.to_ascii_lowercase().as_str() {
"@hourly" => "0 * * * *",
"@daily" | "@midnight" => "0 0 * * *",
"@weekly" => "0 0 * * 0",
"@monthly" => "0 0 1 * *",
"@yearly" | "@annually" => "0 0 1 1 *",
_ => {
return Err(format!(
"'{}' is not supported — use a 5-field cron expression",
trimmed
))
}
};
return Err(format!(
"Nicknames are not supported — use the equivalent 5-field expression: {}",
hint
));
}
let parts: Vec<&str> = trimmed.split_whitespace().collect();
if parts.len() == 6 {
return Err("Seconds are not supported — use a 5-field cron expression".to_string());
}
if parts.len() != 5 {
return Err(format!(
"Expected 5 cron fields (minute hour day month weekday), got {}",
parts.len()
));
}
let minute = parse_field(parts[0], 0, 59, None, "minute")?;
let hour = parse_field(parts[1], 0, 23, None, "hour")?;
let dom = parse_field(parts[2], 1, 31, None, "day of month")?;
let month = parse_field(parts[3], 1, 12, Some(&MONTH_NAMES), "month")?;
let mut dow = parse_field(parts[4], 0, 7, Some(&DOW_NAMES), "day of week")?;
// Normalize dow 7 (also Sunday) to 0.
if dow.values.remove(&7) {
dow.values.insert(0);
}
Ok(CronSpec {
minute,
hour,
dom,
month,
dow,
})
}
fn parse_field(
raw: &str,
min: u16,
max: u16,
names: Option<&[&str]>,
label: &str,
) -> Result<Field, String> {
if raw == "*" {
return Ok(Field::any());
}
let mut values = BTreeSet::new();
for part in raw.split(',') {
if part.is_empty() {
return Err(format!("Empty list entry in {} field", label));
}
let (base, step) = match part.split_once('/') {
Some((b, s)) => {
let step: u16 = s
.parse()
.map_err(|_| format!("Invalid step '{}' in {} field", s, label))?;
if step == 0 {
return Err(format!("Step of 0 in {} field", label));
}
(b, Some(step))
}
None => (part, None),
};
let (start, end) = if base == "*" {
(min, max)
} else if let Some((a, b)) = base.split_once('-') {
let a = parse_value(a, names, label)?;
let b = parse_value(b, names, label)?;
if a > b {
return Err(format!(
"Range '{}' in {} field must be ascending — for wrap-around use two parts, e.g. '{}-{},{}-{}'",
base, label, a, max, min, b
));
}
(a, b)
} else {
let v = parse_value(base, names, label)?;
// "a/n" means: starting at a, every n up to the field max.
if step.is_some() {
(v, max)
} else {
(v, v)
}
};
if start < min || end > max {
return Err(format!(
"Value out of range in {} field (allowed {}-{})",
label, min, max
));
}
let step = step.unwrap_or(1);
let mut v = start;
while v <= end {
values.insert(v);
match v.checked_add(step) {
Some(next) => v = next,
None => break,
}
}
}
if values.is_empty() {
return Err(format!("No values in {} field", label));
}
Ok(Field {
wildcard: false,
star: raw.starts_with('*'),
values,
raw: raw.to_string(),
})
}
fn parse_value(raw: &str, names: Option<&[&str]>, label: &str) -> Result<u16, String> {
if let Ok(v) = raw.parse::<u16>() {
return Ok(v);
}
if let Some(names) = names {
let upper = raw.to_ascii_uppercase();
if let Some(idx) = names.iter().position(|n| *n == upper) {
// Month names are 1-based, dow names 0-based — the caller's names array is ordered
// to match its numeric domain start.
let offset = if names.len() == 12 { 1 } else { 0 };
return Ok(idx as u16 + offset);
}
}
Err(format!("Invalid value '{}' in {} field", raw, label))
}
pub fn validate_for_current_platform(expr: &str) -> Result<(), String> {
let spec = parse(expr)?;
#[cfg(unix)]
{
// crontab accepts every expression the parser accepts (to_crontab is infallible). On
// macOS a user-mode task additionally goes through to_launchd, whose dict cap can reject
// very complex schedules — that surfaces at register time via registrationError rather
// than here (validation doesn't know the task's run mode).
let _ = spec;
Ok(())
}
#[cfg(target_os = "windows")]
{
to_schtasks(&spec).map(|_| ())
}
}
/// Whether a given local wall-clock time matches the spec. Reproduces cron's dom/dow rule: when
/// BOTH day fields are restricted (Vixie's first-character star test — see `Field::star`) a time
/// matches if EITHER matches; otherwise both must match (a `*/n` day step therefore ANDs with
/// the other day field, as Vixie/cronie execute it). `dow` is 0-6, 0 = Sunday.
///
/// Used only by the macOS runner to suppress launchd's wake-catch-up: an on-time launchd fire
/// lands on a minute the schedule matches, a missed-while-asleep catch-up does not.
pub fn matches(spec: &CronSpec, minute: u16, hour: u16, dom: u16, month: u16, dow: u16) -> bool {
fn hit(field: &Field, value: u16) -> bool {
field.wildcard || field.values.contains(&value)
}
if !hit(&spec.minute, minute) || !hit(&spec.hour, hour) || !hit(&spec.month, month) {
return false;
}
if day_fields_use_or(spec) {
return hit(&spec.dom, dom) || hit(&spec.dow, dow);
}
hit(&spec.dom, dom) && hit(&spec.dow, dow)
}
/// The next `count` local wall-clock fire times after `from`, as RFC3339 strings with the local
/// offset. THE preview source of truth: it runs on the exact `matches()` the runner itself uses,
/// so the UI can never predict fires the native schedule won't perform (JS cron libraries
/// classify the dom/dow star flag differently from Vixie cron). Bounded at 5 years — a schedule
/// with no match in that window (e.g. `0 0 31 2 *`) returns what it found.
pub fn next_fires(
spec: &CronSpec,
from: chrono::DateTime<chrono::Local>,
count: usize,
) -> Vec<String> {
use chrono::{Datelike, Duration, SecondsFormat, Timelike};
fn hit(field: &Field, value: u16) -> bool {
field.wildcard || field.values.contains(&value)
}
let mut out = Vec::new();
// Start at the next whole minute; days that can't match are skipped whole (and hours
// likewise), so even a yearly schedule scans ~1800 day probes, not 2.6M minutes.
let mut t = from
.with_second(0)
.and_then(|t| t.with_nanosecond(0))
.unwrap_or(from)
+ Duration::minutes(1);
let horizon = from + Duration::days(5 * 366);
while out.len() < count && t <= horizon {
let day_ok = hit(&spec.month, t.month() as u16) && {
let dom_hit = hit(&spec.dom, t.day() as u16);
let dow_hit = hit(&spec.dow, t.weekday().num_days_from_sunday() as u16);
if day_fields_use_or(spec) {
dom_hit || dow_hit
} else {
dom_hit && dow_hit
}
};
if !day_ok {
// Next CALENDAR day via succ_opt — never `t + 24h`: on a 25-hour fall-back day,
// midnight + 24h is 23:00 of the SAME date, and deriving the "next" day from it
// loops on that midnight forever. A DST gap at the next midnight (earliest() =
// None) falls back to absolute +24h, which always progresses.
t = t
.date_naive()
.succ_opt()
.and_then(|day| day.and_hms_opt(0, 0, 0))
.and_then(|naive| naive.and_local_timezone(chrono::Local).earliest())
.unwrap_or_else(|| t + Duration::days(1));
continue;
}
if !hit(&spec.hour, t.hour() as u16) {
t = t
.with_minute(0)
.map(|t| t + Duration::hours(1))
.unwrap_or(t + Duration::hours(1));
continue;
}
if hit(&spec.minute, t.minute() as u16) {
out.push(t.to_rfc3339_opts(SecondsFormat::Secs, false));
}
t += Duration::minutes(1);
}
out
}
// ---------------------------------------------------------------------------
// crontab (macOS + Linux)
// ---------------------------------------------------------------------------
/// 5-field string for a crontab entry. Star-origin fields (`*` or `*/n` — anything STARTING
/// with '*') are emitted VERBATIM: cron's dom/dow AND-vs-OR decision keys on the leading '*',
/// so normalizing `*/5` into an explicit list would change execution semantics. Everything else
/// is normalized to plain value lists (names/ranges expanded — equivalent on every cron; a
/// mixed `1,*/5` list is already restricted in cron's eyes, so its expansion is too).
pub fn to_crontab(spec: &CronSpec) -> String {
fn plain(field: &Field) -> String {
if field.wildcard {
"*".to_string()
} else if field.star {
field.raw.clone()
} else {
field
.values
.iter()
.map(|v| v.to_string())
.collect::<Vec<_>>()
.join(",")
}
}
format!(
"{} {} {} {} {}",
plain(&spec.minute),
plain(&spec.hour),
plain(&spec.dom),
plain(&spec.month),
plain(&spec.dow)
)
}
// ---------------------------------------------------------------------------
// macOS launchd (StartCalendarInterval)
// ---------------------------------------------------------------------------
/// One `StartCalendarInterval` dict: each key is a single integer (launchd has no range/list
/// syntax), a `None` key means "any", the keys within a dict are ANDed, and multiple dicts are
/// ORed. `weekday` is 0-6, 0 = Sunday.
#[derive(Debug, Clone, PartialEq)]
pub struct LaunchdCalendar {
pub minute: Option<u16>,
pub hour: Option<u16>,
pub day: Option<u16>,
pub weekday: Option<u16>,
pub month: Option<u16>,
}
pub fn to_launchd(spec: &CronSpec) -> Result<Vec<LaunchdCalendar>, String> {
// Day dimension. When cron restricts BOTH dom and dow, a time matches if EITHER matches (OR).
// launchd also ORs a dict's Day and Weekday, so we emit SEPARATE Day-only and Weekday-only
// dicts — |days| + |weekdays| dicts whose union is exactly cron's OR, and the minimal form.
// (Combined Day+Weekday dicts would ALSO reproduce the OR correctly, but only as a redundant
// |days| x |weekdays| Cartesian product; launchd has no lists, so multiple dicts are required
// either way. The one thing launchd cannot express is Day AND Weekday — an intersection cron
// does not want here.)
let mut day_constraints: Vec<(Option<u16>, Option<u16>)> = Vec::new();
match (spec.dom.wildcard, spec.dow.wildcard) {
(true, true) => day_constraints.push((None, None)),
(false, true) => {
for d in spec.dom.expanded(1, 31) {
day_constraints.push((Some(d), None));
}
}
(true, false) => {
for w in spec.dow.expanded(0, 6) {
day_constraints.push((None, Some(w)));
}
}
(false, false) => {
if !day_fields_use_or(spec) {
// A star-step day field (e.g. `*/5` dom) combined with the other day field is
// AND in cron — and launchd ORs a dict's Day and Weekday, so the intersection
// is inexpressible. Fail clearly instead of silently over-firing.
return Err(
"This schedule requires the day of month AND the weekday to match together (a '*/n' day step combined with a weekday restriction) — macOS cannot express that in one scheduled task. Use explicit days of the month (e.g. 1,6,11) or drop one of the two day fields."
.to_string(),
);
}
for d in spec.dom.expanded(1, 31) {
day_constraints.push((Some(d), None));
}
for w in spec.dow.expanded(0, 6) {
day_constraints.push((None, Some(w)));
}
}
}
// A wildcard field contributes a single `None` (the key is omitted = "any").
let opt = |field: &Field, min: u16, max: u16| -> Vec<Option<u16>> {
if field.wildcard {
vec![None]
} else {
field.expanded(min, max).into_iter().map(Some).collect()
}
};
let months = opt(&spec.month, 1, 12);
let hours = opt(&spec.hour, 0, 23);
let minutes = opt(&spec.minute, 0, 59);
let total = day_constraints.len() * months.len() * hours.len() * minutes.len();
if total > MAX_SCHEDULE_ENTRIES {
return Err(format!(
"This schedule expands to {} calendar entries — more than Rclone UI will put in one scheduled task ({}). Simplify the cron expression (for example use an even interval like */15).",
total, MAX_SCHEDULE_ENTRIES
));
}
let mut out = Vec::with_capacity(total);
for (day, weekday) in &day_constraints {
for month in &months {
for hour in &hours {
for minute in &minutes {
out.push(LaunchdCalendar {
minute: *minute,
hour: *hour,
day: *day,
weekday: *weekday,
month: *month,
});
}
}
}
}
Ok(out)
}
// ---------------------------------------------------------------------------
// Windows Task Scheduler
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, PartialEq)]
pub enum DayShape {
Daily,
/// 0 = Sunday.
Weekly(BTreeSet<u16>),
/// Days 1-31 plus months 1-12 (all twelve when the cron month field is a wildcard).
Monthly {
days: BTreeSet<u16>,
months: BTreeSet<u16>,
},
/// Weekdays (0 = Sunday) in specific months, firing in EVERY week of the month
/// (ScheduleByMonthDayOfWeek with weeks 1-4 + Last). This is how Task Scheduler expresses a
/// cron weekday restriction combined with a month restriction — plain Weekly triggers cannot
/// carry months.
MonthlyDow {
dows: BTreeSet<u16>,
months: BTreeSet<u16>,
},
}
#[derive(Debug, Clone, PartialEq)]
pub struct SchtasksTrigger {
pub shape: DayShape,
pub start_hour: u16,
pub start_minute: u16,
/// Repetition interval in minutes with its duration in minutes.
pub repetition: Option<(u16, u16)>,
}
/// (start_hour, start_minute, repetition) — a trigger's time dimension before it is crossed
/// with the day shapes.
type TriggerTime = (u16, u16, Option<(u16, u16)>);
pub fn to_schtasks(spec: &CronSpec) -> Result<Vec<SchtasksTrigger>, String> {
// Day-shape dimension. dom+dow both restricted → both trigger families (triggers OR).
let mut shapes: Vec<DayShape> = Vec::new();
match (spec.dom.wildcard, spec.dow.wildcard, spec.month.wildcard) {
(true, true, true) => shapes.push(DayShape::Daily),
(true, false, true) => shapes.push(DayShape::Weekly(spec.dow.values.clone())),
(false, true, _) | (true, true, false) => shapes.push(DayShape::Monthly {
days: spec
.dom
.expanded(1, 31)
.into_iter()
.collect(),
months: spec
.month
.expanded(1, 12)
.into_iter()
.collect(),
}),
(true, false, false) => {
// Weekly triggers can't carry a month restriction — ScheduleByMonthDayOfWeek (every
// week of the month) expresses "these weekdays, in these months" exactly.
shapes.push(DayShape::MonthlyDow {
dows: spec.dow.values.clone(),
months: spec.month.expanded(1, 12).into_iter().collect(),
});
}
(false, false, month_wild) => {
if !day_fields_use_or(spec) {
// Cron ANDs the day fields here (a star-step day field with the other day field
// restricted); Task Scheduler triggers can only OR. Fail clearly.
return Err(
"This schedule requires the day of month AND the weekday to match together (a '*/n' day step combined with a weekday restriction) — Windows Task Scheduler cannot express that in one task. Use explicit days of the month (e.g. 1,6,11) or drop one of the two day fields."
.to_string(),
);
}
// dom+dow both restricted = cron OR = both trigger families. With a month
// restriction the weekday half needs ScheduleByMonthDayOfWeek; without one a plain
// Weekly trigger is the simpler equivalent.
if month_wild {
shapes.push(DayShape::Weekly(spec.dow.values.clone()));
} else {
shapes.push(DayShape::MonthlyDow {
dows: spec.dow.values.clone(),
months: spec.month.expanded(1, 12).into_iter().collect(),
});
}
shapes.push(DayShape::Monthly {
days: spec.dom.expanded(1, 31).into_iter().collect(),
months: spec.month.expanded(1, 12).into_iter().collect(),
});
}
}
// Time dimension: uniform minute intervals become a Repetition; otherwise one trigger per
// (hour, minute) combination.
//
// The repetition Duration is endpoint-INCLUSIVE: per the RepetitionPattern docs, a duration
// of 4 minutes with a 1-minute interval launches the task FIVE times. So the duration must
// be one interval short of the window (60/1440 min), or the last repeat lands on the top of
// the next hour/day — an extra fire outside the schedule.
let mut times: Vec<TriggerTime> = Vec::new();
if let Some(interval) = uniform_minute_interval(&spec.minute) {
let first = if spec.minute.wildcard {
0
} else {
*spec.minute.values.iter().next().unwrap()
};
if spec.hour.wildcard {
// Repeat all day.
times.push((0, first, Some((interval, 24 * 60 - interval))));
} else {
// One trigger per hour, repeating within that hour.
for hour in spec.hour.expanded(0, 23) {
times.push((hour, first, Some((interval, 60 - interval))));
}
}
} else {
let minutes = spec.minute.expanded(0, 59);
if spec.hour.wildcard {
// Irregular minutes repeated every hour: one daily trigger per minute value,
// repeating hourly for the rest of the day. 23h duration = 23 intervals, which with
// the inclusive endpoint gives exactly 24 fires (…:mm each hour) — a full 24h
// duration would fire once more at the next midnight.
for minute in &minutes {
times.push((0, *minute, Some((60, 24 * 60 - 60))));
}
} else {
for hour in spec.hour.expanded(0, 23) {
for minute in &minutes {
times.push((hour, *minute, None));
}
}
}
}
let total = shapes.len() * times.len();
if total > SCHTASKS_MAX_TRIGGERS {
return Err(format!(
"This schedule expands to {} triggers — more than Windows Task Scheduler supports in one task ({}). Simplify the cron expression (for example use an even interval like */15).",
total, SCHTASKS_MAX_TRIGGERS
));
}
let mut triggers = Vec::with_capacity(total);
for shape in &shapes {
for (hour, minute, repetition) in &times {
triggers.push(SchtasksTrigger {
shape: shape.clone(),
start_hour: *hour,
start_minute: *minute,
repetition: *repetition,
});
}
}
Ok(triggers)
}
/// Some(n) when the minute field fires at a constant interval n that stays aligned across the
/// hour wrap (so a Task Scheduler Repetition every n minutes matches exactly).
fn uniform_minute_interval(minute: &Field) -> Option<u16> {
if minute.wildcard {
return Some(1);
}
let values: Vec<u16> = minute.values.iter().copied().collect();
if values.len() < 2 {
return None;
}
let interval = values[1] - values[0];
if interval == 0 || 60 % interval != 0 {
return None;
}
for pair in values.windows(2) {
if pair[1] - pair[0] != interval {
return None;
}
}
// Must wrap evenly into the next hour.
if (60 - values[values.len() - 1]) + values[0] != interval {
return None;
}
Some(interval)
}
#[cfg(test)]
mod tests {
use super::*;
fn set(values: &[u16]) -> BTreeSet<u16> {
values.iter().copied().collect()
}
#[test]
fn parses_presets() {
let spec = parse("*/15 * * * *").unwrap();
assert!(!spec.minute.wildcard);
assert_eq!(spec.minute.values, set(&[0, 15, 30, 45]));
assert!(spec.hour.wildcard);
}
#[test]
fn parses_names_ranges_lists() {
let spec = parse("0 9-17 1,15 JAN,jul MON-FRI").unwrap();
assert_eq!(spec.hour.values, set(&[9, 10, 11, 12, 13, 14, 15, 16, 17]));
assert_eq!(spec.dom.values, set(&[1, 15]));
assert_eq!(spec.month.values, set(&[1, 7]));
assert_eq!(spec.dow.values, set(&[1, 2, 3, 4, 5]));
}
#[test]
fn normalizes_dow_seven() {
let spec = parse("0 0 * * 7").unwrap();
assert_eq!(spec.dow.values, set(&[0]));
}
#[test]
fn rejects_six_fields_and_bad_values() {
assert!(parse("0 0 0 * * *").is_err());
assert!(parse("61 * * * *").is_err());
assert!(parse("* * * * MOO").is_err());
assert!(parse("5-1 * * * *").is_err());
}
#[test]
fn rejects_nicknames_with_equivalent_hint() {
let err = parse("@daily").unwrap_err();
assert!(err.contains("0 0 * * *"));
let err = parse("@hourly").unwrap_err();
assert!(err.contains("0 * * * *"));
assert!(parse("@bogus").is_err());
}
#[test]
fn wraparound_error_suggests_split() {
let err = parse("50-10 * * * *").unwrap_err();
assert!(err.contains("50-59,0-10"));
}
#[test]
fn schtasks_minute_repetition() {
let spec = parse("*/10 * * * *").unwrap();
let triggers = to_schtasks(&spec).unwrap();
assert_eq!(triggers.len(), 1);
// Duration is endpoint-inclusive, so it stops one interval short of the full day —
// otherwise the last repeat would fire again at the next midnight.
assert_eq!(triggers[0].repetition, Some((10, 24 * 60 - 10)));
assert_eq!(triggers[0].shape, DayShape::Daily);
}
#[test]
fn schtasks_hourly_window_repetition() {
let spec = parse("*/15 9,10 * * *").unwrap();
let triggers = to_schtasks(&spec).unwrap();
assert_eq!(triggers.len(), 2);
// 45, not 60: an inclusive 60-minute duration would add a 10:00 fire to the 9:xx window.
assert_eq!(triggers[0].repetition, Some((15, 45)));
assert_eq!(triggers[0].start_hour, 9);
}
#[test]
fn schtasks_weekly_and_monthly_or() {
let spec = parse("0 3 1 * 1").unwrap();
let triggers = to_schtasks(&spec).unwrap();
assert_eq!(triggers.len(), 2);
assert!(matches!(triggers[0].shape, DayShape::Weekly(_)));
assert!(matches!(triggers[1].shape, DayShape::Monthly { .. }));
}
#[test]
fn schtasks_weekday_with_months_uses_monthly_dow() {
// "every Monday in June" — representable via ScheduleByMonthDayOfWeek (all weeks).
let triggers = to_schtasks(&parse("0 3 * 6 1").unwrap()).unwrap();
assert_eq!(triggers.len(), 1);
assert_eq!(
triggers[0].shape,
DayShape::MonthlyDow {
dows: set(&[1]),
months: set(&[6]),
}
);
}
#[test]
fn schtasks_dom_dow_month_or_uses_both_families() {
// "the 1st OR a Monday, in June, at 03:00" — Monthly + MonthlyDow triggers (cron OR).
let triggers = to_schtasks(&parse("0 3 1 6 1").unwrap()).unwrap();
assert_eq!(triggers.len(), 2);
assert!(triggers.iter().any(|t| matches!(t.shape, DayShape::MonthlyDow { .. })));
assert!(triggers
.iter()
.any(|t| matches!(&t.shape, DayShape::Monthly { days, months } if days == &set(&[1]) && months == &set(&[6]))));
}
#[test]
fn schtasks_irregular_minutes_with_wildcard_hours_use_hourly_repetition() {
// Irregular minutes across every hour: one daily trigger per minute value, each
// repeating hourly. 23h duration + inclusive endpoint = 24 fires/day per trigger.
let triggers = to_schtasks(&parse("1,7,13 * * * *").unwrap()).unwrap();
assert_eq!(triggers.len(), 3);
assert!(triggers
.iter()
.all(|t| t.start_hour == 0 && t.repetition == Some((60, 24 * 60 - 60))));
let starts: Vec<u16> = triggers.iter().map(|t| t.start_minute).collect();
assert_eq!(starts, vec![1, 7, 13]);
// The same shape keeps a 2-minute list at 2 triggers instead of the old 24x2 expansion.
assert_eq!(to_schtasks(&parse("0,10 * * * *").unwrap()).unwrap().len(), 2);
}
#[test]
fn schtasks_cap_is_the_documented_48() {
// 3 irregular minutes x 17 hours = 51 triggers — over the 48-trigger schema limit, so it
// must fail validation instead of failing later at schtasks /Create.
let err = to_schtasks(&parse("0,10,20 0-16 * * *").unwrap()).unwrap_err();
assert!(err.contains("48"), "error should name the limit: {}", err);
// 16 hours x 3 minutes = 48 exactly — allowed.
assert!(to_schtasks(&parse("0,10,20 0-15 * * *").unwrap()).is_ok());
}
#[test]
fn crontab_normalization() {
// Star-origin fields stay verbatim — cron's dom/dow AND-vs-OR decision keys on the '*'
// character, so `*/n` must never be expanded into an explicit list.
assert_eq!(to_crontab(&parse("*/15 * * * *").unwrap()), "*/15 * * * *");
assert_eq!(to_crontab(&parse("0 0 */5 * 1").unwrap()), "0 0 */5 * 1");
// Non-star fields normalize to plain lists (semantically identical on every cron).
assert_eq!(to_crontab(&parse("0 9-11 1 JAN MON").unwrap()), "0 9,10,11 1 1 1");
assert_eq!(to_crontab(&parse("* * * * *").unwrap()), "* * * * *");
}
#[test]
fn star_step_day_fields_use_and_semantics() {
// crontab(5): OR applies only when both day fields are "restricted (i.e., do not
// contain the * character)" — `*/5` dom + `1` dow is therefore AND in Vixie/cronie.
let and = parse("0 0 */5 * 1").unwrap();
assert!(and.dom.star && !and.dom.restricted());
// Monday the 6th: dom ∈ {1,6,11,...} AND Monday → runs.
assert!(matches(&and, 0, 0, 6, 5, 1));
// Monday the 3rd: dom misses → must NOT run (OR semantics would have run it).
assert!(!matches(&and, 0, 0, 3, 5, 1));
// Friday the 6th: dow misses → must NOT run.
assert!(!matches(&and, 0, 0, 6, 5, 5));
// Plain-restricted both sides keeps the OR rule.
let or = parse("0 0 13 * 1").unwrap();
assert!(matches(&or, 0, 0, 13, 5, 4));
assert!(matches(&or, 0, 0, 20, 5, 1));
// The AND intersection is inexpressible on launchd and schtasks — both must reject it
// with an actionable error instead of silently over-firing as OR.
assert!(to_launchd(&and).is_err());
assert!(to_schtasks(&and).is_err());
// Single-day-dimension star steps stay representable (no AND involved).
assert!(to_launchd(&parse("0 0 */5 * *").unwrap()).is_ok());
assert!(to_schtasks(&parse("0 0 * * */2").unwrap()).is_ok());
// Vixie's star flag is a FIRST-CHARACTER test: a mixed list like `1,*/5` does not start
// with '*', so cron treats it as restricted → OR with the other day field. It must also
// stay representable (OR = separate trigger families / dicts).
let mixed = parse("0 0 1,*/5 * 1").unwrap();
assert!(!mixed.dom.star && mixed.dom.restricted());
assert!(matches(&mixed, 0, 0, 3, 5, 1), "Monday the 3rd fires via the dow half (OR)");
assert!(matches(&mixed, 0, 0, 6, 5, 5), "Friday the 6th fires via the dom half (OR)");
assert!(!matches(&mixed, 0, 0, 3, 5, 5), "Friday the 3rd matches neither half");
assert!(to_launchd(&mixed).is_ok());
assert!(to_schtasks(&mixed).is_ok());
// Not star-origin → to_crontab normalizes it to an explicit (still restricted) list.
assert_eq!(to_crontab(&mixed), "0 0 1,6,11,16,21,26,31 * 1");
}
#[test]
fn next_fires_uses_the_runner_semantics() {
let from = chrono::TimeZone::with_ymd_and_hms(&chrono::Local, 2026, 7, 1, 12, 0, 0)
.single()
.unwrap();
// Plain interval: next quarter hours.
let fires = next_fires(&parse("*/15 * * * *").unwrap(), from, 3);
assert_eq!(fires.len(), 3);
assert!(fires[0].starts_with("2026-07-01T12:15:00"), "got {}", fires[0]);
assert!(fires[1].starts_with("2026-07-01T12:30:00"));
assert!(fires[2].starts_with("2026-07-01T12:45:00"));
// `*/5` dom + Monday is AND in cron (the JS preview library said OR — the whole reason
// this exists): only Mondays landing on the 1,6,11,… grid fire.
let fires = next_fires(&parse("0 0 */5 * 1").unwrap(), from, 3);
assert!(fires[0].starts_with("2026-07-06T00:00:00"), "got {}", fires[0]);
assert!(fires[1].starts_with("2026-08-31T00:00:00"), "got {}", fires[1]);
assert!(fires[2].starts_with("2026-09-21T00:00:00"), "got {}", fires[2]);
// Never-matching schedules terminate at the horizon with what they found. This walk
// day-skips across five years of DST fall-back days — the case that once looped forever
// on a 25-hour day (see the succ_opt comment in next_fires).
assert!(next_fires(&parse("0 0 31 2 *").unwrap(), from, 1).is_empty());
}
#[test]
fn uniform_interval_detection() {
assert_eq!(uniform_minute_interval(&parse("*/15 * * * *").unwrap().minute), Some(15));
assert_eq!(uniform_minute_interval(&parse("5,20,35,50 * * * *").unwrap().minute), Some(15));
assert_eq!(uniform_minute_interval(&parse("0,10,30 * * * *").unwrap().minute), None);
assert_eq!(uniform_minute_interval(&parse("30 * * * *").unwrap().minute), None);
}
#[test]
fn launchd_every_minute_is_one_empty_dict() {
let dicts = to_launchd(&parse("* * * * *").unwrap()).unwrap();
assert_eq!(dicts.len(), 1);
assert_eq!(
dicts[0],
LaunchdCalendar { minute: None, hour: None, day: None, weekday: None, month: None }
);
}
#[test]
fn launchd_minute_steps_omit_wildcards() {
let dicts = to_launchd(&parse("*/15 * * * *").unwrap()).unwrap();
assert_eq!(dicts.len(), 4);
let minutes: Vec<Option<u16>> = dicts.iter().map(|d| d.minute).collect();
assert_eq!(minutes, vec![Some(0), Some(15), Some(30), Some(45)]);
assert!(dicts.iter().all(|d| d.hour.is_none() && d.day.is_none() && d.weekday.is_none()));
}
#[test]
fn launchd_hour_minute_product() {
let dicts = to_launchd(&parse("0,30 9,17 * * *").unwrap()).unwrap();
assert_eq!(dicts.len(), 4); // 2 minutes x 2 hours
assert!(dicts.contains(&LaunchdCalendar {
minute: Some(30),
hour: Some(17),
day: None,
weekday: None,
month: None,
}));
}
#[test]
fn launchd_dom_dow_or_splits_into_separate_dicts() {
// "the 13th OR a Monday at 00:00" — 1 Day-only dict + 1 Weekday-only dict, never combined.
let dicts = to_launchd(&parse("0 0 13 * 1").unwrap()).unwrap();
assert_eq!(dicts.len(), 2);
assert!(dicts.contains(&LaunchdCalendar {
minute: Some(0),
hour: Some(0),
day: Some(13),
weekday: None,
month: None,
}));
assert!(dicts.contains(&LaunchdCalendar {
minute: Some(0),
hour: Some(0),
day: None,
weekday: Some(1),
month: None,
}));
// Emitted as separate single-key dicts (the minimal union); we never combine Day+Weekday
// into one dict, which would only be a redundant Cartesian product of the same OR.
assert!(dicts.iter().all(|d| !(d.day.is_some() && d.weekday.is_some())));
}
#[test]
fn launchd_month_restriction() {
let dicts = to_launchd(&parse("0 0 1 1,6 *").unwrap()).unwrap();
assert_eq!(dicts.len(), 2); // 1 day x 2 months
assert!(dicts.iter().all(|d| d.day == Some(1) && d.hour == Some(0)));
let months: Vec<Option<u16>> = dicts.iter().map(|d| d.month).collect();
assert!(months.contains(&Some(1)) && months.contains(&Some(6)));
}
#[test]
fn launchd_cap_rejects_explosive_schedules() {
// 30 explicit minutes x 31 explicit days = 930 dicts (wildcards would omit and not
// explode, so both fields must be explicit lists).
assert!(to_launchd(&parse("*/2 * 1-31 * *").unwrap()).is_err());
}
#[test]
fn matches_reproduces_cron_semantics() {
let spec = parse("*/15 * * * *").unwrap();
assert!(matches(&spec, 0, 3, 10, 6, 2));
assert!(matches(&spec, 45, 23, 31, 12, 0));
assert!(!matches(&spec, 7, 3, 10, 6, 2));
// dom+dow OR: the 13th (any weekday) OR a Friday (any date).
let or = parse("0 0 13 * 5").unwrap();
assert!(matches(&or, 0, 0, 13, 3, 2));
assert!(matches(&or, 0, 0, 20, 3, 5));
assert!(!matches(&or, 0, 0, 20, 3, 2));
// Only dow restricted: dom must not OR in.
let weekly = parse("0 0 * * 1").unwrap();
assert!(matches(&weekly, 0, 0, 20, 3, 1));
assert!(!matches(&weekly, 0, 0, 20, 3, 2));
}
}
+474
View File
@@ -0,0 +1,474 @@
//! The Unix backend (macOS + Linux): the user's crontab.
//!
//! Chosen deliberately over launchd/systemd: one uniform backend, and cron jobs run
//! whether or not the user is logged in (the cron daemon is system-wide). Known trade-offs:
//! there is NO missed-run catch-up (a fire skipped while the machine sleeps is simply skipped),
//! Linux installs need a cron implementation (bundled as a deb/rpm dependency; the in-app
//! message covers AppImage), and on macOS a job touching TCC-protected folders
//! (Desktop/Documents/Downloads) may require granting Full Disk Access to `cron`.
//!
//! Under Flatpak, `crontab` lives on the host, not in the sandbox, so every invocation is
//! wrapped in `flatpak-spawn --host` (which needs `--talk-name=org.freedesktop.Flatpak`). The
//! install temp file therefore goes under the app-data dir — `~/.var/app/<id>/…` is the same
//! absolute path inside and outside the sandbox, so the host `crontab` can read it — never
//! sandbox-private `/tmp`.
//!
//! Layout inside the crontab — a managed pair of lines per task, everything else untouched:
//! # rclone-ui-task: <taskId>
//! <schedule> '<program>' run-task <taskId> --host local >/dev/null 2>&1
//! A disabled task keeps its pair with the entry line prefixed `#off# `.
use std::path::PathBuf;
use std::process::Command;
use super::storeread::AppDirs;
use super::{InstallState, RenderedSchedule, SchedulerBackend};
const MARKER_PREFIX: &str = "# rclone-ui-task: ";
const DISABLED_PREFIX: &str = "#off# ";
/// A `Command` for a host program — direct off Flatpak, `flatpak-spawn --host <program>` inside
/// the sandbox.
pub(crate) fn host_command(program: &str) -> Command {
if crate::is_flatpak() {
let mut cmd = Command::new("flatpak-spawn");
cmd.arg("--host").arg(program);
cmd
} else {
Command::new(program)
}
}
pub fn check_available() -> Result<(), String> {
if crate::is_flatpak() {
// Probe the host for crontab — this also confirms the spawn permission actually works.
let ok = host_command("sh")
.arg("-c")
.arg("command -v crontab")
.output()
.map(|o| o.status.success())
.unwrap_or(false);
return if ok {
Ok(())
} else {
Err("cron is not installed on the host. Install 'cron' (Debian/Ubuntu) or 'cronie' (Fedora/Arch) to enable scheduling.".to_string())
};
}
let found = std::env::var_os("PATH")
.map(|path| std::env::split_paths(&path).any(|dir| dir.join("crontab").is_file()))
.unwrap_or(false);
if found {
Ok(())
} else {
Err("Scheduling requires a cron service. Install 'cron' (Debian/Ubuntu) or 'cronie' (Fedora/Arch) to enable scheduling.".to_string())
}
}
/// Classifies a failed `crontab -l`. Must never misclassify a real failure as "empty" — see
/// read() for the wipe hazard. Vixie/cronie/macOS all phrase the fresh-user case as "no crontab".
pub(crate) fn stderr_means_no_crontab(stderr: &str) -> bool {
stderr.to_lowercase().contains("no crontab")
}
pub struct CrontabBackend {
/// Where the install temp file goes — under app-data so it is host-visible under Flatpak.
tmp_dir: PathBuf,
/// Cross-process lock file for whole-crontab read-modify-write sequences.
lock_path: PathBuf,
}
/// Guards a crontab read→modify→replace against concurrent writers in OTHER processes — the
/// headless runner's orphan self-heal can race the GUI (whose own webviews are serialized by
/// mod.rs::MUTATION_LOCK, which cannot reach a separate process). Without this, two overlapping
/// writes drop each other's managed pair until the next reconcile. flock on a file under
/// app_data works across Flatpak sandbox instances too (same inode via the shared mount).
struct CrontabLock {
_file: std::fs::File,
}
impl CrontabLock {
fn acquire(path: &PathBuf) -> Result<Self, String> {
use std::os::unix::io::AsRawFd;
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(path)
.map_err(|e| format!("failed to open the crontab lock: {}", e))?;
// Bounded wait (~10s): holders finish in milliseconds (one crontab -l + one crontab
// install, plus flatpak-spawn hops), but a wedged holder must not hang the UI forever.
// The lock releases with the fd (kernel-managed — a crashed holder frees it).
for _ in 0..40 {
if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 {
return Ok(Self { _file: file });
}
let err = std::io::Error::last_os_error();
if err.raw_os_error() != Some(libc::EWOULDBLOCK) {
return Err(format!("failed to lock crontab operations: {}", err));
}
std::thread::sleep(std::time::Duration::from_millis(250));
}
Err("another crontab operation is still in progress".to_string())
}
}
impl CrontabBackend {
pub fn new(dirs: &AppDirs) -> Self {
Self {
tmp_dir: dirs.app_data.join("scheduler").join("tmp"),
lock_path: dirs
.app_data
.join("scheduler")
.join("locks")
.join("crontab.lock"),
}
}
/// Current crontab content. Exactly ONE failure is benign — "no crontab for <user>" (a
/// fresh user, exit 1). Every other failure (cron.deny "not allowed", PAM/SELinux denial,
/// spool errors) must abort the operation: treating it as an empty crontab would make the
/// next write silently WIPE the user's real cron jobs.
fn read() -> Result<String, String> {
let output = host_command("crontab")
.arg("-l")
.output()
.map_err(|e| format!("failed to run crontab: {}", e))?;
if output.status.success() {
return Ok(String::from_utf8_lossy(&output.stdout).into_owned());
}
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr_means_no_crontab(&stderr) {
return Ok(String::new());
}
Err(format!("crontab -l failed: {}", stderr.trim()))
}
/// Replaces the whole crontab via a 0600 temp file (`crontab <file>`), preserving every
/// line that isn't one of our managed pairs.
fn write(&self, content: &str) -> Result<(), String> {
std::fs::create_dir_all(&self.tmp_dir)
.map_err(|e| format!("failed to create scheduler tmp dir: {}", e))?;
let tmp = self.tmp_dir.join(format!(
"crontab-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
));
{
use std::io::Write as _;
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
options.mode(0o600);
}
let mut file = options
.open(&tmp)
.map_err(|e| format!("failed to create temp crontab: {}", e))?;
file.write_all(content.as_bytes())
.map_err(|e| format!("failed to write temp crontab: {}", e))?;
}
let result = host_command("crontab")
.arg(&tmp)
.output()
.map_err(|e| format!("failed to run crontab: {}", e));
let _ = std::fs::remove_file(&tmp);
let output = result?;
if !output.status.success() {
return Err(format!(
"crontab install failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
));
}
Ok(())
}
fn marker(task_id: &str) -> String {
format!("{}{}", MARKER_PREFIX, task_id)
}
/// Whether a line is verifiably OUR entry for this task. The line after a marker is only
/// trusted when it references the runner invocation AND the task id — a user who hand-edits
/// their crontab (deletes the entry but leaves the marker, reorders lines) must never get an
/// unrelated cron job deleted, `#off#`-disabled, or executed by Run Now in its place.
fn is_managed_entry(line: &str, task_id: &str) -> bool {
line.contains("run-task") && line.contains(task_id)
}
/// Content with the task's managed pair (marker + following entry) removed. A dangling
/// marker (its entry line missing or not recognizably ours) is removed alone; the foreign
/// line stays.
fn without_pair(content: &str, task_id: &str) -> String {
let marker = Self::marker(task_id);
let mut result = String::new();
let mut lines = content.lines().peekable();
while let Some(line) = lines.next() {
if line.trim() == marker {
if lines
.peek()
.map(|next| Self::is_managed_entry(next, task_id))
.unwrap_or(false)
{
lines.next();
}
continue;
}
result.push_str(line);
result.push('\n');
}
result
}
/// The task's entry line (the validated line after its marker), if present.
fn find_entry(content: &str, task_id: &str) -> Option<String> {
let marker = Self::marker(task_id);
let mut lines = content.lines();
while let Some(line) = lines.next() {
if line.trim() == marker {
return lines
.next()
.filter(|entry| Self::is_managed_entry(entry, task_id))
.map(|entry| entry.to_string());
}
}
None
}
fn build_entry(rendered: &RenderedSchedule) -> Result<String, String> {
let schedule = super::cronconv::to_crontab(&rendered.cron);
// Single-quote the program path AND every arg (embedded quotes get the '\'' dance —
// args include data-dir paths like "~/Library/Application Support/…").
let quote = |raw: &str| format!("'{}'", raw.replace('\'', r"'\''"));
let quoted_program = quote(&rendered.program.to_string_lossy());
let args = rendered
.args
.iter()
.map(|arg| quote(arg))
.collect::<Vec<_>>()
.join(" ");
let inner = format!("{} {} >/dev/null 2>&1", quoted_program, args);
// Wrap the whole command in an explicit `/bin/sh -c '…'`: cron runs entries with
// whatever SHELL= the user's crontab set above our block, and the quoting + redirects
// here are POSIX-shell syntax (csh/tcsh would break on `2>&1` while Run Now — which
// invokes sh directly — kept working). The wrapper makes the entry shell-agnostic.
// '%' is escaped LAST: cron itself unescapes it before any shell sees the line.
let command = format!("/bin/sh -c '{}'", inner.replace('\'', r"'\''")).replace('%', r"\%");
Ok(format!("{} {}", schedule, command))
}
}
impl SchedulerBackend for CrontabBackend {
fn install(&self, task_id: &str, rendered: &RenderedSchedule) -> Result<(), String> {
let entry = Self::build_entry(rendered)?;
// Install directly in the target state — a disabled task must never be briefly armed.
let entry = if rendered.enabled {
entry
} else {
format!("{}{}", DISABLED_PREFIX, entry)
};
let _lock = CrontabLock::acquire(&self.lock_path)?;
let content = Self::read()?;
let mut result = Self::without_pair(&content, task_id);
result.push_str(&Self::marker(task_id));
result.push('\n');
result.push_str(&entry);
result.push('\n');
self.write(&result)
}
fn uninstall(&self, task_id: &str) -> Result<(), String> {
let _lock = CrontabLock::acquire(&self.lock_path)?;
let content = Self::read()?;
let result = Self::without_pair(&content, task_id);
if result == content {
return Ok(());
}
self.write(&result)
}
fn set_enabled(&self, task_id: &str, enabled: bool) -> Result<(), String> {
let _lock = CrontabLock::acquire(&self.lock_path)?;
let content = Self::read()?;
let Some(entry) = Self::find_entry(&content, task_id) else {
return Err(super::NOT_REGISTERED.to_string());
};
let currently_enabled = !entry.starts_with(DISABLED_PREFIX);
if currently_enabled == enabled {
return Ok(());
}
let new_entry = if enabled {
entry.trim_start_matches(DISABLED_PREFIX).to_string()
} else {
format!("{}{}", DISABLED_PREFIX, entry)
};
let mut result = Self::without_pair(&content, task_id);
result.push_str(&Self::marker(task_id));
result.push('\n');
result.push_str(&new_entry);
result.push('\n');
self.write(&result)
}
fn run_now(&self, task_id: &str) -> Result<(), String> {
// cron has no on-demand trigger — run the entry's command directly, detached through
// `sh -c '... &'` so the child re-parents to init and never zombies under the GUI. Under
// Flatpak this runs on the host (the entry is a `flatpak run …` line).
let content = Self::read()?;
let Some(entry) = Self::find_entry(&content, task_id) else {
return Err(super::NOT_REGISTERED.to_string());
};
if entry.starts_with(DISABLED_PREFIX) {
return Err("Task is disabled".to_string());
}
// Strip the 5 schedule fields, keep the command.
let command = entry
.splitn(6, ' ')
.nth(5)
.ok_or("Malformed crontab entry")?;
// Undo cron's `%` escaping (we store `%` as `\%`): cron unescapes before handing the
// command to the shell, so bypassing cron for Run Now must do the same or a program
// path containing `%` would run with a stray backslash.
let command = command.replace(r"\%", "%");
let status = host_command("sh")
.arg("-c")
.arg(format!("{} &", command))
.status()
.map_err(|e| format!("failed to start the task: {}", e))?;
if !status.success() {
return Err("failed to start the task".to_string());
}
Ok(())
}
fn is_installed(&self, task_id: &str) -> Result<InstallState, String> {
let content = Self::read()?;
match Self::find_entry(&content, task_id) {
Some(entry) => Ok(InstallState::Installed {
enabled: !entry.starts_with(DISABLED_PREFIX),
}),
None => Ok(InstallState::NotInstalled),
}
}
}
/// Uninstall managed pairs except those in `keep` (the task ids that still have job files —
/// pass an empty set to sweep everything, as unregister_all does after removing all job files).
pub fn sweep_orphans(backend: &dyn SchedulerBackend, keep: &std::collections::HashSet<String>) -> u32 {
let Ok(content) = CrontabBackend::read() else {
return 0;
};
let ids: Vec<String> = content
.lines()
.filter_map(|line| line.trim().strip_prefix(MARKER_PREFIX))
.map(|id| id.to_string())
.collect();
let mut removed = 0;
for id in ids {
if keep.contains(&id) {
continue;
}
if super::sanitize_id(&id).is_ok() && backend.uninstall(&id).is_ok() {
removed += 1;
}
}
removed
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pair_filtering_preserves_other_lines() {
let content = "PATH=/usr/bin\n# user comment\n0 1 * * * /usr/bin/backup\n# rclone-ui-task: abc\n0 2 * * * 'x' run-task abc --host local >/dev/null 2>&1\n";
let filtered = CrontabBackend::without_pair(content, "abc");
assert!(filtered.contains("PATH=/usr/bin"));
assert!(filtered.contains("/usr/bin/backup"));
assert!(!filtered.contains("rclone-ui-task"));
assert!(!filtered.contains("run-task abc"));
// Unrelated task pairs stay.
let untouched = CrontabBackend::without_pair(content, "other");
assert_eq!(untouched, content);
}
#[test]
fn find_entry_returns_line_after_marker() {
let content = "# rclone-ui-task: t1\n#off# 0 2 * * * run-task t1 cmd\n";
let entry = CrontabBackend::find_entry(content, "t1").unwrap();
assert!(entry.starts_with(DISABLED_PREFIX));
assert!(CrontabBackend::find_entry(content, "t2").is_none());
}
#[test]
fn foreign_line_after_marker_is_never_touched() {
// A hand-edited crontab: the managed entry was deleted (or moved), so the line after our
// marker is the USER'S job. It must never be deleted, disabled, or run in our task's name.
let content = "# rclone-ui-task: abc\n0 4 * * * /usr/bin/backup-my-stuff\n";
assert!(CrontabBackend::find_entry(content, "abc").is_none());
let filtered = CrontabBackend::without_pair(content, "abc");
assert!(filtered.contains("/usr/bin/backup-my-stuff"), "user line survives");
assert!(!filtered.contains("rclone-ui-task"), "dangling marker cleaned up");
// Trailing dangling marker (no following line at all).
let filtered = CrontabBackend::without_pair("# rclone-ui-task: abc\n", "abc");
assert!(!filtered.contains("rclone-ui-task"));
}
#[test]
fn entry_is_wrapped_in_posix_shell() {
let rendered = RenderedSchedule {
cron: super::super::cronconv::parse("*/15 * * * *").unwrap(),
program: std::path::PathBuf::from("/Applications/Rclone UI.app/Contents/MacOS/Rclone UI"),
args: vec![
"run-task".into(),
"abc".into(),
"--data-dir".into(),
"/Users/x/Library/Application Support/com.rclone.ui".into(),
],
display_name: "abc".into(),
user_mode: true,
enabled: true,
max_run_seconds: 86_400,
};
let entry = CrontabBackend::build_entry(&rendered).unwrap();
// Star schedule stays verbatim (cron's dom/dow star semantics).
assert!(entry.starts_with("*/15 * * * * "));
// Shell-agnostic: the whole command runs under an explicit POSIX shell, so a SHELL=csh
// line in the user's crontab can't break the quoting or the redirects.
assert!(entry.contains("/bin/sh -c '"));
// Redirects live INSIDE the sh -c string.
assert!(entry.trim_end().ends_with(">/dev/null 2>&1'"));
// Inner single quotes use the '\'' dance; space-containing args stay one word.
assert!(entry.contains(r"'\''run-task'\''"));
assert!(entry.contains("Application Support"));
}
#[test]
fn only_no_crontab_failures_read_as_empty() {
assert!(stderr_means_no_crontab("crontab: no crontab for alice"));
assert!(stderr_means_no_crontab("no crontab for user\n"));
// Real failures must abort, never be treated as an empty crontab (wipe hazard).
assert!(!stderr_means_no_crontab(
"crontab: you (alice) are not allowed to use this program (cron.deny)"
));
assert!(!stderr_means_no_crontab("crontab: error renaming spool file"));
}
}
+545
View File
@@ -0,0 +1,545 @@
//! Run history (append-only JSONL per task), run locks, and runner log files.
//!
//! The runner is the only writer of a task's history/lock/log; the GUI only reads. History
//! replaces the old zustand isRunning/lastRun/lastRunError fields, which avoids concurrent
//! writes to the store file from two processes.
use std::fs::OpenOptions;
use std::io::Write;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use super::storeread::AppDirs;
const HISTORY_ROTATE_BYTES: u64 = 512 * 1024;
const HISTORY_KEEP_LINES: usize = 200;
const LOG_ROTATE_BYTES: u64 = 1024 * 1024;
pub fn history_path(dirs: &AppDirs, task_id: &str) -> PathBuf {
dirs.app_data
.join("scheduler")
.join("history")
.join(format!("{}.jsonl", task_id))
}
pub fn lock_path(dirs: &AppDirs, task_id: &str) -> PathBuf {
dirs.app_data
.join("scheduler")
.join("locks")
.join(format!("{}.lock", task_id))
}
pub fn log_path(dirs: &AppDirs, task_id: &str) -> PathBuf {
dirs.app_data
.join("scheduler")
.join("logs")
.join(format!("{}.log", task_id))
}
pub fn now_iso() -> String {
// RFC3339 UTC with millisecond precision, no chrono dependency.
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
let secs = now.as_secs();
let millis = now.subsec_millis();
let days = secs / 86_400;
let (year, month, day) = civil_from_days(days as i64);
let rem = secs % 86_400;
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:03}Z",
year,
month,
day,
rem / 3600,
(rem % 3600) / 60,
rem % 60,
millis
)
}
// Howard Hinnant's civil-from-days algorithm.
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u64;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
(if m <= 2 { y + 1 } else { y }, m, d)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "event", rename_all = "lowercase")]
pub enum HistoryLine {
Started {
#[serde(rename = "runId")]
run_id: String,
ts: String,
pid: u32,
#[serde(rename = "hostId")]
host_id: String,
},
Finished {
#[serde(rename = "runId")]
run_id: String,
ts: String,
success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
#[serde(rename = "durationMs")]
duration_ms: u64,
#[serde(skip_serializing_if = "Option::is_none")]
jobids: Option<Vec<i64>>,
#[serde(skip_serializing_if = "Option::is_none")]
stats: Option<serde_json::Value>,
},
Skipped {
ts: String,
reason: String,
},
}
pub fn append(dirs: &AppDirs, task_id: &str, line: &HistoryLine) {
let path = history_path(dirs, task_id);
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
rotate_history_if_needed(&path);
let Ok(json) = serde_json::to_string(line) else {
return;
};
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(&path) {
let _ = writeln!(file, "{}", json);
// fsync: a run that already had external effects must not lose its record to a crash
// or power loss right after finishing.
let _ = file.sync_all();
}
}
fn rotate_history_if_needed(path: &PathBuf) {
let Ok(meta) = std::fs::metadata(path) else {
return;
};
if meta.len() <= HISTORY_ROTATE_BYTES {
return;
}
if let Ok(content) = std::fs::read_to_string(path) {
let lines: Vec<&str> = content.lines().collect();
let keep = lines.len().saturating_sub(HISTORY_KEEP_LINES);
let trimmed = lines[keep..].join("\n");
let _ = std::fs::write(path, format!("{}\n", trimmed));
}
}
/// Last `limit` parsed lines, newest first. Unparseable lines are skipped.
pub fn read(dirs: &AppDirs, task_id: &str, limit: usize) -> Vec<serde_json::Value> {
let path = history_path(dirs, task_id);
let Ok(content) = std::fs::read_to_string(&path) else {
return Vec::new();
};
content
.lines()
.rev()
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
.take(limit)
.collect()
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LockInfo {
pub pid: u32,
pub started_at_ms: u64,
/// Process name of the lock holder — guards liveness checks against pid reuse after a
/// crash/reboot (a recycled pid would otherwise keep the task "running" for 24h).
#[serde(default)]
pub process_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub daemon_pid: Option<u32>,
/// OS start time (seconds since epoch) of the recorded daemon process. Identifies the
/// process GENERATION: a recycled pid — which could be anything, including the GUI's own
/// rclone daemon — never matches, and a custom-named rclone binary still does.
#[serde(skip_serializing_if = "Option::is_none")]
pub daemon_start_time: Option<u64>,
}
fn current_process_name() -> String {
std::env::current_exe()
.ok()
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
.unwrap_or_default()
}
/// True when `pid` is alive AND (when recorded) still runs under the recorded process name.
/// Windows-only: on Unix the flock IS the liveness check (and pids are meaningless across
/// Flatpak sandbox PID namespaces anyway).
#[cfg(not(unix))]
fn pid_is_this_holder(pid: u32, expected_name: &str) -> bool {
let pid = sysinfo::Pid::from_u32(pid);
let mut system = sysinfo::System::new();
system.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[pid]), true);
let Some(process) = system.process(pid) else {
return false;
};
if expected_name.is_empty() {
return true; // pre-identity lock file: fall back to bare liveness
}
process.name().to_string_lossy() == expected_name
}
/// Leftover transient daemon cleanup of last resort: kill the recorded daemon pid, but only
/// after verifying it is still OUR process. The recorded OS start time identifies the process
/// generation exactly (±2s for clock rounding) — a recycled pid never matches, and custom-named
/// rclone binaries are still covered. Locks written before the start-time field fall back to
/// the old name check (which refuses custom names and, in the worst pid-reuse case, could match
/// an unrelated rclone — acceptable only for that legacy window).
fn kill_stale_daemon(info: &LockInfo) {
let Some(daemon_pid) = info.daemon_pid else {
return;
};
let pid = sysinfo::Pid::from_u32(daemon_pid);
let mut sys = sysinfo::System::new();
sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[pid]), true);
let Some(proc_) = sys.process(pid) else {
return;
};
let is_ours = match info.daemon_start_time {
Some(recorded) => proc_.start_time().abs_diff(recorded) <= 2,
None => {
let name = proc_.name().to_string_lossy().to_lowercase();
name == "rclone" || name == "rclone.exe"
}
};
if is_ours {
proc_.kill();
}
}
pub enum LockResult {
Acquired(RunLock),
Held,
}
/// The held run lock. On Unix it owns the flock'd file handle: the kernel releases the lock the
/// moment the holder dies (crash-safe, no stale heuristics) and the lock is visible across
/// Flatpak sandboxes, whose separate PID namespaces make pid-liveness checks meaningless there
/// (a namespace-local pid from another sandbox is unfindable — or worse, matches an unrelated
/// process). On Windows the lock file's existence plus pid checks remain the mechanism.
pub struct RunLock {
#[cfg(unix)]
file: std::fs::File,
#[cfg(not(unix))]
path: PathBuf,
}
impl RunLock {
pub fn release(self) {
#[cfg(unix)]
{
// Truncate (a clean end must not leave daemon info for the next acquire to "reap")
// and let the flock drop with the fd. The file itself stays: unlinking would open an
// unlink/recreate race where two runners hold locks on two inodes of the same path.
let _ = self.file.set_len(0);
}
#[cfg(not(unix))]
{
let _ = std::fs::remove_file(&self.path);
}
}
}
fn lock_info_now() -> LockInfo {
LockInfo {
pid: std::process::id(),
started_at_ms: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64,
process_name: current_process_name(),
daemon_pid: None,
daemon_start_time: None,
}
}
/// Try to take the run lock.
///
/// Unix: an exclusive flock held for the runner's lifetime. Non-empty leftover content means the
/// previous run crashed without releasing (clean release truncates) — its recorded transient
/// daemon is reaped first. `max_run_seconds` is unused here: a hung (not crashed) runner keeps
/// the flock, and its own deadline/SIGTERM handling is what unwedges it.
#[cfg(unix)]
pub fn acquire_lock(
dirs: &AppDirs,
task_id: &str,
max_run_seconds: u64,
) -> Result<LockResult, String> {
use std::os::unix::io::AsRawFd;
let _ = max_run_seconds;
let path = lock_path(dirs, task_id);
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
// Two attempts with a pause: the GUI's is_running probe holds a shared lock for
// microseconds and must not turn a real fire into an "already-running" skip.
for attempt in 0..2 {
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&path)
.map_err(|e| format!("failed to create lock file: {}", e))?;
let locked = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0;
if locked {
if let Ok(raw) = std::fs::read_to_string(&path) {
if !raw.trim().is_empty() {
log::warn!("task {} lock was left by a crashed run — cleaning up", task_id);
if let Ok(stale) = serde_json::from_str::<LockInfo>(&raw) {
kill_stale_daemon(&stale);
}
}
}
file.set_len(0)
.map_err(|e| format!("failed to write lock file: {}", e))?;
(&file)
.write_all(
serde_json::to_string(&lock_info_now())
.unwrap_or_default()
.as_bytes(),
)
.map_err(|e| format!("failed to write lock file: {}", e))?;
return Ok(LockResult::Acquired(RunLock { file }));
}
let err = std::io::Error::last_os_error();
if err.raw_os_error() != Some(libc::EWOULDBLOCK) {
return Err(format!("failed to lock {}: {}", path.display(), err));
}
if attempt == 0 {
std::thread::sleep(std::time::Duration::from_millis(150));
}
}
Ok(LockResult::Held)
}
/// Windows: lock-file existence with pid+name liveness. A stale lock (dead/renamed pid, or older
/// than max_run_seconds + 5 min) is broken; any recorded transient daemon still alive AND named
/// rclone is killed first.
#[cfg(not(unix))]
pub fn acquire_lock(
dirs: &AppDirs,
task_id: &str,
max_run_seconds: u64,
) -> Result<LockResult, String> {
let path = lock_path(dirs, task_id);
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
for attempt in 0..2 {
match OpenOptions::new().write(true).create_new(true).open(&path) {
Ok(mut file) => {
let _ = file.write_all(
serde_json::to_string(&lock_info_now())
.unwrap_or_default()
.as_bytes(),
);
return Ok(LockResult::Acquired(RunLock { path }));
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
if attempt > 0 {
return Ok(LockResult::Held);
}
if !is_lock_stale(&path, max_run_seconds) {
return Ok(LockResult::Held);
}
log::warn!("breaking stale lock for task {}", task_id);
let _ = std::fs::remove_file(&path);
}
Err(e) => return Err(format!("failed to create lock file: {}", e)),
}
}
Ok(LockResult::Held)
}
#[cfg(not(unix))]
fn is_lock_stale(path: &PathBuf, max_run_seconds: u64) -> bool {
let Ok(raw) = std::fs::read_to_string(path) else {
return true; // unreadable lock = stale
};
let Ok(info) = serde_json::from_str::<LockInfo>(&raw) else {
return true;
};
let now_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
let expired = now_ms.saturating_sub(info.started_at_ms) > (max_run_seconds + 300) * 1000;
if pid_is_this_holder(info.pid, &info.process_name) && !expired {
return false;
}
kill_stale_daemon(&info);
true
}
/// Record the transient daemon's pid (and its OS start time, the pid-reuse-proof identity for
/// later cleanup) into the held lock. Best effort.
pub fn record_daemon_pid(dirs: &AppDirs, task_id: &str, daemon_pid: u32) {
let path = lock_path(dirs, task_id);
let Ok(raw) = std::fs::read_to_string(&path) else {
return;
};
let Ok(mut info) = serde_json::from_str::<LockInfo>(&raw) else {
return;
};
info.daemon_pid = Some(daemon_pid);
let pid = sysinfo::Pid::from_u32(daemon_pid);
let mut sys = sysinfo::System::new();
sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[pid]), true);
info.daemon_start_time = sys.process(pid).map(|p| p.start_time());
let _ = std::fs::write(&path, serde_json::to_string(&info).unwrap_or_default());
}
/// Whether a live run currently holds the lock. Unix: a shared-lock probe — it fails
/// (EWOULDBLOCK) exactly while a runner holds the exclusive flock, and works across Flatpak
/// sandboxes where pid checks cannot. The probe's own momentary lock drops with the fd.
#[cfg(unix)]
pub fn is_running(dirs: &AppDirs, task_id: &str) -> bool {
use std::os::unix::io::AsRawFd;
let Ok(file) = OpenOptions::new().read(true).open(lock_path(dirs, task_id)) else {
return false;
};
let free = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_SH | libc::LOCK_NB) } == 0;
!free && std::io::Error::last_os_error().raw_os_error() == Some(libc::EWOULDBLOCK)
}
/// Whether a live run currently holds the lock (Windows: pid+name liveness).
#[cfg(not(unix))]
pub fn is_running(dirs: &AppDirs, task_id: &str) -> bool {
let path = lock_path(dirs, task_id);
let Ok(raw) = std::fs::read_to_string(&path) else {
return false;
};
let Ok(info) = serde_json::from_str::<LockInfo>(&raw) else {
return false;
};
pid_is_this_holder(info.pid, &info.process_name)
}
/// Rename `path` to `.old` when it exceeds `max_bytes` (single-generation rotation).
pub fn rotate_file(path: &PathBuf, max_bytes: u64) {
if let Ok(meta) = std::fs::metadata(path) {
if meta.len() > max_bytes {
let mut old = path.as_os_str().to_owned();
old.push(".old");
let _ = std::fs::rename(path, std::path::PathBuf::from(old));
}
}
}
/// Simple appending logger for the runner, rotated at 1 MB.
pub struct RunLog {
file: Option<std::fs::File>,
}
impl RunLog {
pub fn open(dirs: &AppDirs, task_id: &str) -> Self {
let path = log_path(dirs, task_id);
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(meta) = std::fs::metadata(&path) {
if meta.len() > LOG_ROTATE_BYTES {
let _ = std::fs::rename(&path, path.with_extension("log.old"));
}
}
let file = OpenOptions::new().create(true).append(true).open(&path).ok();
Self { file }
}
pub fn line(&mut self, message: &str) {
if let Some(file) = &mut self.file {
let _ = writeln!(file, "[{}] {}", now_iso(), message);
}
}
}
pub fn remove_all(dirs: &AppDirs, task_id: &str) {
let _ = std::fs::remove_file(history_path(dirs, task_id));
let _ = std::fs::remove_file(lock_path(dirs, task_id));
let _ = std::fs::remove_file(log_path(dirs, task_id));
let _ = std::fs::remove_file(log_path(dirs, task_id).with_extension("log.old"));
// The runner also writes the transient daemon's stderr to `<task>.daemon.log`
// (rotated to `.daemon.log.old`); remove both so unregistering leaves nothing behind.
let _ = std::fs::remove_file(log_path(dirs, task_id).with_extension("daemon.log"));
let _ = std::fs::remove_file(log_path(dirs, task_id).with_extension("daemon.log.old"));
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
fn test_dirs(tag: &str) -> AppDirs {
let root = std::env::temp_dir().join(format!("rcloneui-lock-test-{}", tag));
let _ = std::fs::remove_dir_all(&root);
AppDirs {
app_data: root.clone(),
app_local_data: root,
}
}
#[test]
fn flock_mutual_exclusion_probe_and_crash_release() {
let dirs = test_dirs("flock");
let task = "t1";
// Free → acquired; the GUI probe must see it as running while held.
let lock = match acquire_lock(&dirs, task, 60).unwrap() {
LockResult::Acquired(lock) => lock,
LockResult::Held => panic!("fresh lock reported held"),
};
assert!(is_running(&dirs, task), "probe sees the held lock");
// A second open+flock (even in the same process — flock is per open-file-description)
// must report Held, not break the live lock like the old pid heuristics could.
assert!(matches!(
acquire_lock(&dirs, task, 60).unwrap(),
LockResult::Held
));
// Clean release: probe clears, file stays (truncated), re-acquire works.
lock.release();
assert!(!is_running(&dirs, task));
assert!(lock_path(&dirs, task).exists(), "release truncates, never unlinks");
assert_eq!(std::fs::read(lock_path(&dirs, task)).unwrap(), b"");
// Crash: dropping without release leaves content behind but the kernel frees the lock —
// the next acquire must succeed on its own, with no staleness heuristics.
let crashed = match acquire_lock(&dirs, task, 60).unwrap() {
LockResult::Acquired(lock) => lock,
LockResult::Held => panic!("re-acquire after release failed"),
};
drop(crashed);
assert!(!is_running(&dirs, task), "kernel released the crashed lock");
assert!(
!std::fs::read(lock_path(&dirs, task)).unwrap().is_empty(),
"crashed run leaves its record for daemon cleanup"
);
assert!(matches!(
acquire_lock(&dirs, task, 60).unwrap(),
LockResult::Acquired(_)
));
let _ = std::fs::remove_dir_all(&dirs.app_data);
}
}
+130
View File
@@ -0,0 +1,130 @@
//! The per-task job file: the static definition the headless runner executes.
//!
//! Written only by the `scheduler_register` command (atomic temp+rename); read by the runner and
//! by `scheduler_status`. Dynamic state (passwords, proxy, webhook targets) is deliberately NOT
//! stored here — the runner resolves it live from the app stores so it never goes stale.
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use super::storeread::AppDirs;
pub const JOB_SCHEMA_VERSION: u32 = 1;
pub const DEFAULT_MAX_RUN_SECONDS: u64 = 86_400;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RcRequest {
/// e.g. "/job/batch", "/sync/sync", "/sync/bisync" — POSTed to the transient daemon.
pub endpoint: String,
/// JSON body. The TS serializer folds what were query params into the body and always sets
/// `_async: true`; rclone's RC treats query and body parameters identically.
pub body: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct JobSpec {
pub schema_version: u32,
pub task_id: String,
pub host_id: String,
pub name: String,
pub operation: String,
pub cron: String,
pub config_id: String,
/// "app-default" or an absolute path to a specific rclone binary.
pub binary: String,
#[serde(default = "default_max_run_seconds")]
pub max_run_seconds: u64,
/// Raise the transient daemon to INFO logging (per-transfer lines in the daemon log).
#[serde(default)]
pub verbose_logging: bool,
/// "user" (the default): only runs while the user is logged in — on Unix the runner gates on
/// an active session and borrows its context; on Windows the task uses the interactive logon
/// type. "system": runs even while logged out, but outside the login session (no OS keychain,
/// session-mounted drives, and on macOS cron's TCC attribution for protected folders).
/// Absent field = "user": the legacy in-app scheduler only ever ran inside the app session,
/// so migrated tasks keep their effective semantics.
#[serde(default = "default_run_mode")]
pub run_mode: String,
pub requests: Vec<RcRequest>,
}
impl JobSpec {
/// Anything that isn't explicitly "system" runs in user mode (the default, and the safer
/// interpretation for unknown values — it skips logged-out fires instead of failing them).
pub fn is_user_mode(&self) -> bool {
self.run_mode != "system"
}
}
fn default_max_run_seconds() -> u64 {
DEFAULT_MAX_RUN_SECONDS
}
fn default_run_mode() -> String {
"user".to_string()
}
pub fn jobs_dir(dirs: &AppDirs, host_id: &str) -> PathBuf {
dirs.app_data.join("scheduler").join("jobs").join(host_id)
}
pub fn job_path(dirs: &AppDirs, host_id: &str, task_id: &str) -> PathBuf {
jobs_dir(dirs, host_id).join(format!("{}.json", task_id))
}
pub fn load(dirs: &AppDirs, host_id: &str, task_id: &str) -> Result<JobSpec, String> {
let path = job_path(dirs, host_id, task_id);
let raw = std::fs::read_to_string(&path)
.map_err(|e| format!("failed to read job file {}: {}", path.display(), e))?;
let spec: JobSpec =
serde_json::from_str(&raw).map_err(|e| format!("invalid job file: {}", e))?;
if spec.schema_version > JOB_SCHEMA_VERSION {
return Err(format!(
"job file schema {} is newer than this app supports ({})",
spec.schema_version, JOB_SCHEMA_VERSION
));
}
Ok(spec)
}
/// Atomic write: temp file in the same directory, then rename over the target.
pub fn save(dirs: &AppDirs, spec: &JobSpec) -> Result<(), String> {
let dir = jobs_dir(dirs, &spec.host_id);
std::fs::create_dir_all(&dir).map_err(|e| format!("failed to create jobs dir: {}", e))?;
let target = dir.join(format!("{}.json", spec.task_id));
let tmp = dir.join(format!("{}.json.tmp", spec.task_id));
let json = serde_json::to_string_pretty(spec).map_err(|e| e.to_string())?;
std::fs::write(&tmp, json).map_err(|e| format!("failed to write job file: {}", e))?;
std::fs::rename(&tmp, &target).map_err(|e| format!("failed to move job file: {}", e))?;
Ok(())
}
pub fn remove(dirs: &AppDirs, host_id: &str, task_id: &str) {
let _ = std::fs::remove_file(job_path(dirs, host_id, task_id));
}
/// All job specs registered for a host (unreadable files skipped with a log line).
pub fn list(dirs: &AppDirs, host_id: &str) -> Vec<JobSpec> {
let dir = jobs_dir(dirs, host_id);
let Ok(entries) = std::fs::read_dir(&dir) else {
return Vec::new();
};
let mut specs = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
match std::fs::read_to_string(&path)
.map_err(|e| e.to_string())
.and_then(|raw| serde_json::from_str::<JobSpec>(&raw).map_err(|e| e.to_string()))
{
Ok(spec) => specs.push(spec),
Err(e) => log::warn!("skipping unreadable job file {}: {}", path.display(), e),
}
}
specs
}
+593
View File
@@ -0,0 +1,593 @@
//! macOS backend for USER-mode tasks: per-user launchd LaunchAgents.
//!
//! Chosen over crontab for user mode because a LaunchAgent runs inside the user's Aqua login
//! session — it has the login Keychain, session-mounted `/Volumes`, and (crucially) TCC attributes
//! protected-folder access to the app's own code signature, so the task inherits the grants the
//! user gave Rclone UI rather than needing Full Disk Access on `/usr/sbin/cron`. It runs even when
//! the app is closed, but only while the user is logged in (launchd loads agents at login and
//! boots them out at logout) — exactly the "User" run-mode contract.
//!
//! macOS SYSTEM-mode tasks stay on crontab (see `crontab.rs`); this backend is user-mode only.
//!
//! Enabled state is durable via FILE LOCATION, not `launchctl disable` (whose override database
//! outlives reinstalls): an enabled agent's plist lives in `~/Library/LaunchAgents/` (auto-loaded
//! at every login); a disabled agent's plist is "parked" under app-data so launchd never sees it.
use std::path::PathBuf;
use std::process::{Command, Stdio};
use super::cronconv::{self, LaunchdCalendar};
use super::storeread::AppDirs;
use super::{InstallState, RenderedSchedule, SchedulerBackend};
const LABEL_PREFIX: &str = "com.rclone.ui.task.";
/// Must match tauri.conf.json `identifier`. Since macOS 13, LaunchAgents surface in System
/// Settings → General → Login Items & Extensions as user-toggleable background items;
/// AssociatedBundleIdentifiers is what makes ours appear under the app's name and icon there
/// instead of an anonymous developer entry.
const APP_BUNDLE_ID: &str = "com.rclone.ui";
pub struct LaunchdBackend {
/// `~/Library/LaunchAgents` — launchd auto-loads every *.plist here at login.
launch_agents_dir: PathBuf,
/// Disabled agents are parked here (outside the auto-load dir) so they stay off across logins.
parked_dir: PathBuf,
/// launchd stdout/stderr sink for each agent.
log_dir: PathBuf,
}
impl LaunchdBackend {
pub fn new(dirs: &AppDirs) -> Self {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
Self {
launch_agents_dir: home.join("Library").join("LaunchAgents"),
parked_dir: dirs.app_data.join("scheduler").join("launchd-parked"),
log_dir: dirs.app_data.join("scheduler").join("logs"),
}
}
fn label(task_id: &str) -> String {
format!("{}{}", LABEL_PREFIX, task_id)
}
fn plist_name(task_id: &str) -> String {
format!("{}{}.plist", LABEL_PREFIX, task_id)
}
fn active_path(&self, task_id: &str) -> PathBuf {
self.launch_agents_dir.join(Self::plist_name(task_id))
}
fn parked_path(&self, task_id: &str) -> PathBuf {
self.parked_dir.join(Self::plist_name(task_id))
}
fn uid() -> u32 {
unsafe { libc::getuid() }
}
fn domain() -> String {
format!("gui/{}", Self::uid())
}
fn service_target(task_id: &str) -> String {
format!("gui/{}/{}", Self::uid(), Self::label(task_id))
}
fn launchctl(args: &[&str]) -> std::io::Result<std::process::Output> {
Command::new("launchctl").args(args).output()
}
/// Whether the service is currently loaded in the gui domain (`launchctl print` exits 0 for a
/// loaded service, 113 otherwise). This is the check that makes install/enable idempotent:
/// re-bootstrapping a loaded agent FAILS ("Bootstrap failed: 5: Input/output error"), and
/// booting it out first would kill a running instance.
fn is_loaded(task_id: &str) -> bool {
Self::launchctl(&["print", &Self::service_target(task_id)])
.map(|o| o.status.success())
.unwrap_or(false)
}
/// Unload the service. Not-loaded ("Boot-out failed: 3: No such process") is benign; any
/// other failure is reported — discarding it would let a disable/uninstall report success
/// while the loaded service keeps firing until logout.
fn bootout(task_id: &str) -> Result<(), String> {
let output = Self::launchctl(&["bootout", &Self::service_target(task_id)])
.map_err(|e| format!("failed to run launchctl bootout: {}", e))?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("No such process") || output.status.code() == Some(3) {
return Ok(());
}
Err(format!("launchctl bootout failed: {}", stderr.trim()))
}
fn bootstrap(&self, task_id: &str) -> Result<(), String> {
// The plist's StandardOutPath/StandardErrorPath live here, and launchd never creates
// intermediate directories (the job still runs, but its output silently goes nowhere).
// Created here — the one choke point every load goes through — because a task first
// installed DISABLED skips install()'s enabled path and reaches launchd only via a
// later set_enabled(true) → bootstrap().
std::fs::create_dir_all(&self.log_dir)
.map_err(|e| format!("failed to create scheduler log dir: {}", e))?;
// Clear any stale disabled-override for this label before loading. We never write one
// ourselves (our disable = parking the plist file, not `launchctl disable`), but a prior
// app version or a user running `launchctl disable gui/<uid>/<label>` by hand leaves an
// entry in the per-user override DB that silently makes bootstrap fail ("Service is
// disabled") — which the success check below cannot detect. `enable` is idempotent and,
// unlike `disable`, safe: it only restores the default (enabled) state.
let _ = Self::launchctl(&["enable", &Self::service_target(task_id)]);
let path = self.active_path(task_id);
let output = Self::launchctl(&["bootstrap", &Self::domain(), &path.to_string_lossy()])
.map_err(|e| format!("failed to run launchctl bootstrap: {}", e))?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr);
// Some launchd versions phrase a re-bootstrap of a live agent as "already loaded"; keep
// accepting that, though callers now check is_loaded() first (current macOS says
// "Bootstrap failed: 5: Input/output error", which is indistinguishable from a real one).
if stderr.contains("already")
|| String::from_utf8_lossy(&output.stdout).contains("already")
{
return Ok(());
}
Err(format!("launchctl bootstrap failed: {}", stderr.trim()))
}
fn build_plist(&self, task_id: &str, rendered: &RenderedSchedule) -> Result<String, String> {
let calendars = cronconv::to_launchd(&rendered.cron)?;
let mut program_args = String::new();
program_args.push_str(&format!(
" <string>{}</string>\n",
escape_xml(&rendered.program.to_string_lossy())
));
for arg in &rendered.args {
program_args.push_str(&format!(" <string>{}</string>\n", escape_xml(arg)));
}
let mut intervals = String::new();
for cal in &calendars {
intervals.push_str(&render_calendar(cal));
}
let log_path = self.log_dir.join(format!("{}.launchd.log", task_id));
let log_str = escape_xml(&log_path.to_string_lossy());
Ok(format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>{label}</string>
<key>AssociatedBundleIdentifiers</key>
<string>{bundle_id}</string>
<key>ProgramArguments</key>
<array>
{program_args} </array>
<key>StartCalendarInterval</key>
<array>
{intervals} </array>
<key>RunAtLoad</key>
<false/>
<key>ProcessType</key>
<string>Background</string>
<key>StandardOutPath</key>
<string>{log}</string>
<key>StandardErrorPath</key>
<string>{log}</string>
</dict>
</plist>
"#,
label = escape_xml(&Self::label(task_id)),
bundle_id = APP_BUNDLE_ID,
program_args = program_args,
intervals = intervals,
log = log_str,
))
}
}
fn render_calendar(cal: &LaunchdCalendar) -> String {
let mut body = String::new();
let mut push = |key: &str, value: Option<u16>| {
if let Some(v) = value {
body.push_str(&format!(
" <key>{}</key>\n <integer>{}</integer>\n",
key, v
));
}
};
push("Minute", cal.minute);
push("Hour", cal.hour);
push("Day", cal.day);
push("Weekday", cal.weekday);
push("Month", cal.month);
format!(" <dict>\n{} </dict>\n", body)
}
impl SchedulerBackend for LaunchdBackend {
fn install(&self, task_id: &str, rendered: &RenderedSchedule) -> Result<(), String> {
let plist = self.build_plist(task_id, rendered)?;
let active = self.active_path(task_id);
if !rendered.enabled {
// Install directly into the parked (disabled) location — the agent must never be
// briefly armed. Any previously-active copy is unloaded and removed.
std::fs::create_dir_all(&self.parked_dir)
.map_err(|e| format!("failed to create parked dir: {}", e))?;
std::fs::write(self.parked_path(task_id), plist)
.map_err(|e| format!("failed to write LaunchAgent plist: {}", e))?;
let bootout = Self::bootout(task_id);
let _ = std::fs::remove_file(&active);
return bootout;
}
// Idempotent fast path: identical plist already loaded (the reconcile-on-every-startup
// case). Skipping the bootout+bootstrap reload here is what keeps opening the app from
// KILLING a currently-running task — bootout terminates the service's live process.
if std::fs::read(&active).ok().as_deref() == Some(plist.as_bytes())
&& Self::is_loaded(task_id)
{
let _ = std::fs::remove_file(self.parked_path(task_id));
return Ok(());
}
std::fs::create_dir_all(&self.launch_agents_dir)
.map_err(|e| format!("failed to create LaunchAgents dir: {}", e))?;
// Bootout BEFORE writing the new definition. launchd has no in-place reload (this kills
// a running instance, but only on an actual definition change — the fast path above
// covers no-change). Order matters for crash safety: a crash between these steps then
// leaves the task unloaded (missed fires, healed by the next reconcile/login) instead
// of the old definition still firing in-memory while the new plist on disk makes the
// fast path report everything as fine.
Self::bootout(task_id)?;
std::fs::write(&active, plist)
.map_err(|e| format!("failed to write LaunchAgent plist: {}", e))?;
// A disabled copy would otherwise shadow the meaning of "installed" — drop it.
let _ = std::fs::remove_file(self.parked_path(task_id));
self.bootstrap(task_id)
}
fn uninstall(&self, task_id: &str) -> Result<(), String> {
// Files BEFORE bootout — the order is load-bearing for the runner's orphan self-heal,
// which calls this from INSIDE the fired agent: bootout SIGTERMs that very process, so
// anything after it may never run. Removing the plists first means even a bootout that
// kills us mid-call leaves nothing to reload at the next login (launchd completes the
// unload independently of our survival). A real bootout failure still surfaces: the
// loaded service would keep firing until logout while looking uninstalled.
let _ = std::fs::remove_file(self.active_path(task_id));
let _ = std::fs::remove_file(self.parked_path(task_id));
Self::bootout(task_id)
}
fn set_enabled(&self, task_id: &str, enabled: bool) -> Result<(), String> {
let active = self.active_path(task_id);
let parked = self.parked_path(task_id);
if enabled {
if active.exists() {
// Already in the auto-load dir; load it only if launchd doesn't have it —
// re-bootstrapping a loaded agent fails (and a bootout first would kill a
// running instance).
if Self::is_loaded(task_id) {
return Ok(());
}
return self.bootstrap(task_id);
}
if parked.exists() {
std::fs::create_dir_all(&self.launch_agents_dir)
.map_err(|e| format!("failed to create LaunchAgents dir: {}", e))?;
std::fs::rename(&parked, &active)
.map_err(|e| format!("failed to enable the task: {}", e))?;
if let Err(e) = self.bootstrap(task_id) {
// Roll the plist back to the parked dir: leaving it in LaunchAgents would
// arm it for the next login while the failed toggle keeps the UI (and
// is_installed, which keys off file location) saying disabled.
let _ = std::fs::rename(&active, &parked);
return Err(e);
}
return Ok(());
}
Err(super::NOT_REGISTERED.to_string())
} else {
if active.exists() {
// Unload BEFORE parking: if bootout really fails the service is still live, and
// parking the plist anyway would report "disabled" while it keeps firing.
Self::bootout(task_id)?;
std::fs::create_dir_all(&self.parked_dir)
.map_err(|e| format!("failed to create parked dir: {}", e))?;
std::fs::rename(&active, &parked)
.map_err(|e| format!("failed to disable the task: {}", e))?;
return Ok(());
}
if parked.exists() {
return Ok(()); // already disabled
}
Err(super::NOT_REGISTERED.to_string())
}
}
fn run_now(&self, task_id: &str) -> Result<(), String> {
// Direct detached spawn instead of `launchctl kickstart`: it runs in the app's own
// in-session context (this command is invoked from the running GUI), works whether the
// agent is enabled or disabled, and `--forced` bypasses the runner's catch-up suppression
// (a manual run is intentionally off-schedule). kickstart can't pass the flag.
let exe = std::env::current_exe().map_err(|e| format!("cannot resolve app path: {}", e))?;
Command::new(exe)
.args(["run-task", task_id, "--host", "local", "--forced"])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("failed to start the task: {}", e))?;
Ok(())
}
fn is_installed(&self, task_id: &str) -> Result<InstallState, String> {
// File location is the source of truth (durable across logins), mirroring crontab's
// `#off#` model: LaunchAgents = enabled, parked = disabled.
if self.active_path(task_id).exists() {
Ok(InstallState::Installed { enabled: true })
} else if self.parked_path(task_id).exists() {
Ok(InstallState::Installed { enabled: false })
} else {
Ok(InstallState::NotInstalled)
}
}
fn health_warning(&self, task_id: &str) -> Option<String> {
// An active plist that launchd does NOT have loaded while we (a GUI process in the same
// login session) are running means something outside the app unloaded it — since macOS
// 13 that is usually the user toggling the background item off in System Settings, which
// file-location-based is_installed cannot see. (A `launchctl disable` override or manual
// bootout look the same; the remedy below covers those too, since our enable path clears
// the override and re-bootstraps.)
if self.active_path(task_id).exists() && !Self::is_loaded(task_id) {
return Some(
"macOS is not running this task — its background item is turned off. Enable Rclone UI under System Settings → General → Login Items & Extensions, or pause and resume the schedule."
.to_string(),
);
}
None
}
}
/// Uninstall LaunchAgents (enabled or parked) that belong to us, except those in `keep` (task
/// ids that still have job files — empty set sweeps everything). Mirrors `crontab::sweep_orphans`.
pub fn sweep_orphans(
dirs: &AppDirs,
backend: &dyn SchedulerBackend,
keep: &std::collections::HashSet<String>,
) -> u32 {
let mut removed = 0;
let backend_ld = LaunchdBackend::new(dirs);
for dir in [&backend_ld.launch_agents_dir, &backend_ld.parked_dir] {
let Ok(entries) = std::fs::read_dir(dir) else {
continue;
};
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
let Some(id) = name
.strip_prefix(LABEL_PREFIX)
.and_then(|rest| rest.strip_suffix(".plist"))
else {
continue;
};
if keep.contains(id) {
continue;
}
if super::sanitize_id(id).is_ok() && backend.uninstall(id).is_ok() {
removed += 1;
}
}
}
removed
}
fn escape_xml(raw: &str) -> String {
raw.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scheduler::cronconv;
fn dirs() -> AppDirs {
AppDirs {
app_data: std::env::temp_dir().join("rcloneui-launchd-test"),
app_local_data: std::env::temp_dir().join("rcloneui-launchd-test-local"),
}
}
fn rendered(cron: &str) -> RenderedSchedule {
RenderedSchedule {
cron: cronconv::parse(cron).unwrap(),
program: PathBuf::from("/Applications/Rclone UI.app/Contents/MacOS/Rclone UI"),
args: vec![
"run-task".into(),
"abc".into(),
"--host".into(),
"local".into(),
],
display_name: "abc".into(),
user_mode: true,
enabled: true,
max_run_seconds: 86_400,
}
}
#[test]
fn plist_has_label_program_and_calendar() {
let backend = LaunchdBackend::new(&dirs());
let plist = backend.build_plist("abc", &rendered("*/15 9 * * *")).unwrap();
assert!(plist.contains("<string>com.rclone.ui.task.abc</string>"));
// System Settings background-item attribution (macOS 13+).
assert!(plist.contains("<key>AssociatedBundleIdentifiers</key>\n <string>com.rclone.ui</string>"));
// The space in the .app path stays a single unquoted argv element.
assert!(plist.contains("<string>/Applications/Rclone UI.app/Contents/MacOS/Rclone UI</string>"));
assert!(plist.contains("<string>run-task</string>"));
assert!(plist.contains("<key>StartCalendarInterval</key>"));
// */15 at hour 9 → 4 dicts, each Minute+Hour.
assert_eq!(plist.matches("<dict>").count(), 1 + 4); // outer dict + 4 calendar dicts
assert_eq!(plist.matches("<key>Hour</key>").count(), 4);
assert!(plist.contains("<key>Minute</key>\n <integer>45</integer>"));
assert!(plist.contains("<key>RunAtLoad</key>\n <false/>"));
}
#[test]
fn plist_every_minute_is_empty_calendar_dict() {
let backend = LaunchdBackend::new(&dirs());
let plist = backend.build_plist("abc", &rendered("* * * * *")).unwrap();
// One empty calendar dict (every minute) — no Minute/Hour/Day/Weekday/Month keys.
assert!(plist.contains("<key>StartCalendarInterval</key>"));
assert_eq!(plist.matches("<key>Minute</key>").count(), 0);
assert!(plist.contains(" <dict>\n </dict>\n"));
}
/// End-to-end lifecycle against REAL launchctl (app-scoped label, self-cleaning). Ignored by
/// default — run explicitly on a macOS dev machine:
/// RCLONE_UI_SMOKE_BIN=target/debug/app cargo test --package app launchd::tests::e2e_lifecycle -- --ignored --nocapture
/// Add RCLONE_UI_SMOKE_WAIT=1 to also wait ~70s for a real launchd fire.
#[test]
#[ignore]
fn e2e_lifecycle() {
let Ok(bin) = std::env::var("RCLONE_UI_SMOKE_BIN") else {
eprintln!("skipped: set RCLONE_UI_SMOKE_BIN to the built app binary");
return;
};
let real = super::super::storeread::app_dirs().expect("app dirs");
let backend = LaunchdBackend::new(&real);
let task = "ldtest";
let uid = LaunchdBackend::uid();
let target = LaunchdBackend::service_target(task);
let mut r = rendered("* * * * *");
r.program = PathBuf::from(&bin);
r.args = vec!["run-task".into(), task.into(), "--host".into(), "local".into()];
let print_ok = || {
std::process::Command::new("launchctl")
.args(["print", &target])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
};
backend.install(task, &r).expect("install");
assert!(backend.active_path(task).exists(), "plist in LaunchAgents");
assert!(print_ok(), "bootstrapped (launchctl print succeeds)");
assert_eq!(
backend.is_installed(task).unwrap(),
InstallState::Installed { enabled: true }
);
eprintln!("installed + bootstrapped in gui/{}", uid);
// Re-install with an unchanged definition (the startup-reconcile case) must be a no-op:
// no bootout (which would kill a running instance) and no failing re-bootstrap.
backend.install(task, &r).expect("idempotent re-install");
assert!(print_ok(), "still loaded after re-install");
// Enabling an already-enabled, already-loaded task must also succeed without touching
// launchd (a re-bootstrap would fail with 'Bootstrap failed: 5').
backend.set_enabled(task, true).expect("enable when already enabled");
assert!(print_ok(), "still loaded after redundant enable");
eprintln!("re-install + redundant enable are no-ops");
backend.set_enabled(task, false).expect("disable");
assert!(!backend.active_path(task).exists(), "plist left LaunchAgents");
assert!(backend.parked_path(task).exists(), "plist parked");
assert!(!print_ok(), "bootted out");
assert_eq!(
backend.is_installed(task).unwrap(),
InstallState::Installed { enabled: false }
);
eprintln!("disabled (parked, not loaded)");
backend.set_enabled(task, true).expect("re-enable");
assert!(backend.active_path(task).exists());
assert!(print_ok(), "re-bootstrapped");
eprintln!("re-enabled");
if std::env::var("RCLONE_UI_SMOKE_WAIT").is_ok() {
let before = super::super::history::read(&real, task, 50).len();
eprintln!("waiting up to 80s for a launchd fire...");
let mut fired = false;
for _ in 0..16 {
std::thread::sleep(std::time::Duration::from_secs(5));
if super::super::history::read(&real, task, 50).len() > before {
fired = true;
break;
}
}
assert!(fired, "launchd fired the runner within the window");
eprintln!("launchd fired the runner");
}
backend.uninstall(task).expect("uninstall");
assert!(!backend.active_path(task).exists());
assert!(!backend.parked_path(task).exists());
assert!(!print_ok(), "unloaded");
eprintln!("uninstalled + cleaned up");
}
/// A same-definition re-install (what the startup reconcile does for every task) must not
/// kill a currently-running instance — `launchctl bootout` terminates the service's live
/// process, so the no-op fast path is load-bearing. Real launchctl; self-cleaning.
/// cargo test --package app launchd::tests::e2e_reinstall_preserves_running_instance -- --ignored --nocapture
#[test]
#[ignore]
fn e2e_reinstall_preserves_running_instance() {
let real = super::super::storeread::app_dirs().expect("app dirs");
let backend = LaunchdBackend::new(&real);
let task = "ldkilltest";
let target = LaunchdBackend::service_target(task);
// A service that just sleeps, so there is a live process to preserve.
let mut r = rendered("* * * * *");
r.program = PathBuf::from("/bin/sleep");
r.args = vec!["300".into()];
backend.install(task, &r).expect("install");
let kick = std::process::Command::new("launchctl")
.args(["kickstart", &target])
.status()
.expect("kickstart");
assert!(kick.success(), "kickstart started the service");
let running_pid = || {
let out = std::process::Command::new("launchctl")
.args(["print", &target])
.output()
.expect("print");
String::from_utf8_lossy(&out.stdout)
.lines()
.find_map(|l| l.trim().strip_prefix("pid = ").map(|p| p.trim().to_string()))
};
let pid_before = running_pid().expect("service has a running pid after kickstart");
// Same definition → must be a no-op that leaves the running process untouched.
backend.install(task, &r).expect("re-install");
let pid_after = running_pid();
assert_eq!(
pid_after.as_deref(),
Some(pid_before.as_str()),
"re-install must not kill or restart the running instance"
);
eprintln!("running pid {} survived a same-definition re-install", pid_before);
backend.uninstall(task).expect("uninstall");
assert!(running_pid().is_none(), "uninstall stops the instance");
}
}
+981
View File
@@ -0,0 +1,981 @@
//! OS-native scheduling for Rclone UI's scheduled tasks.
//!
//! The GUI registers each task with the platform scheduler (the user's crontab on macOS/Linux,
//! Task Scheduler on Windows); the OS invokes this same binary headlessly (`run-task <id>`),
//! which executes the pre-serialized rclone requests stored in the task's job file. Whether a
//! task runs while logged out depends on its run mode: "user" (the default) only fires while the
//! user is logged in; "system" fires whether or not the user is logged in (cron daemon / S4U).
//!
//! Under Flatpak, scheduling works only when the user has granted host-spawn access
//! (`--talk-name=org.freedesktop.Flatpak`): the crontab commands run on the host via
//! `flatpak-spawn --host`, and the cron entry re-launches the app with `flatpak run … run-task`.
pub mod cronconv;
pub mod history;
pub mod jobfile;
pub mod runner;
pub mod storeread;
#[cfg(unix)]
mod crontab;
#[cfg(target_os = "macos")]
mod launchd;
#[cfg(target_os = "windows")]
mod schtasks;
#[cfg(target_os = "windows")]
mod winjob;
use std::path::PathBuf;
use serde::Serialize;
use tauri::AppHandle;
use jobfile::JobSpec;
use storeread::AppDirs;
/// The one cross-backend error sentinel: `set_enabled` on a task with no OS artifact. The
/// disable path in `scheduler_set_enabled` treats it as benign (nothing armed IS disabled), so
/// every backend must return exactly this — schtasks in particular can't rely on its localized
/// /Change stderr and prechecks with its locale-invariant query instead.
pub(crate) const NOT_REGISTERED: &str = "Task is not registered";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstallState {
NotInstalled,
Installed { enabled: bool },
}
/// Everything a backend needs to (re)create the OS artifact for a task.
pub struct RenderedSchedule {
pub cron: cronconv::CronSpec,
pub program: PathBuf,
pub args: Vec<String>,
/// The task's friendly name. Only the Windows backend has somewhere to put it (the schtasks
/// XML `<Description>`); launchd identifies by Label = task id and crontab by a marker comment,
/// so neither reads it — hence the cfg-gated allow.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
pub display_name: String,
/// User-mode task (the default): only runs while the user is logged in. Only the Windows
/// backend reads this — it bakes the mode into a single artifact (InteractiveToken vs S4U).
/// On macOS the mode already picked the backend (launchd vs crontab) before rendering, and on
/// Linux the crontab entry is identical for both modes (the runner gates/borrows the session
/// at fire time from the job file). Hence the cfg-gated allow off Windows.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
pub user_mode: bool,
/// The state to install in. Baked into the artifact (crontab `#off#` prefix, launchd
/// active-vs-parked location, schtasks Settings `<Enabled>`) so registration is one
/// operation: a disabled task is never briefly armed between an install and a follow-up
/// set_enabled, and a partial failure can't leave it running against the user's intent.
pub enabled: bool,
/// The task's max run time. Only the Windows backend reads it (schtasks
/// `<ExecutionTimeLimit>` must sit above the runner's own deadline or Task Scheduler kills
/// the run first); cron/launchd don't supervise run durations — the runner's deadline is the
/// only limit there.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
pub max_run_seconds: u64,
}
pub trait SchedulerBackend: Send + Sync {
/// Create or overwrite the OS artifact in `rendered.enabled`'s state. Idempotent.
fn install(&self, task_id: &str, rendered: &RenderedSchedule) -> Result<(), String>;
/// Remove the OS artifact. Idempotent (missing artifacts are not an error).
fn uninstall(&self, task_id: &str) -> Result<(), String>;
fn set_enabled(&self, task_id: &str, enabled: bool) -> Result<(), String>;
fn run_now(&self, task_id: &str) -> Result<(), String>;
fn is_installed(&self, task_id: &str) -> Result<InstallState, String>;
/// A user-visible reason the task won't fire even though it is installed and enabled —
/// state the backend's own enabled model cannot see (macOS: the background item toggled off
/// in System Settings unloads the agent while the plist stays in LaunchAgents). None = healthy.
fn health_warning(&self, _task_id: &str) -> Option<String> {
None
}
}
/// The mode-agnostic / default backend (crontab on Unix, schtasks on Windows). Used where the run
/// mode is irrelevant — `scheduler_supported`, and the runner's orphan self-heal. On macOS this is
/// the SYSTEM-mode backend; user-mode tasks go through launchd via `backend_for`.
pub fn backend(dirs: &AppDirs) -> Result<Box<dyn SchedulerBackend>, String> {
// No Flatpak permission check here: the startup gate (has_flatpak_permissions) quits the app
// unless both host filesystem and host-spawn access are granted, so any running instance can
// schedule. Only the "is cron installed on the host" capability is checked below.
#[cfg(unix)]
{
crontab::check_available()?;
Ok(Box::new(crontab::CrontabBackend::new(dirs)))
}
#[cfg(target_os = "windows")]
{
Ok(Box::new(schtasks::SchtasksBackend::new(dirs)))
}
#[cfg(not(any(unix, target_os = "windows")))]
{
let _ = dirs;
Err("Scheduling is not supported on this platform".to_string())
}
}
/// The backend for a task given its run mode. Only macOS splits by mode: user-mode → launchd
/// LaunchAgent (login-session context), system-mode → crontab. Linux uses crontab for both modes
/// (the runner gates/borrows the session at fire time); Windows uses schtasks for both (the logon
/// type differs inside the task XML).
pub fn backend_for(dirs: &AppDirs, user_mode: bool) -> Result<Box<dyn SchedulerBackend>, String> {
#[cfg(target_os = "macos")]
{
if user_mode {
return Ok(Box::new(launchd::LaunchdBackend::new(dirs)));
}
crontab::check_available()?;
Ok(Box::new(crontab::CrontabBackend::new(dirs)))
}
#[cfg(not(target_os = "macos"))]
{
let _ = user_mode;
backend(dirs)
}
}
/// Backends OTHER than the one selected for `user_mode` — the artifacts a mode flip must clean up
/// so a task never fires from two backends. Only macOS has a second backend; empty elsewhere.
/// Errors (crontab unavailable) PROPAGATE: silently skipping the cleanup would let a flip
/// install the new backend while the old one keeps firing.
fn other_backends(dirs: &AppDirs, user_mode: bool) -> Result<Vec<Box<dyn SchedulerBackend>>, String> {
#[cfg(target_os = "macos")]
{
let other: Box<dyn SchedulerBackend> = if user_mode {
crontab::check_available()?;
Box::new(crontab::CrontabBackend::new(dirs))
} else {
Box::new(launchd::LaunchdBackend::new(dirs))
};
Ok(vec![other])
}
#[cfg(not(target_os = "macos"))]
{
let _ = (dirs, user_mode);
Ok(Vec::new())
}
}
/// Serializes every scheduler mutation across the process. The hidden main window's startup
/// reconcile and an edit from the Settings webview otherwise interleave their whole-crontab
/// read-modify-write and silently drop each other's entry (healed only at the next reconcile).
/// The runner process is covered separately by the crontab file lock (crontab.rs).
static MUTATION_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn mutation_guard() -> std::sync::MutexGuard<'static, ()> {
MUTATION_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
/// Every backend a task could be registered in — used for mode-agnostic teardown (unregister,
/// orphan sweep) that must cover both macOS backends.
fn all_backends(dirs: &AppDirs) -> Vec<Box<dyn SchedulerBackend>> {
let mut backends: Vec<Box<dyn SchedulerBackend>> = Vec::new();
#[cfg(target_os = "macos")]
{
if crontab::check_available().is_ok() {
backends.push(Box::new(crontab::CrontabBackend::new(dirs)));
}
backends.push(Box::new(launchd::LaunchdBackend::new(dirs)));
}
#[cfg(not(target_os = "macos"))]
{
if let Ok(b) = backend(dirs) {
backends.push(b);
}
}
backends
}
/// The Flatpak application id (from FLATPAK_ID inside the sandbox; the manifest id otherwise).
fn flatpak_app_id() -> String {
std::env::var("FLATPAK_ID").unwrap_or_else(|_| "com.rcloneui.RcloneUI".to_string())
}
/// Task ids become crontab markers, schtasks task names, and file names — never trust them,
/// even though the app generates UUIDs.
pub fn sanitize_id(task_id: &str) -> Result<String, String> {
if task_id.is_empty() || task_id.len() > 64 {
return Err("invalid task id".to_string());
}
if !task_id
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
|| task_id.contains("..")
{
return Err("invalid task id".to_string());
}
Ok(task_id.to_string())
}
/// The path the OS scheduler should invoke — stable across app restarts and updates.
pub fn registered_invocation() -> Result<PathBuf, String> {
#[cfg(target_os = "linux")]
{
// AppImage: current_exe() is the transient /tmp/.mount_* path; $APPIMAGE is the real file.
if let Some(appimage) = std::env::var_os("APPIMAGE") {
return Ok(PathBuf::from(appimage));
}
}
let exe = std::env::current_exe().map_err(|e| format!("cannot resolve app path: {}", e))?;
#[cfg(target_os = "macos")]
{
if exe.to_string_lossy().contains("/AppTranslocation/") {
return Err(
"Move Rclone UI to the Applications folder before scheduling tasks".to_string(),
);
}
}
#[cfg(target_os = "linux")]
{
// Snap (classic): pin to the 'current' symlink so registrations survive refreshes.
let text = exe.to_string_lossy().to_string();
if let Some(rest) = text.strip_prefix("/snap/") {
let mut parts = rest.splitn(3, '/');
if let (Some(name), Some(_rev), Some(tail)) = (parts.next(), parts.next(), parts.next())
{
return Ok(PathBuf::from(format!("/snap/{}/current/{}", name, tail)));
}
}
}
Ok(exe)
}
fn render(dirs: &AppDirs, spec: &JobSpec, enabled: bool) -> Result<RenderedSchedule, String> {
let cron = cronconv::parse(&spec.cron)?;
// Under Flatpak the host scheduler can't invoke the sandbox binary directly — it re-launches
// the app via `flatpak run <id> …`, which forwards the trailing args to our headless mode.
let mut args = Vec::new();
let program = if crate::is_flatpak() {
args.push("run".to_string());
args.push(flatpak_app_id());
PathBuf::from("flatpak")
} else {
registered_invocation()?
};
args.extend([
"run-task".to_string(),
spec.task_id.clone(),
"--host".to_string(),
spec.host_id.clone(),
// Bake the GUI's resolved data roots into the invocation: schedulers hand the runner a
// bare environment, so re-deriving them there silently diverges when the session sets
// XDG_DATA_HOME (Linux) — the runner would look in ~/.local/share, find no job file,
// and treat a valid task as an orphan.
"--data-dir".to_string(),
dirs.app_data.to_string_lossy().into_owned(),
"--local-data-dir".to_string(),
dirs.app_local_data.to_string_lossy().into_owned(),
]);
Ok(RenderedSchedule {
cron,
program,
args,
display_name: spec.name.clone(),
user_mode: spec.is_user_mode(),
enabled,
max_run_seconds: spec.max_run_seconds,
})
}
// ---------------------------------------------------------------------------
// Tauri commands
// ---------------------------------------------------------------------------
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SupportInfo {
pub supported: bool,
pub reason: Option<String>,
}
#[tauri::command]
pub async fn scheduler_supported(app: AppHandle) -> SupportInfo {
// spawn_blocking: on Flatpak, backend()→check_available() probes the host with a subprocess.
tauri::async_runtime::spawn_blocking(move || {
let dirs = match storeread::app_dirs_from(&app) {
Ok(d) => d,
Err(e) => {
return SupportInfo {
supported: false,
reason: Some(e),
}
}
};
match backend(&dirs) {
Ok(_) => SupportInfo {
supported: true,
reason: None,
},
Err(reason) => SupportInfo {
supported: false,
reason: Some(reason),
},
}
})
.await
.unwrap_or(SupportInfo {
supported: false,
reason: Some("scheduler availability check failed".to_string()),
})
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CronValidation {
pub valid: bool,
pub error: Option<String>,
/// The next few local fire times (RFC3339 with offset), computed by the SAME matcher the
/// runner uses. This is the UI's preview source — JS cron libraries disagree with Vixie
/// cron on the dom/dow star flag, so predicting fires anywhere else risks showing runs the
/// native schedule will never perform. Empty when invalid (or nothing fires within 5 years).
pub next_runs: Vec<String>,
}
#[tauri::command]
pub fn scheduler_validate_cron(cron: String) -> CronValidation {
match cronconv::validate_for_current_platform(&cron) {
Ok(()) => CronValidation {
valid: true,
error: None,
next_runs: cronconv::parse(&cron)
.map(|spec| cronconv::next_fires(&spec, chrono::Local::now(), 5))
.unwrap_or_default(),
},
Err(error) => CronValidation {
valid: false,
error: Some(error),
next_runs: Vec::new(),
},
}
}
/// UPSERT: write the job file and (re)install the OS artifact in the given enabled state (one
/// operation — no separate set_enabled step to half-fail). The backend depends on the run mode
/// (macOS user → launchd, else crontab/schtasks); a mode flip first uninstalls the old artifact
/// from the other backend so the task never fires twice.
#[tauri::command]
pub async fn scheduler_register(app: AppHandle, spec: JobSpec, enabled: bool) -> Result<(), String> {
tauri::async_runtime::spawn_blocking(move || {
let dirs = storeread::app_dirs_from(&app)?;
sanitize_id(&spec.task_id)?;
sanitize_id(&spec.host_id)?;
if spec.schema_version != jobfile::JOB_SCHEMA_VERSION {
return Err(format!(
"unsupported job schema version {}",
spec.schema_version
));
}
if spec.host_id != "local" {
return Err("Scheduling is only supported for the local host".to_string());
}
if spec.requests.is_empty() {
return Err("The task produced no rclone requests".to_string());
}
// Linux 'User' mode is gated at fire time on logind session state — a system without
// systemd-logind/elogind can never pass that gate, so every fire would silently skip.
// Registration happens from the GUI, i.e. while the user IS logged in: failing the gate
// right now proves it can never pass, and the error can name the fix.
#[cfg(target_os = "linux")]
{
if spec.is_user_mode() && !runner::user_has_login_session() {
return Err(
"This system does not report login sessions (systemd-logind or elogind is required for the 'User' run mode to know when you are logged in). Switch this schedule's run mode to 'System', which runs regardless of login state."
.to_string(),
);
}
}
let _guard = mutation_guard();
let user_mode = spec.is_user_mode();
let backend = backend_for(&dirs, user_mode)?;
let rendered = render(&dirs, &spec, enabled)?;
// Remove any artifact left in the other backend (a user↔system flip on macOS) BEFORE the
// job file changes. Order matters: if this cleanup fails after the job file already says
// the NEW mode, the old backend's still-firing trigger would run under the new mode's
// contract — on macOS a cron fire would be trusted as launchd-in-session and skip every
// gate. Failing here leaves old trigger + old job file: consistent old behavior.
for other in other_backends(&dirs, user_mode)? {
other.uninstall(&spec.task_id).map_err(|e| {
format!("failed to remove the task's previous registration: {}", e)
})?;
}
jobfile::save(&dirs, &spec)?;
if let Err(e) = backend.install(&spec.task_id, &rendered) {
// Keep the reported state truthful: "not registered" must mean nothing fires. The
// old artifact would otherwise keep firing the OLD schedule against the NEW job
// file. The job file stays for the startup reconcile to retry.
let _ = backend.uninstall(&spec.task_id);
return Err(e);
}
Ok(())
})
.await
.map_err(|e| e.to_string())?
}
#[tauri::command]
pub async fn scheduler_unregister(
app: AppHandle,
task_id: String,
host_id: String,
) -> Result<(), String> {
tauri::async_runtime::spawn_blocking(move || {
let dirs = storeread::app_dirs_from(&app)?;
let task_id = sanitize_id(&task_id)?;
let host_id = sanitize_id(&host_id)?;
let _guard = mutation_guard();
// Uninstall from every backend (macOS covers both launchd and crontab) so the task is
// removed regardless of the mode it was registered under. The job file is removed even
// when an OS-level uninstall fails: a surviving trigger self-heals on its next fire (the
// runner finds no job file, removes the trigger, and exits).
let mut uninstall_result = Ok(());
for backend in all_backends(&dirs) {
if let Err(e) = backend.uninstall(&task_id) {
uninstall_result = Err(e);
}
}
jobfile::remove(&dirs, &host_id, &task_id);
history::remove_all(&dirs, &task_id);
uninstall_result
})
.await
.map_err(|e| e.to_string())?
}
#[tauri::command]
pub async fn scheduler_set_enabled(
app: AppHandle,
task_id: String,
enabled: bool,
) -> Result<(), String> {
tauri::async_runtime::spawn_blocking(move || {
let dirs = storeread::app_dirs_from(&app)?;
let task_id = sanitize_id(&task_id)?;
let _guard = mutation_guard();
// Load the spec to pick the backend the task is actually registered in (macOS user vs
// system live in different backends).
let user_mode = jobfile::load(&dirs, "local", &task_id)
.map(|spec| spec.is_user_mode())
.unwrap_or(true);
let result = backend_for(&dirs, user_mode)?.set_enabled(&task_id, enabled);
// Disabling must reach whatever artifact actually exists. After a failed registration
// or mode flip the artifact can live in the OTHER backend (or nowhere): try every
// backend, treat "no artifact anywhere" as success (nothing armed IS disabled), but
// never swallow a real failure — that would leave the task firing while the UI says
// paused. Enabling keeps the strict single-backend error: it must not guess.
if !enabled {
let mut real_error = match result {
Ok(()) => return Ok(()),
Err(e) if e == NOT_REGISTERED => None,
Err(e) => Some(e),
};
for backend in all_backends(&dirs) {
match backend.set_enabled(&task_id, false) {
Ok(()) => return Ok(()),
Err(e) if e == NOT_REGISTERED => {}
Err(e) => {
real_error.get_or_insert(e);
}
}
}
return match real_error {
Some(e) => Err(e),
None => Ok(()),
};
}
result
})
.await
.map_err(|e| e.to_string())?
}
#[tauri::command]
pub async fn scheduler_run_now(app: AppHandle, task_id: String) -> Result<(), String> {
tauri::async_runtime::spawn_blocking(move || {
let dirs = storeread::app_dirs_from(&app)?;
let task_id = sanitize_id(&task_id)?;
let user_mode = jobfile::load(&dirs, "local", &task_id)
.map(|spec| spec.is_user_mode())
.unwrap_or(true);
backend_for(&dirs, user_mode)?.run_now(&task_id)
})
.await
.map_err(|e| e.to_string())?
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TaskStatus {
pub task_id: String,
pub installed: bool,
pub enabled: bool,
pub running: bool,
pub last_finished: Option<serde_json::Value>,
/// Backend health warning (see `SchedulerBackend::health_warning`).
#[serde(skip_serializing_if = "Option::is_none")]
pub warning: Option<String>,
}
#[tauri::command]
pub async fn scheduler_status(app: AppHandle, host_id: String) -> Result<Vec<TaskStatus>, String> {
// spawn_blocking: shells out to crontab/schtasks once per task.
tauri::async_runtime::spawn_blocking(move || {
let dirs = storeread::app_dirs_from(&app)?;
let host_id = sanitize_id(&host_id)?;
let mut statuses = Vec::new();
for spec in jobfile::list(&dirs, &host_id) {
// Per-task backend: a macOS user-mode task's state lives in launchd, a system-mode
// task's in crontab.
let backend = backend_for(&dirs, spec.is_user_mode());
let install_state = backend
.as_ref()
.ok()
.map(|backend| backend.is_installed(&spec.task_id))
.and_then(Result::ok)
.unwrap_or(InstallState::NotInstalled);
let (installed, enabled) = match install_state {
InstallState::NotInstalled => (false, false),
InstallState::Installed { enabled } => (true, enabled),
};
let warning = backend
.as_ref()
.ok()
.and_then(|backend| backend.health_warning(&spec.task_id));
let running = history::is_running(&dirs, &spec.task_id);
let lines = history::read(&dirs, &spec.task_id, 20);
let event_of = |line: &serde_json::Value| {
line.get("event").and_then(|e| e.as_str()).map(str::to_owned)
};
// Newest-first: the latest started/finished event is the latest ATTEMPT. A started
// with no finished and no live lock is a run that died without writing its terminal
// record (crash, SIGKILL, power loss, Task Scheduler hard timeout) — surfacing the
// older success (or "Never") instead would hide the interruption.
let newest_attempt = lines.iter().find(|line| {
matches!(event_of(line).as_deref(), Some("started") | Some("finished"))
});
let last_finished = match newest_attempt {
Some(line) if event_of(line).as_deref() == Some("started") && !running => {
Some(serde_json::json!({
"runId": line.get("runId").cloned().unwrap_or_default(),
"ts": line.get("ts").cloned().unwrap_or_default(),
"success": false,
"error": "The run was interrupted before it could finish (crash, forced shutdown, or power loss).",
"durationMs": 0,
"interrupted": true,
}))
}
_ => lines
.iter()
.find(|line| event_of(line).as_deref() == Some("finished"))
.cloned(),
};
statuses.push(TaskStatus {
task_id: spec.task_id.clone(),
installed,
enabled,
running,
last_finished,
warning,
});
}
Ok(statuses)
})
.await
.map_err(|e| e.to_string())?
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LogContent {
pub content: String,
pub truncated: bool,
}
/// Tail of a task's log for the in-app viewer. `which`: "runner" (our runner's lines) or
/// "daemon" (the transient rclone daemon's stderr).
#[tauri::command]
pub fn scheduler_read_log(
app: AppHandle,
task_id: String,
which: String,
) -> Result<LogContent, String> {
const MAX_TAIL_BYTES: usize = 64 * 1024;
let dirs = storeread::app_dirs_from(&app)?;
let task_id = sanitize_id(&task_id)?;
let path = match which.as_str() {
"runner" => history::log_path(&dirs, &task_id),
"daemon" => history::log_path(&dirs, &task_id).with_extension("daemon.log"),
other => return Err(format!("unknown log '{}'", other)),
};
let Ok(bytes) = std::fs::read(&path) else {
return Ok(LogContent {
content: String::new(),
truncated: false,
});
};
let truncated = bytes.len() > MAX_TAIL_BYTES;
let tail = if truncated {
let cut = bytes.len() - MAX_TAIL_BYTES;
// Align to the next line boundary so the viewer never starts mid-line.
let aligned = bytes[cut..]
.iter()
.position(|&b| b == b'\n')
.map(|i| cut + i + 1)
.unwrap_or(cut);
&bytes[aligned..]
} else {
&bytes[..]
};
Ok(LogContent {
content: String::from_utf8_lossy(tail).into_owned(),
truncated,
})
}
#[tauri::command]
pub async fn scheduler_read_history(
app: AppHandle,
task_id: String,
limit: Option<usize>,
) -> Result<Vec<serde_json::Value>, String> {
tauri::async_runtime::spawn_blocking(move || {
let dirs = storeread::app_dirs_from(&app)?;
let task_id = sanitize_id(&task_id)?;
Ok(history::read(&dirs, &task_id, limit.unwrap_or(50)))
})
.await
.map_err(|e| e.to_string())?
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DoctorCheck {
pub name: String,
pub ok: bool,
pub detail: String,
pub fix: Option<String>,
}
/// Preflight diagnostics with actionable fixes — the "why didn't my task run" surface.
#[tauri::command]
pub async fn scheduler_doctor() -> Result<Vec<DoctorCheck>, String> {
tauri::async_runtime::spawn_blocking(move || {
let mut checks: Vec<DoctorCheck> = Vec::new();
if crate::is_flatpak() {
// Scheduling works under Flatpak through the host's cron (crontab via
// `flatpak-spawn --host`, entries re-launch the app with `flatpak run`). The startup
// gate normally guarantees these permissions; verify anyway so the doctor stays
// truthful if that gate ever changes.
let ok = crate::has_flatpak_permissions();
checks.push(DoctorCheck {
name: "Sandbox host access".to_string(),
ok,
detail: if ok {
"host filesystem and host-spawn access are granted — scheduling uses the host's cron"
.to_string()
} else {
"missing host filesystem or host-spawn permission — the app cannot reach the host's crontab"
.to_string()
},
fix: if ok {
None
} else {
Some(format!(
"Grant it: flatpak override --user --filesystem=host --talk-name=org.freedesktop.Flatpak {}",
flatpak_app_id()
))
},
});
if !ok {
return Ok(checks);
}
}
#[cfg(unix)]
{
match crontab::check_available() {
Ok(()) => checks.push(DoctorCheck {
name: "cron installed".to_string(),
ok: true,
detail: if crate::is_flatpak() {
"crontab found on the host".to_string()
} else {
"crontab found in PATH".to_string()
},
fix: None,
}),
Err(_) => {
checks.push(DoctorCheck {
name: "cron installed".to_string(),
ok: false,
detail: if crate::is_flatpak() {
"no crontab binary found on the host".to_string()
} else {
"no crontab binary found in PATH".to_string()
},
fix: Some(
"Install 'cron' (Debian/Ubuntu) or 'cronie' (Fedora/Arch), then restart the app."
.to_string(),
),
});
return Ok(checks);
}
}
match crontab::host_command("crontab").arg("-l").output() {
Ok(output) if output.status.success() => {
let managed = String::from_utf8_lossy(&output.stdout)
.lines()
.filter(|line| line.trim_start().starts_with("# rclone-ui-task:"))
.count();
checks.push(DoctorCheck {
name: "crontab access".to_string(),
ok: true,
detail: format!("{} scheduled task(s) registered", managed),
fix: None,
});
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
if crontab::stderr_means_no_crontab(&stderr) {
checks.push(DoctorCheck {
name: "crontab access".to_string(),
ok: true,
detail: "no crontab yet — created on first schedule".to_string(),
fix: None,
});
} else {
checks.push(DoctorCheck {
name: "crontab access".to_string(),
ok: false,
detail: format!("crontab -l failed: {}", stderr.trim()),
fix: Some(
"Your user may be denied cron access (cron.deny / system policy)."
.to_string(),
),
});
}
}
Err(e) => checks.push(DoctorCheck {
name: "crontab access".to_string(),
ok: false,
detail: format!("could not run crontab: {}", e),
fix: None,
}),
}
}
#[cfg(target_os = "linux")]
{
// Inside the Flatpak sandbox (own PID namespace) host processes are invisible to
// sysinfo — list them on the host instead.
let cron_running = if crate::is_flatpak() {
crontab::host_command("sh")
.arg("-c")
.arg("ps -e -o comm=")
.output()
.map(|o| {
String::from_utf8_lossy(&o.stdout)
.lines()
.any(|name| matches!(name.trim(), "cron" | "crond" | "cronie"))
})
.unwrap_or(false)
} else {
let mut system = sysinfo::System::new();
system.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
system.processes().values().any(|process| {
let name = process.name().to_string_lossy().to_lowercase();
name == "cron" || name == "crond" || name == "cronie"
})
};
checks.push(DoctorCheck {
name: "cron service".to_string(),
ok: cron_running,
detail: if cron_running {
"cron daemon is running".to_string()
} else {
"no cron daemon process found — schedules will not fire".to_string()
},
fix: if cron_running {
None
} else {
Some("Enable it: sudo systemctl enable --now cron (or cronie)".to_string())
},
});
}
#[cfg(target_os = "macos")]
{
// TCC is the common silent failure for tasks touching Desktop/Documents/Downloads or
// external/network volumes. The fix differs by run mode: user-mode tasks run as the
// app via a LaunchAgent (grant the app), system-mode tasks run under cron (grant cron).
checks.push(DoctorCheck {
name: "macOS privacy (TCC)".to_string(),
ok: true,
detail:
"Tasks reading protected folders (Desktop, Documents, Downloads) or external/network volumes may be blocked by macOS privacy protections. A scheduled run cannot show a permission prompt, so access must be granted beforehand."
.to_string(),
fix: Some(
"'User' run mode (default): grant Rclone UI access to the folder, or add Rclone UI to Full Disk Access in System Settings → Privacy & Security. 'System' run mode: grant Full Disk Access to /usr/sbin/cron instead."
.to_string(),
),
});
}
#[cfg(target_os = "windows")]
{
let query = std::process::Command::new("schtasks").arg("/Query").output();
let ok = query.map(|o| o.status.success()).unwrap_or(false);
checks.push(DoctorCheck {
name: "Task Scheduler".to_string(),
ok,
detail: if ok {
"Task Scheduler service is reachable".to_string()
} else {
"schtasks query failed — the Task Scheduler service may be disabled".to_string()
},
fix: if ok {
None
} else {
Some("Start the 'Task Scheduler' service (services.msc).".to_string())
},
});
checks.push(DoctorCheck {
name: "Runs while logged out".to_string(),
ok: true,
detail:
"Schedules in 'System' run mode run while logged out (S4U) but cannot access mapped drives or implicit-auth network shares — schedules that need them should use the 'User' run mode. S4U also requires the 'Log on as a batch job' right: on domain-managed machines that deny it, System-mode tasks register but never start."
.to_string(),
fix: None,
});
}
Ok(checks)
})
.await
.map_err(|e| e.to_string())?
}
/// Remove every registration this app ever made (Settings escape hatch / pre-uninstall cleanup).
/// Sweeps both job files and orphaned OS artifacts by prefix.
#[tauri::command]
pub async fn scheduler_unregister_all(app: AppHandle) -> Result<u32, String> {
tauri::async_runtime::spawn_blocking(move || {
let dirs = storeread::app_dirs_from(&app)?;
let _guard = mutation_guard();
let backends = all_backends(&dirs);
let mut removed: u32 = 0;
let jobs_root = dirs.app_data.join("scheduler").join("jobs");
if let Ok(host_dirs) = std::fs::read_dir(&jobs_root) {
for host_dir in host_dirs.flatten() {
let host_id = host_dir.file_name().to_string_lossy().to_string();
for spec in jobfile::list(&dirs, &host_id) {
// Uninstall from every backend (macOS: launchd + crontab).
if backends.iter().any(|b| b.uninstall(&spec.task_id).is_ok()) {
removed += 1;
}
jobfile::remove(&dirs, &host_id, &spec.task_id);
history::remove_all(&dirs, &spec.task_id);
}
}
}
// Orphan sweep: artifacts whose job files were lost, across every backend.
removed += sweep_orphans(&dirs);
Ok(removed)
})
.await
.map_err(|e| e.to_string())?
}
/// Task ids that still have a job file — by FILENAME, deliberately not by parse: an unreadable
/// or newer-schema job file is an environment problem, and sweeping its artifact would destroy
/// a valid registration (same conservatism as the runner's self-heal).
fn registered_task_ids(dirs: &AppDirs) -> std::collections::HashSet<String> {
let mut ids = std::collections::HashSet::new();
let jobs_root = dirs.app_data.join("scheduler").join("jobs");
let Ok(host_dirs) = std::fs::read_dir(&jobs_root) else {
return ids;
};
for host_dir in host_dirs.flatten() {
let Ok(entries) = std::fs::read_dir(host_dir.path()) else {
continue;
};
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
if let Some(id) = name.strip_suffix(".json") {
ids.insert(id.to_string());
}
}
}
ids
}
/// Sweep OS artifacts that have NO job file, across every backend on this platform (macOS:
/// crontab + launchd). These leftovers appear when an OS-level uninstall fails after the job
/// file was removed; a DISABLED leftover never fires, so the runner's fire-time self-heal can
/// never reach it — this sweep is the only thing that does.
fn sweep_orphans(dirs: &AppDirs) -> u32 {
let keep = registered_task_ids(dirs);
#[cfg(target_os = "macos")]
{
let mut removed = 0;
if let Ok(cron) = backend_for(dirs, false) {
removed += crontab::sweep_orphans(&*cron, &keep);
}
let launchd_backend = launchd::LaunchdBackend::new(dirs);
removed += launchd::sweep_orphans(dirs, &launchd_backend, &keep);
removed
}
#[cfg(all(unix, not(target_os = "macos")))]
{
match backend(dirs) {
Ok(b) => crontab::sweep_orphans(&*b, &keep),
Err(_) => 0,
}
}
#[cfg(target_os = "windows")]
{
match backend(dirs) {
Ok(b) => schtasks::sweep_orphans(&*b, &keep, dirs),
Err(_) => 0,
}
}
#[cfg(not(any(unix, target_os = "windows")))]
{
let _ = (dirs, keep);
0
}
}
/// Startup-reconcile hook for the sweep above. Runs AFTER the reconcile has re-registered every
/// stored task (their job files then exist and protect their artifacts).
#[tauri::command]
pub async fn scheduler_sweep_orphans(app: AppHandle) -> Result<u32, String> {
tauri::async_runtime::spawn_blocking(move || {
let dirs = storeread::app_dirs_from(&app)?;
let _guard = mutation_guard();
Ok(sweep_orphans(&dirs))
})
.await
.map_err(|e| e.to_string())?
}
+876
View File
@@ -0,0 +1,876 @@
//! Headless `run-task` engine: executes one scheduled task end-to-end without any GUI.
//!
//! Spawns a transient, private rclone daemon (task's binary + config, ephemeral localhost port,
//! random credentials), POSTs the pre-serialized RC requests from the job file, polls to
//! terminal state, records history, and dispatches the schedule.* webhooks.
//!
//! Exit codes: 0 success · 1 run failed · 2 setup error · 3 skipped (already running).
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use serde_json::{json, Value};
use super::history::{self, HistoryLine, RunLog};
use super::jobfile::{self, JobSpec};
use super::storeread::{self, AppDirs};
use crate::notifications::{os, webhooks};
const READINESS_TIMEOUT: Duration = Duration::from_secs(15);
const POLL_INTERVAL: Duration = Duration::from_secs(2);
static TERMINATED: AtomicBool = AtomicBool::new(false);
#[cfg(unix)]
extern "C" fn on_sigterm(_: libc::c_int) {
TERMINATED.store(true, Ordering::SeqCst);
}
fn install_sigterm_handler() {
#[cfg(unix)]
unsafe {
let handler = on_sigterm as extern "C" fn(libc::c_int);
libc::signal(libc::SIGTERM, handler as usize as libc::sighandler_t);
}
}
pub fn run(
task_id: &str,
host_id: &str,
forced: bool,
data_dir: Option<&str>,
local_data_dir: Option<&str>,
) -> i32 {
// forced: a manual Run Now — bypass the macOS launchd catch-up suppression (a manual run is
// intentionally off-schedule). Unused on non-macOS builds.
#[cfg(not(target_os = "macos"))]
let _ = forced;
let Ok(task_id) = super::sanitize_id(task_id) else {
eprintln!("run-task: invalid task id");
return 2;
};
let Ok(host_id) = super::sanitize_id(host_id) else {
eprintln!("run-task: invalid host id");
return 2;
};
// Prefer the data roots baked into the trigger at registration (the GUI's resolved paths):
// schedulers hand us a bare environment, so re-deriving them can diverge — a session-set
// XDG_DATA_HOME is invisible to cron. Old triggers without the flags fall back to deriving.
let dirs = match (data_dir, local_data_dir) {
(Some(data), Some(local)) => AppDirs {
app_data: std::path::PathBuf::from(data),
app_local_data: std::path::PathBuf::from(local),
},
_ => match storeread::app_dirs() {
Ok(d) => d,
Err(e) => {
eprintln!("run-task: {}", e);
return 2;
}
},
};
let mut log = RunLog::open(&dirs, &task_id);
log.line(&format!("run-task {} (host {})", task_id, host_id));
// No Flatpak guard here: when running under Flatpak the runner is a fresh sandboxed instance
// launched by host cron via `flatpak run … run-task`; it drives rclone in-sandbox and never
// needs host access itself. Registration (mod.rs::backend) is where the permission is gated.
// Missing job file: the task was deleted but its OS trigger survived (e.g. unregister
// failed). Self-heal by removing the orphan trigger — from EVERY backend, since on macOS a
// user-mode trigger lives in launchd, not the default cron backend.
//
// Self-heal ONLY on a clean not-found with the jobs directory present. A missing/unreadable
// data root (unmounted systemd-homed home, wrong XDG-derived path from an old trigger) or a
// malformed/newer-schema job file is an ENVIRONMENT problem — uninstalling there would
// destroy a valid registration.
let spec = match jobfile::load(&dirs, &host_id, &task_id) {
Ok(spec) => spec,
Err(e) => {
let job_path = jobfile::job_path(&dirs, &host_id, &task_id);
let jobs_dir_present = job_path.parent().map(|p| p.is_dir()).unwrap_or(false);
if jobs_dir_present && !job_path.exists() {
log.line(&format!("job file missing: {} — removing orphan trigger", e));
for backend in super::all_backends(&dirs) {
let _ = backend.uninstall(&task_id);
}
} else {
log.line(&format!(
"job file unusable: {} — leaving the trigger in place (environment problem, not an orphan)",
e
));
}
return 2;
}
};
if spec.host_id != "local" {
log.line("remote-host tasks are not supported by the scheduler");
return 2;
}
// User-mode context handling differs by platform. macOS: launchd fires the task inside the
// login session already (Keychain, /Volumes, TCC-as-the-app) and only while logged in, so
// there is nothing to gate — we only suppress launchd's wake-catch-up to honor no-replay.
// Linux: cron fires regardless of login state, so we gate on an active session and borrow its
// context. Windows: the interactive logon type is the gate (nothing here).
if spec.is_user_mode() {
#[cfg(target_os = "macos")]
{
if !forced && is_launchd_catchup(&spec) {
log.line("skipped: missed while asleep (launchd catch-up suppressed)");
history::append(
&dirs,
&task_id,
&HistoryLine::Skipped {
ts: history::now_iso(),
reason: "missed while asleep".to_string(),
},
);
return 3;
}
}
#[cfg(target_os = "linux")]
{
if !user_has_login_session() {
log.line("skipped: no active login session");
history::append(
&dirs,
&task_id,
&HistoryLine::Skipped {
ts: history::now_iso(),
reason: "no active login session".to_string(),
},
);
return 3;
}
if let Some(runtime_dir) = session_runtime_dir() {
borrow_session_env(&runtime_dir, &mut log);
}
}
}
// Held (not dropped) for the entire run: on Unix the flock inside is the mutual exclusion.
let run_lock = match history::acquire_lock(&dirs, &task_id, spec.max_run_seconds) {
Ok(history::LockResult::Acquired(lock)) => lock,
Ok(history::LockResult::Held) => {
log.line("skipped: another run is in progress");
history::append(
&dirs,
&task_id,
&HistoryLine::Skipped {
ts: history::now_iso(),
reason: "already-running".to_string(),
},
);
return 3;
}
Err(e) => {
log.line(&format!("lock error: {}", e));
return 2;
}
};
install_sigterm_handler();
let run_id = format!(
"{}-{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis(),
std::process::id()
);
let started_at = Instant::now();
// The user-facing "max run time" covers the WHOLE run — started-webhook delivery (up to
// ~17s per target, sequential) and daemon readiness included — not just the rclone jobs.
let deadline = started_at + Duration::from_secs(spec.max_run_seconds);
history::append(
&dirs,
&task_id,
&HistoryLine::Started {
run_id: run_id.clone(),
ts: history::now_iso(),
pid: std::process::id(),
host_id: host_id.clone(),
},
);
let client = webhooks::http_client();
let root = storeread::read_root(&dirs).unwrap_or_default();
let task_label = if spec.name.is_empty() {
spec.operation.clone()
} else {
spec.name.clone()
};
for line in webhooks::dispatch(
&dirs,
&client,
"schedule.started",
"Scheduled task started",
&format!("{} started", task_label),
json!({ "scheduleId": task_id, "operation": spec.operation, "cron": spec.cron }),
) {
log.line(&line);
}
let mut outcome = execute(&dirs, &spec, &task_id, &root, &client, deadline, &mut log);
if let Some(error) = outcome.error.take() {
outcome.error = Some(annotate_session_failure(error, &spec));
}
let duration_ms = started_at.elapsed().as_millis() as u64;
history::append(
&dirs,
&task_id,
&HistoryLine::Finished {
run_id,
ts: history::now_iso(),
success: outcome.error.is_none(),
error: outcome.error.clone(),
duration_ms,
jobids: if outcome.jobids.is_empty() {
None
} else {
Some(outcome.jobids.clone())
},
stats: outcome.stats.clone(),
},
);
// Release BEFORE the completion webhooks: the run's work is done, and holding the lock
// through up-to-minutes of sequential webhook delivery would make the next scheduled fire
// skip as "already-running".
run_lock.release();
let (event, title, body) = match &outcome.error {
None => (
"schedule.completed",
"Scheduled task completed",
format!("{} completed successfully", task_label),
),
Some(error) => (
"schedule.failed",
"Scheduled task failed",
format!("{} failed: {}", task_label, error),
),
};
let mut data = json!({ "scheduleId": task_id, "operation": spec.operation, "durationMs": duration_ms });
if let Some(error) = &outcome.error {
data["error"] = Value::String(error.clone());
}
for line in webhooks::dispatch(&dirs, &client, event, title, &body, data) {
log.line(&line);
}
// OS toast for the terminal state — hardcoded to completed/failed (started would be noise).
// Scheduled runs happen with the GUI possibly closed, so the runner must post it itself.
if let Err(e) = os::notify_headless(title, &body) {
log.line(&format!("os notification failed: {}", e));
}
log.line(&format!(
"finished: {} ({} ms)",
outcome.error.as_deref().unwrap_or("success"),
duration_ms
));
if outcome.setup_failure {
2
} else if outcome.error.is_some() {
1
} else {
0
}
}
/// Whether this launchd fire is a wake-catch-up (a run for a time missed while asleep/off) rather
/// than an on-time fire. launchd fires an on-time job at the scheduled minute — which the cron
/// matches — whereas a catch-up fires at wake time, on some arbitrary non-matching minute. We
/// check the current AND previous minute so launchd's sub-second jitter across a minute boundary
/// still counts as on-time. Unparseable cron fails open (does not suppress).
#[cfg(target_os = "macos")]
fn is_launchd_catchup(spec: &JobSpec) -> bool {
use chrono::{Datelike, Timelike};
let Ok(cron) = super::cronconv::parse(&spec.cron) else {
return false;
};
let now = chrono::Local::now();
for minutes_ago in [0i64, 1] {
let t = now - chrono::Duration::minutes(minutes_ago);
if super::cronconv::matches(
&cron,
t.minute() as u16,
t.hour() as u16,
t.day() as u16,
t.month() as u16,
t.weekday().num_days_from_sunday() as u16,
) {
return false;
}
}
true
}
/// Whether the user currently has a real login session — the ONLY thing that authorizes a
/// user-mode run. `/run/user/<uid>` alone is NOT that check: `loginctl enable-linger` keeps the
/// user manager (and the runtime dir) alive after logout. And raw `SESSIONS=` entries are not
/// enough either: on distros whose cron PAM stack includes pam_systemd, the cron job that fired
/// us registers its OWN logind session (SERVICE=cron, CLASS=background on current systemd) — a
/// gate counting raw sessions would authorize itself. So each session id is checked against its
/// `/run/systemd/sessions/<id>` state file and background/cron/at sessions are excluded.
///
/// Flatpak: the sandbox never sees `/run/systemd` (a reserved path — even `--filesystem=host`
/// mounts the host at /run/host, never over /run), so the strict check is unreachable there.
/// Gate on the proxied session D-Bus socket instead: flatpak wires it into the sandbox only
/// when the host session bus exists, and it is exactly the context a user-mode run needs
/// (keyring via secret-service, portals). Accepted caveat: lingering keeps the host bus alive,
/// so under Flatpak lingering counts as logged in.
///
/// No other leniency: without logind state we cannot PROVE a session, and a user-mode task must
/// never run outside one just because borrowable context happens to exist — that is what System
/// mode is for. (systemd-logind and elogind both write these files.)
#[cfg(target_os = "linux")]
pub(super) fn user_has_login_session() -> bool {
if crate::is_flatpak() {
return session_runtime_dir()
.map(|dir| dir.join("bus").exists())
.unwrap_or(false);
}
let uid = unsafe { libc::getuid() };
let Ok(state) = std::fs::read_to_string(format!("/run/systemd/users/{}", uid)) else {
return false;
};
let Some(sessions) = state
.lines()
.find_map(|line| line.strip_prefix("SESSIONS="))
else {
return false;
};
sessions.split_whitespace().any(is_real_login_session)
}
/// Whether a logind session id is a live user login rather than a background job session
/// (cron/at via pam_systemd) or one already tearing down.
#[cfg(target_os = "linux")]
fn is_real_login_session(session_id: &str) -> bool {
let Ok(info) = std::fs::read_to_string(format!("/run/systemd/sessions/{}", session_id)) else {
return false;
};
let field = |key: &str| {
info.lines()
.find_map(|line| line.strip_prefix(key))
.unwrap_or("")
.trim()
};
// background / background-light are the non-login classes (systemd ≥ 252 puts cron and at
// jobs there); on older systemd those jobs still carry the scheduler's SERVICE name with
// CLASS=user, hence the explicit service exclusions. A "closing" session has already lost
// its user context.
if field("CLASS=").starts_with("background") || field("STATE=") == "closing" {
return false;
}
!matches!(
field("SERVICE="),
"cron" | "crond" | "cronie" | "atd" | "anacron"
)
}
/// The user manager's runtime dir, when present — the session context worth borrowing.
#[cfg(target_os = "linux")]
fn session_runtime_dir() -> Option<std::path::PathBuf> {
let dir = std::path::PathBuf::from(format!("/run/user/{}", unsafe { libc::getuid() }));
dir.is_dir().then_some(dir)
}
/// Borrow the login session's context: XDG_RUNTIME_DIR and the session D-Bus address are what
/// keyring password commands (secret-tool) and gvfs mounts need. Inherited by the transient
/// rclone daemon and everything it spawns.
#[cfg(target_os = "linux")]
fn borrow_session_env(runtime_dir: &std::path::Path, log: &mut RunLog) {
if std::env::var_os("XDG_RUNTIME_DIR").is_none() {
std::env::set_var("XDG_RUNTIME_DIR", runtime_dir);
}
let bus = runtime_dir.join("bus");
if std::env::var_os("DBUS_SESSION_BUS_ADDRESS").is_none() && bus.exists() {
std::env::set_var(
"DBUS_SESSION_BUS_ADDRESS",
format!("unix:path={}", bus.display()),
);
}
log.line("user mode: borrowed login session environment (XDG_RUNTIME_DIR, session D-Bus)");
}
/// Failure hints for session-context errors, so the history/webhook error names the actual fix
/// instead of leaving the user to guess. macOS user-mode runs execute as the app via a
/// LaunchAgent — a protected-folder denial means the app itself lacks the grant (and a background
/// run can't prompt), so the fix is to grant the APP. System-mode runs that trip session-shaped
/// errors are pointed at the 'User' run mode (or, on macOS, cron's FDA grant).
fn annotate_session_failure(error: String, spec: &JobSpec) -> String {
let lower = error.to_lowercase();
if spec.is_user_mode() {
#[cfg(target_os = "macos")]
{
if lower.contains("operation not permitted") || lower.contains("permission denied") {
return format!(
"{} — this task runs as Rclone UI, but a scheduled run cannot show a permission prompt, so the app must be granted access first: grant Rclone UI access to the folder (open it once in the app), or add Rclone UI to Full Disk Access in System Settings → Privacy & Security.",
error
);
}
}
return error;
}
let session_shaped = lower.contains("operation not permitted")
|| lower.contains("permission denied")
|| lower.contains("password command")
|| lower.contains("directory not found")
|| lower.contains("no such file or directory");
if !session_shaped {
return error;
}
#[cfg(target_os = "macos")]
let fda_hint = ", or grant Full Disk Access to /usr/sbin/cron in System Settings → Privacy & Security";
#[cfg(not(target_os = "macos"))]
let fda_hint = "";
format!(
"{} — this schedule runs in System mode, outside your login session: no OS keychain, session-mounted drives, or (on macOS) protected folders. If it works when run manually, switch its run mode to 'User' in the schedule's settings{}.",
error, fda_hint
)
}
struct RunOutcome {
error: Option<String>,
setup_failure: bool,
jobids: Vec<i64>,
stats: Option<Value>,
}
impl RunOutcome {
fn setup(error: String) -> Self {
Self {
error: Some(error),
setup_failure: true,
jobids: Vec::new(),
stats: None,
}
}
}
fn execute(
dirs: &AppDirs,
spec: &JobSpec,
task_id: &str,
root: &storeread::RootState,
client: &reqwest::Client,
deadline: Instant,
log: &mut RunLog,
) -> RunOutcome {
// Binary resolution.
let binary = if spec.binary == "app-default" {
match root.rclone_path.as_deref().filter(|p| !p.is_empty()) {
Some(p) => p.to_string(),
None => return RunOutcome::setup("no rclone binary configured — open Rclone UI to set one up".to_string()),
}
} else {
spec.binary.clone()
};
if !std::path::Path::new(&binary).is_file() {
return RunOutcome::setup(format!(
"rclone binary not found at {} — open Rclone UI to repair the schedule",
binary
));
}
// Config + env.
let host = match storeread::read_host(dirs, &spec.host_id) {
Ok(h) => h,
Err(e) => return RunOutcome::setup(e),
};
let config_path = storeread::resolve_config_path(dirs, &host, &spec.config_id);
if !config_path.is_file() {
return RunOutcome::setup(format!(
"config file not found at {} — open Rclone UI to repair the schedule",
config_path.display()
));
}
let config_entry = storeread::find_config(&host, &spec.config_id);
let env = match storeread::build_run_env(&host, config_entry, &config_path) {
Ok(env) => env,
Err(e) => return RunOutcome::setup(e),
};
// Transient daemon.
let port = match pick_port() {
Ok(p) => p,
Err(e) => return RunOutcome::setup(e),
};
let user = random_token("user");
let pass = random_token("pass");
let base = format!("http://127.0.0.1:{}", port);
log.line(&format!("starting transient daemon: {} (port {})", binary, port));
let daemon_log_path = history::log_path(dirs, task_id).with_extension("daemon.log");
// Verbose (INFO) logging grows fast — rotate the daemon log independently of the runner log.
history::rotate_file(&daemon_log_path, 4 * 1024 * 1024);
let daemon_log = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&daemon_log_path)
.ok();
let rc_addr = format!("127.0.0.1:{}", port);
let mut daemon_args = vec![
"rcd",
"--rc-addr",
&rc_addr,
"--rc-user",
&user,
"--rc-pass",
&pass,
];
if spec.verbose_logging {
daemon_args.extend(["--log-level", "INFO"]);
}
let mut cmd = Command::new(&binary);
cmd.args(&daemon_args);
for (k, v) in &env {
cmd.env(k, v);
}
cmd.stdin(Stdio::null());
cmd.stdout(Stdio::null());
cmd.stderr(match daemon_log {
Some(file) => Stdio::from(file),
None => Stdio::null(),
});
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
}
let child = match cmd.spawn() {
Ok(c) => c,
Err(e) => return RunOutcome::setup(format!("failed to start rclone: {}", e)),
};
history::record_daemon_pid(dirs, task_id, child.id());
// Tie the daemon's lifetime to this process: Task Scheduler's hard kill (TerminateProcess)
// runs no destructors, so without the job object a hung, hard-killed runner orphans it.
#[cfg(windows)]
let job = match super::winjob::KillOnCloseJob::assign(&child) {
Ok(job) => Some(job),
Err(e) => {
log.line(&format!(
"job object unavailable ({}) — a hard-killed runner would orphan the daemon until the next run's cleanup",
e
));
None
}
};
let mut daemon = DaemonGuard {
child,
client: client.clone(),
quit_url: format!("{}/core/quit", base),
user: user.clone(),
pass: pass.clone(),
cleaned: false,
#[cfg(windows)]
_job: job,
};
// Readiness.
let ready_deadline = Instant::now() + READINESS_TIMEOUT;
loop {
if let Ok(Some(status)) = daemon.child.try_wait() {
return RunOutcome::setup(format!(
"rclone daemon exited during startup (code {:?}) — see the task's daemon log",
status.code()
));
}
let ready = rc_call(client, &base, &user, &pass, "/rc/noop", &json!({})).is_ok();
if ready {
break;
}
if Instant::now() >= ready_deadline {
return RunOutcome::setup("rclone daemon did not become ready within 15s".to_string());
}
std::thread::sleep(Duration::from_millis(250));
}
// Execute the stored requests sequentially (deadline covers the whole run — set in run()).
let mut jobids: Vec<i64> = Vec::new();
let mut stats: Option<Value> = None;
for request in &spec.requests {
let submitted = match rc_call(client, &base, &user, &pass, &request.endpoint, &request.body)
{
Ok(v) => v,
Err(e) => {
return RunOutcome {
error: Some(format!("failed to submit {}: {}", request.endpoint, e)),
setup_failure: false,
jobids,
stats,
}
}
};
let Some(jobid) = submitted.get("jobid").and_then(|j| j.as_i64()) else {
return RunOutcome {
error: Some(format!(
"{} returned no jobid: {}",
request.endpoint, submitted
)),
setup_failure: false,
jobids,
stats,
};
};
jobids.push(jobid);
log.line(&format!("submitted {} as job {}", request.endpoint, jobid));
// Poll to terminal state.
let job_status: Value = loop {
if TERMINATED.load(Ordering::SeqCst) {
let _ = rc_call(client, &base, &user, &pass, "/job/stop", &json!({ "jobid": jobid }));
return RunOutcome {
error: Some("terminated by the system".to_string()),
setup_failure: false,
jobids,
stats,
};
}
if Instant::now() >= deadline {
let _ = rc_call(client, &base, &user, &pass, "/job/stop", &json!({ "jobid": jobid }));
return RunOutcome {
error: Some(format!("timed out after {} seconds", spec.max_run_seconds)),
setup_failure: false,
jobids,
stats,
};
}
match rc_call(client, &base, &user, &pass, "/job/status", &json!({ "jobid": jobid })) {
Ok(status) => {
if status.get("finished").and_then(|f| f.as_bool()) == Some(true) {
break status;
}
}
Err(e) => {
// Daemon died mid-run (crash, or the GUI's "stop all rclone processes").
if let Ok(Some(code)) = daemon.child.try_wait() {
return RunOutcome {
error: Some(format!(
"rclone daemon exited unexpectedly (code {:?}): {}",
code.code(),
e
)),
setup_failure: false,
jobids,
stats,
};
}
}
}
std::thread::sleep(POLL_INTERVAL);
};
// Best-effort stats before evaluating the outcome.
if let Ok(job_stats) = rc_call(
client,
&base,
&user,
&pass,
"/core/stats",
&json!({ "group": format!("job/{}", jobid) }),
) {
stats = Some(json!({
"bytes": job_stats.get("bytes"),
"transfers": job_stats.get("transfers"),
"errors": job_stats.get("errors"),
}));
}
if let Some(error) = evaluate_job_failure(&job_status) {
return RunOutcome {
error: Some(error),
setup_failure: false,
jobids,
stats,
};
}
log.line(&format!("job {} completed successfully", jobid));
}
daemon.shutdown();
RunOutcome {
error: None,
setup_failure: false,
jobids,
stats,
}
}
/// Failure detection mirroring the app: the job-level error, plus per-result errors from batch
/// jobs. Deliberately stricter than the app's launch check (which only fails when ALL batch
/// items fail): a scheduled run with partial failures must not report success.
fn evaluate_job_failure(job_status: &Value) -> Option<String> {
if let Some(error) = job_status.get("error").and_then(|e| e.as_str()) {
if !error.is_empty() {
return Some(error.to_string());
}
}
let results = job_status
.get("output")
.and_then(|o| o.get("results"))
.and_then(|r| r.as_array())?;
let failed: Vec<String> = results
.iter()
.filter_map(|result| {
let error = result.get("error").and_then(|e| e.as_str())?;
if error.is_empty() {
return None;
}
let input = result.get("input");
let path = input
.and_then(|i| i.get("srcRemote").or_else(|| i.get("dstRemote")))
.and_then(|p| p.as_str())
.unwrap_or("unknown");
Some(format!("{}: {}", path, error))
})
.collect();
if failed.is_empty() {
None
} else {
Some(format!(
"{} of {} operations failed — {}",
failed.len(),
results.len(),
failed.join("; ")
))
}
}
fn rc_call(
client: &reqwest::Client,
base: &str,
user: &str,
pass: &str,
endpoint: &str,
body: &Value,
) -> Result<Value, String> {
tauri::async_runtime::block_on(async {
let response = client
.post(format!("{}{}", base, endpoint))
.basic_auth(user, Some(pass))
.json(body)
.send()
.await
.map_err(|e| e.to_string())?;
let status = response.status();
let value: Value = response.json().await.map_err(|e| e.to_string())?;
if !status.is_success() {
let message = value
.get("error")
.and_then(|e| e.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| format!("status {}", status));
return Err(message);
}
Ok(value)
})
}
fn pick_port() -> Result<u16, String> {
for _ in 0..10 {
let listener = std::net::TcpListener::bind(("127.0.0.1", 0))
.map_err(|e| format!("failed to allocate a port: {}", e))?;
let port = listener
.local_addr()
.map_err(|e| e.to_string())?
.port();
drop(listener);
// Never collide with the GUI daemon's fixed RC port.
if port != 5572 {
return Ok(port);
}
}
Err("could not allocate a local port".to_string())
}
fn random_token(salt: &str) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(format!(
"{:?}-{}-{}",
SystemTime::now(),
std::process::id(),
salt
));
hasher
.finalize()
.iter()
.take(12)
.map(|b| format!("{:02x}", b))
.collect()
}
/// Guarantees the transient daemon dies with the run — graceful /core/quit, then kill. The Drop
/// impl covers panics and every early-return path; the next run's stale-lock daemonPid cleanup
/// (and Task Scheduler's ExecutionTimeLimit on Windows) are the nets behind this net — cron
/// itself does not supervise or kill job process trees.
struct DaemonGuard {
child: Child,
client: reqwest::Client,
quit_url: String,
user: String,
pass: String,
cleaned: bool,
/// Kill-on-close job object holding the daemon (see winjob.rs). Dropped after the graceful
/// shutdown; the kernel drops it on ANY runner death, including TerminateProcess.
#[cfg(windows)]
_job: Option<super::winjob::KillOnCloseJob>,
}
impl DaemonGuard {
fn shutdown(&mut self) {
if self.cleaned {
return;
}
self.cleaned = true;
let _ = tauri::async_runtime::block_on(async {
self.client
.post(&self.quit_url)
.basic_auth(&self.user, Some(&self.pass))
.json(&json!({}))
.send()
.await
});
let grace_deadline = Instant::now() + Duration::from_secs(2);
loop {
if matches!(self.child.try_wait(), Ok(Some(_))) {
return;
}
if Instant::now() >= grace_deadline {
break;
}
std::thread::sleep(Duration::from_millis(100));
}
let _ = self.child.kill();
let _ = self.child.wait();
}
}
impl Drop for DaemonGuard {
fn drop(&mut self) {
self.shutdown();
}
}
+524
View File
@@ -0,0 +1,524 @@
//! Windows backend: Task Scheduler via schtasks.exe with full XML task definitions
//! (the /XML route has far better trigger fidelity than /SC flags).
//!
//! Missed fires are NOT replayed (`StartWhenAvailable` is false) — deliberate policy, matching
//! cron's no-catch-up semantics on Unix: a missed window is skipped, never run late.
//!
//! User-mode tasks (the default) register with the InteractiveToken logon type: they run only
//! while the user is logged on, inside that session, with mapped drives available. System-mode
//! tasks use S4U (Service-for-User) instead: they run as the registering user even while logged
//! out, with no password stored — matching cron semantics on Linux. The one S4U restriction: no
//! access to Windows-authenticated network resources (mapped drives, SMB with implicit auth);
//! rclone remotes with their own credentials are unaffected.
use std::collections::BTreeSet;
use std::path::PathBuf;
use std::process::Command;
use super::cronconv::{DayShape, SchtasksTrigger};
use super::storeread::AppDirs;
use super::{InstallState, RenderedSchedule, SchedulerBackend};
const TASK_FOLDER: &str = "RcloneUI";
const TASK_PREFIX: &str = "task-";
pub struct SchtasksBackend {
artifacts_dir: PathBuf,
}
impl SchtasksBackend {
pub fn new(dirs: &AppDirs) -> Self {
Self {
artifacts_dir: dirs.app_data.join("scheduler").join("artifacts"),
}
}
fn task_name(task_id: &str) -> String {
format!("\\{}\\{}{}", TASK_FOLDER, TASK_PREFIX, task_id)
}
fn xml_path(&self, task_id: &str) -> PathBuf {
self.artifacts_dir.join(format!("{}.xml", task_id))
}
fn schtasks(args: &[&str]) -> Result<std::process::Output, String> {
let mut cmd = Command::new("schtasks");
cmd.args(args);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
}
cmd.output()
.map_err(|e| format!("failed to run schtasks: {}", e))
}
fn schtasks_checked(args: &[&str], verb: &str) -> Result<(), String> {
let output = Self::schtasks(args)?;
if output.status.success() {
return Ok(());
}
Err(format!(
"schtasks {} failed: {}",
verb,
String::from_utf8_lossy(&output.stderr).trim()
))
}
fn build_xml(task_id: &str, rendered: &RenderedSchedule) -> Result<String, String> {
let triggers = super::cronconv::to_schtasks(&rendered.cron)?;
let mut triggers_xml = String::new();
for trigger in &triggers {
triggers_xml.push_str(&render_trigger(trigger));
}
let args_joined = rendered
.args
.iter()
.map(|arg| quote_windows_arg(arg))
.collect::<Vec<_>>()
.join(" ");
// User-mode tasks use the interactive token: they run only while the user is logged on,
// inside that session (mapped drives and implicit-auth shares work). System tasks use
// S4U (see module docs).
let logon_type = if rendered.user_mode {
"InteractiveToken"
} else {
"S4U"
};
// One hour above the runner's own deadline, so the runner always times the job out
// gracefully (stop + history + webhook) before Task Scheduler hard-kills the process.
let time_limit_hours = rendered.max_run_seconds.div_ceil(3600) + 1;
Ok(format!(
r#"<?xml version="1.0" encoding="UTF-16"?>
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
<RegistrationInfo>
<Description>Rclone UI scheduled task {name}</Description>
<URI>{uri}</URI>
</RegistrationInfo>
<Triggers>
{triggers} </Triggers>
<Principals>
<Principal id="Author">
<LogonType>{logon_type}</LogonType>
<RunLevel>LeastPrivilege</RunLevel>
</Principal>
</Principals>
<Settings>
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
<StartWhenAvailable>false</StartWhenAvailable>
<Enabled>{enabled}</Enabled>
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
<ExecutionTimeLimit>PT{time_limit_hours}H</ExecutionTimeLimit>
<Hidden>false</Hidden>
<WakeToRun>false</WakeToRun>
</Settings>
<Actions Context="Author">
<Exec>
<Command>"{program}"</Command>
<Arguments>{args}</Arguments>
</Exec>
</Actions>
</Task>
"#,
name = escape_xml(&rendered.display_name),
uri = escape_xml(&Self::task_name(task_id)),
triggers = triggers_xml,
logon_type = logon_type,
// Task-level Enabled (what /Change /ENABLE|/DISABLE flips): installing directly in
// the target state means a disabled task is never briefly armed between a create and
// a follow-up disable. is_installed keys off this same element.
enabled = rendered.enabled,
program = escape_xml(&rendered.program.to_string_lossy()),
// Each arg is CommandLineToArgvW-quoted (data-dir paths can contain spaces).
args = escape_xml(&args_joined),
))
}
fn write_utf16le(path: &PathBuf, content: &str) -> Result<(), String> {
let mut bytes: Vec<u8> = vec![0xFF, 0xFE]; // UTF-16LE BOM
for unit in content.encode_utf16() {
bytes.extend_from_slice(&unit.to_le_bytes());
}
std::fs::write(path, bytes).map_err(|e| format!("failed to write task XML: {}", e))
}
}
fn render_trigger(trigger: &SchtasksTrigger) -> String {
let start = format!(
"2024-01-01T{:02}:{:02}:00",
trigger.start_hour, trigger.start_minute
);
let repetition = match trigger.repetition {
Some((interval, duration_minutes)) => {
// Durations arrive one interval short of the hour/day window (cronconv): the
// Duration is endpoint-inclusive, so a full-window duration would fire once more at
// the top of the next window.
format!(
" <Repetition>\n <Interval>PT{}M</Interval>\n <Duration>PT{}M</Duration>\n <StopAtDurationEnd>false</StopAtDurationEnd>\n </Repetition>\n",
interval, duration_minutes
)
}
None => String::new(),
};
let schedule = match &trigger.shape {
DayShape::Daily => {
" <ScheduleByDay>\n <DaysInterval>1</DaysInterval>\n </ScheduleByDay>\n"
.to_string()
}
DayShape::Weekly(days) => {
let day_elements: String = days
.iter()
.map(|d| format!(" <{}/>\n", dow_element(*d)))
.collect();
format!(
" <ScheduleByWeek>\n <WeeksInterval>1</WeeksInterval>\n <DaysOfWeek>\n{} </DaysOfWeek>\n </ScheduleByWeek>\n",
day_elements
)
}
DayShape::Monthly { days, months } => {
let day_elements: String = days
.iter()
.map(|d| format!(" <Day>{}</Day>\n", d))
.collect();
let month_elements: String = month_elements(months);
format!(
" <ScheduleByMonth>\n <DaysOfMonth>\n{} </DaysOfMonth>\n <Months>\n{} </Months>\n </ScheduleByMonth>\n",
day_elements, month_elements
)
}
DayShape::MonthlyDow { dows, months } => {
// Every week of the month (1-4 + Last): cron weekday semantics have no week-of-month
// notion. A day that is both the 4th and the last matching weekday still fires once —
// the weeks are one trigger's calendar, not separate triggers.
let day_elements: String = dows
.iter()
.map(|d| format!(" <{}/>\n", dow_element(*d)))
.collect();
let month_elements: String = month_elements(months);
format!(
" <ScheduleByMonthDayOfWeek>\n <Weeks>\n <Week>1</Week>\n <Week>2</Week>\n <Week>3</Week>\n <Week>4</Week>\n <Week>Last</Week>\n </Weeks>\n <DaysOfWeek>\n{} </DaysOfWeek>\n <Months>\n{} </Months>\n </ScheduleByMonthDayOfWeek>\n",
day_elements, month_elements
)
}
};
format!(
" <CalendarTrigger>\n <StartBoundary>{}</StartBoundary>\n <Enabled>true</Enabled>\n{}{} </CalendarTrigger>\n",
start, repetition, schedule
)
}
fn dow_element(dow: u16) -> &'static str {
match dow {
0 => "Sunday",
1 => "Monday",
2 => "Tuesday",
3 => "Wednesday",
4 => "Thursday",
5 => "Friday",
_ => "Saturday",
}
}
fn month_elements(months: &BTreeSet<u16>) -> String {
const NAMES: [&str; 12] = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
months
.iter()
.filter(|m| (1..=12).contains(*m))
.map(|m| format!(" <{}/>\n", NAMES[(*m - 1) as usize]))
.collect()
}
impl SchedulerBackend for SchtasksBackend {
fn install(&self, task_id: &str, rendered: &RenderedSchedule) -> Result<(), String> {
let xml = Self::build_xml(task_id, rendered)?;
std::fs::create_dir_all(&self.artifacts_dir)
.map_err(|e| format!("failed to create artifacts dir: {}", e))?;
let xml_path = self.xml_path(task_id);
Self::write_utf16le(&xml_path, &xml)?;
let task_name = Self::task_name(task_id);
let xml_path_text = xml_path.to_string_lossy().into_owned();
let mut args = vec![
"/Create",
"/F",
"/TN",
task_name.as_str(),
"/XML",
xml_path_text.as_str(),
];
// /NP: no stored password (S4U principal only — interactive-token tasks don't store one).
if !rendered.user_mode {
args.push("/NP");
}
let create_result = Self::schtasks_checked(&args, "/Create");
// S4U needs the user's SID resolved; service accounts on headless/CI machines can't.
if let Err(error) = &create_result {
if error.contains("No mapping between account names") {
return Err(
"Could not register the task: this Windows account's identity (SID) cannot be resolved, which S4U scheduled tasks require. Run Rclone UI under a regular user account."
.to_string(),
);
}
}
create_result
}
fn uninstall(&self, task_id: &str) -> Result<(), String> {
let delete = Self::schtasks(&["/Delete", "/F", "/TN", &Self::task_name(task_id)])?;
let _ = std::fs::remove_file(self.xml_path(task_id));
if delete.status.success() {
return Ok(());
}
// /Delete's error text is localized, so "already absent" (benign) can't be matched by
// string. Confirm via the locale-invariant XML query instead: still installed after a
// failed delete (access denied, service trouble) is a REAL failure — swallowing it would
// report an uninstall that never happened and leave the task firing forever.
match self.is_installed(task_id)? {
InstallState::NotInstalled => Ok(()),
InstallState::Installed { .. } => Err(format!(
"schtasks /Delete failed: {}",
String::from_utf8_lossy(&delete.stderr).trim()
)),
}
}
fn set_enabled(&self, task_id: &str, enabled: bool) -> Result<(), String> {
// /Change's failure text is LOCALIZED, so a missing task could never surface as the
// NOT_REGISTERED sentinel the way it does on the other backends — and mod.rs's
// disable-every-backend path relies on that sentinel to treat "no artifact" as benign.
// Without it, disabling a task whose registration failed errors out, isEnabled stays
// true, and the next startup reconcile re-arms a task the user tried to pause. Prove
// absence first via the locale-invariant query.
if matches!(self.is_installed(task_id)?, InstallState::NotInstalled) {
return Err(super::NOT_REGISTERED.to_string());
}
let flag = if enabled { "/ENABLE" } else { "/DISABLE" };
Self::schtasks_checked(
&["/Change", "/TN", &Self::task_name(task_id), flag],
"/Change",
)
}
fn run_now(&self, task_id: &str) -> Result<(), String> {
Self::schtasks_checked(&["/Run", "/TN", &Self::task_name(task_id)], "/Run")
}
fn is_installed(&self, task_id: &str) -> Result<InstallState, String> {
// Query the task's XML definition rather than the CSV listing: the CSV Status column is
// LOCALIZED ("Disabled" is "Deaktiviert"/"Désactivé"/… elsewhere), while the XML
// <Enabled> element is locale-invariant. Install writes the Settings-level Enabled (the
// flag /Change /ENABLE|/DISABLE flips) and trigger-level Enabled is always true, so any
// 'false' in the document means the task is disabled.
let output = Self::schtasks(&["/Query", "/TN", &Self::task_name(task_id), "/XML"])?;
if output.status.success() {
// Console output can be UTF-16 on some systems — drop NULs before matching.
let mut bytes = output.stdout;
bytes.retain(|&b| b != 0);
let xml = String::from_utf8_lossy(&bytes).to_lowercase();
let enabled = !xml.contains("<enabled>false</enabled>");
return Ok(InstallState::Installed { enabled });
}
// A failed per-task query does NOT prove absence — access denial or a stopped Task
// Scheduler service also exit non-zero (with LOCALIZED stderr, so no string matching).
// Disambiguate via the full listing: if the service answers and our task name isn't in
// it, the task is really gone; anything else is a real error that must not be reported
// as "not installed" (the uninstall verification would turn it into a false success).
let listing = Self::schtasks(&["/Query", "/FO", "CSV", "/NH"])?;
if !listing.status.success() {
return Err(format!(
"schtasks /Query failed: {}",
String::from_utf8_lossy(&listing.stderr).trim()
));
}
let mut bytes = listing.stdout;
bytes.retain(|&b| b != 0);
let stdout = String::from_utf8_lossy(&bytes);
// Closing quote included so task-abc never matches task-abc2.
let needle = format!("{}\"", Self::task_name(task_id));
if stdout.contains(&needle) {
return Err(format!(
"schtasks /Query /TN failed although the task exists: {}",
String::from_utf8_lossy(&output.stderr).trim()
));
}
Ok(InstallState::NotInstalled)
}
}
/// Whether the task's definition references THIS profile's data dir. Task Scheduler's namespace
/// is machine-global: another Windows account running Rclone UI registers under the very same
/// `\RcloneUI\task-*` names, and an elevated sweep could see (and delete) those. Every task we
/// register bakes `--data-dir <this user's app_data>` into its arguments, so requiring it in
/// the XML scopes the sweep to tasks this profile actually owns. Unreadable definitions are NOT
/// ours to judge — skipped.
fn owned_by_this_profile(task_id: &str, dirs: &AppDirs) -> bool {
let Ok(output) =
SchtasksBackend::schtasks(&["/Query", "/TN", &SchtasksBackend::task_name(task_id), "/XML"])
else {
return false;
};
if !output.status.success() {
return false;
}
let mut bytes = output.stdout;
bytes.retain(|&b| b != 0);
let xml = String::from_utf8_lossy(&bytes).to_lowercase();
let needle = escape_xml(&dirs.app_data.to_string_lossy()).to_lowercase();
xml.contains(&needle)
}
/// Uninstall Task Scheduler entries except those in `keep` (task ids that still have job
/// files — empty set sweeps everything). Only entries provably registered by this Windows
/// profile are touched (see `owned_by_this_profile`).
pub fn sweep_orphans(
backend: &dyn SchedulerBackend,
keep: &std::collections::HashSet<String>,
dirs: &AppDirs,
) -> u32 {
let Ok(output) = SchtasksBackend::schtasks(&["/Query", "/FO", "CSV", "/NH"]) else {
return 0;
};
// Console output can be UTF-16 on some systems — drop NULs before matching.
let mut bytes = output.stdout;
bytes.retain(|&b| b != 0);
let stdout = String::from_utf8_lossy(&bytes);
let needle = format!("\\{}\\{}", TASK_FOLDER, TASK_PREFIX);
let mut removed = 0;
for line in stdout.lines() {
let Some(start) = line.find(&needle) else {
continue;
};
let rest = &line[start + needle.len()..];
let task_id: String = rest.chars().take_while(|c| *c != '"').collect();
if keep.contains(&task_id) {
continue;
}
if super::sanitize_id(&task_id).is_ok()
&& owned_by_this_profile(&task_id, dirs)
&& backend.uninstall(&task_id).is_ok()
{
removed += 1;
}
}
removed
}
fn escape_xml(raw: &str) -> String {
raw.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
/// CommandLineToArgvW-style quoting for one argument of the task's <Arguments> string: quote when
/// needed, escape embedded quotes, and double backslash runs that precede a quote (including the
/// closing one). Data-dir paths contain spaces ("C:\Users\John Smith\AppData\…"), so this is
/// load-bearing, not defensive.
fn quote_windows_arg(arg: &str) -> String {
if !arg.is_empty() && !arg.contains([' ', '\t', '"']) {
return arg.to_string();
}
let mut out = String::from("\"");
let mut backslashes = 0;
for c in arg.chars() {
match c {
'\\' => {
backslashes += 1;
out.push('\\');
}
'"' => {
// The n backslashes already emitted must double to 2n, plus one to escape the quote.
out.push_str(&"\\".repeat(backslashes + 1));
out.push('"');
backslashes = 0;
}
_ => {
backslashes = 0;
out.push(c);
}
}
}
// Trailing backslashes double so they don't escape the closing quote.
out.push_str(&"\\".repeat(backslashes));
out.push('"');
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn xml_has_monthly_dow_enabled_state_and_quoted_args() {
let rendered = RenderedSchedule {
// Every Monday in June at 03:00 — the weekday+month combo that needs
// ScheduleByMonthDayOfWeek.
cron: super::super::cronconv::parse("0 3 * 6 1").unwrap(),
program: PathBuf::from(r"C:\Program Files\Rclone UI\Rclone UI.exe"),
args: vec![
"run-task".into(),
"abc".into(),
"--host".into(),
"local".into(),
"--data-dir".into(),
r"C:\Users\John Smith\AppData\Roaming\com.rclone.ui".into(),
],
display_name: "nightly".into(),
user_mode: true,
enabled: false,
max_run_seconds: 120 * 3600,
};
let xml = SchtasksBackend::build_xml("abc", &rendered).unwrap();
assert!(xml.contains("<ScheduleByMonthDayOfWeek>"));
assert!(xml.contains("<Week>Last</Week>"));
assert!(xml.contains("<Monday/>"));
assert!(xml.contains("<June/>"));
// Installed directly in the disabled state (Settings-level Enabled).
assert!(xml.contains("<Enabled>false</Enabled>"));
// Time limit tracks the task's max run (120h) + 1h headroom for a graceful runner stop.
assert!(xml.contains("<ExecutionTimeLimit>PT121H</ExecutionTimeLimit>"));
// The space-containing path arrives quoted; plain args stay bare.
assert!(xml.contains(
r#"run-task abc --host local --data-dir &quot;C:\Users\John Smith\AppData\Roaming\com.rclone.ui&quot;"#
));
assert!(xml.contains("<LogonType>InteractiveToken</LogonType>"));
}
#[test]
fn windows_arg_quoting() {
assert_eq!(quote_windows_arg("run-task"), "run-task");
assert_eq!(quote_windows_arg("--data-dir"), "--data-dir");
assert_eq!(
quote_windows_arg(r"C:\Users\John Smith\AppData\Roaming\com.rclone.ui"),
r#""C:\Users\John Smith\AppData\Roaming\com.rclone.ui""#
);
// Trailing backslash before the closing quote must double.
assert_eq!(quote_windows_arg(r"C:\a dir\"), r#""C:\a dir\\""#);
// Embedded quote: preceding backslashes double, quote gets its own escape.
assert_eq!(quote_windows_arg(r#"a\"b"#), r#""a\\\"b""#);
assert_eq!(quote_windows_arg(""), "\"\"");
}
}
+228
View File
@@ -0,0 +1,228 @@
//! Read-only access to the app's persisted stores for the headless runner and the scheduler
//! commands.
//!
//! Store files are written by tauri-plugin-store + zustand persist: each file is a JSON object
//! whose single key holds a JSON *string* containing `{"state": {...}, "version": n}` — so the
//! value must be parsed twice. Only the fields the scheduler needs are modeled; unknown fields
//! are ignored so unrelated store changes never break the runner.
//!
//! Do NOT add Flatpak `~/.var/app/...` path probing here: inside the sandbox `dirs::data_dir()`
//! already resolves (via XDG_DATA_HOME) to the same remapped path the GUI writes, so the runner
//! reads the identical store — extra path rewriting would only risk pointing at the wrong file.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::de::DeserializeOwned;
use serde::Deserialize;
/// Must match tauri.conf.json `identifier`.
const APP_IDENTIFIER: &str = "com.rclone.ui";
pub struct AppDirs {
/// Store files (tauri-plugin-store resolves against BaseDirectory::AppData — Roaming on
/// Windows).
pub app_data: PathBuf,
/// rclone binaries + `configs/` (the JS side uses appLocalDataDir — Local on Windows).
pub app_local_data: PathBuf,
}
/// Headless resolution — mirrors tauri v2's resolver, which computes app_data_dir as
/// `dirs::data_dir()/<identifier>` and app_local_data_dir as `dirs::data_local_dir()/<identifier>`.
pub fn app_dirs() -> Result<AppDirs, String> {
let data = dirs::data_dir().ok_or("could not resolve the user data directory")?;
let local = dirs::data_local_dir().ok_or("could not resolve the local data directory")?;
Ok(AppDirs {
app_data: data.join(APP_IDENTIFIER),
app_local_data: local.join(APP_IDENTIFIER),
})
}
/// GUI-side resolution via the AppHandle so paths are byte-identical with the webview's.
pub fn app_dirs_from(app: &tauri::AppHandle) -> Result<AppDirs, String> {
use tauri::Manager;
Ok(AppDirs {
app_data: app
.path()
.app_data_dir()
.map_err(|e| format!("failed to resolve app data dir: {}", e))?,
app_local_data: app
.path()
.app_local_data_dir()
.map_err(|e| format!("failed to resolve app local data dir: {}", e))?,
})
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct RootState {
pub rclone_path: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct HostState {
pub proxy: Option<ProxyCfg>,
pub config_files: Vec<ConfigFileEntry>,
pub default_config_path: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct ProxyCfg {
pub url: String,
pub ignored_hosts: Vec<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct ConfigFileEntry {
pub id: Option<String>,
pub label: Option<String>,
pub is_encrypted: bool,
pub pass: Option<String>,
pub pass_command: Option<String>,
}
#[derive(Deserialize)]
struct PersistWrapper<T> {
state: T,
}
fn read_double_encoded<T: DeserializeOwned>(path: &Path, key: &str) -> Result<T, String> {
let raw = std::fs::read_to_string(path)
.map_err(|e| format!("failed to read {}: {}", path.display(), e))?;
let outer: HashMap<String, serde_json::Value> = serde_json::from_str(&raw)
.map_err(|e| format!("invalid store file {}: {}", path.display(), e))?;
let inner = outer
.get(key)
.and_then(|v| v.as_str())
.ok_or_else(|| format!("store key '{}' missing in {}", key, path.display()))?;
let wrapper: PersistWrapper<T> = serde_json::from_str(inner)
.map_err(|e| format!("invalid '{}' state in {}: {}", key, path.display(), e))?;
Ok(wrapper.state)
}
pub fn read_root(dirs: &AppDirs) -> Result<RootState, String> {
read_double_encoded(&dirs.app_data.join("store.json"), "store")
}
pub fn read_host(dirs: &AppDirs, host_id: &str) -> Result<HostState, String> {
read_double_encoded(
&dirs.app_data.join("hosts").join(host_id).join("store.json"),
"host-store",
)
}
/// Mirrors lib/rclone/common.ts getConfigPath: `configs/<id>/rclone.conf` under AppLocalData,
/// except config id 'default' uses the host store's defaultConfigPath when set (so switching
/// binaries never relocates the user's remotes).
pub fn resolve_config_path(dirs: &AppDirs, host: &HostState, config_id: &str) -> PathBuf {
if config_id == "default" {
if let Some(p) = host.default_config_path.as_deref() {
if !p.is_empty() {
return PathBuf::from(p);
}
}
}
dirs.app_local_data
.join("configs")
.join(config_id)
.join("rclone.conf")
}
pub fn find_config<'a>(host: &'a HostState, config_id: &str) -> Option<&'a ConfigFileEntry> {
host.config_files
.iter()
.find(|c| c.id.as_deref() == Some(config_id))
}
/// Mirrors lib/rclone/cli.ts buildRcloneEnv: proxy vars, config pinning, and encrypted-config
/// credentials. Errors when the config is encrypted with nothing stored — the headless runner
/// has no UI to prompt with.
pub fn build_run_env(
host: &HostState,
config: Option<&ConfigFileEntry>,
config_path: &Path,
) -> Result<HashMap<String, String>, String> {
let mut env = HashMap::new();
if let Some(proxy) = &host.proxy {
if !proxy.url.is_empty() {
for key in ["http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY"] {
env.insert(key.to_string(), proxy.url.clone());
}
if !proxy.ignored_hosts.is_empty() {
let joined = proxy.ignored_hosts.join(",");
env.insert("no_proxy".to_string(), joined.clone());
env.insert("NO_PROXY".to_string(), joined);
}
}
}
let config_dir = config_path
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_default();
env.insert(
"RCLONE_CONFIG_DIR".to_string(),
config_dir.to_string_lossy().into_owned(),
);
env.insert(
"RCLONE_CONFIG".to_string(),
config_path.to_string_lossy().into_owned(),
);
if let Some(cfg) = config {
if cfg.is_encrypted {
env.insert("RCLONE_ASK_PASSWORD".to_string(), "false".to_string());
if let Some(cmd) = cfg.pass_command.as_deref().filter(|s| !s.is_empty()) {
env.insert("RCLONE_CONFIG_PASS_COMMAND".to_string(), cmd.to_string());
} else if let Some(pass) = cfg.pass.as_deref().filter(|s| !s.is_empty()) {
env.insert("RCLONE_CONFIG_PASS".to_string(), pass.to_string());
} else {
let label = cfg.label.clone().unwrap_or_else(|| "default".to_string());
return Err(format!(
"Config '{}' is encrypted and no password is stored. Open Rclone UI and save the config password to enable scheduled runs.",
label
));
}
}
}
Ok(env)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn double_decode_reads_state() {
let dir = std::env::temp_dir().join(format!("rcloneui-storetest-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("store.json");
let inner = r#"{"state":{"rclonePath":"/usr/local/bin/rclone","notificationTargets":[{"provider":"slack","url":"https://hooks.slack.com/services/T1/B1/x","isEnabled":true,"events":["schedule.failed"]}],"unknownField":123},"version":3}"#;
let outer = serde_json::json!({ "store": inner });
std::fs::write(&path, serde_json::to_string(&outer).unwrap()).unwrap();
// notificationTargets moved to notifications/targets.json — here it's just one more
// unknown field that must not break the decode.
let state: RootState = read_double_encoded(&path, "store").unwrap();
assert_eq!(state.rclone_path.as_deref(), Some("/usr/local/bin/rclone"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn encrypted_config_without_pass_errors() {
let host = HostState::default();
let cfg = ConfigFileEntry {
id: Some("default".into()),
label: Some("Default config".into()),
is_encrypted: true,
..Default::default()
};
let err = build_run_env(&host, Some(&cfg), Path::new("/tmp/rclone.conf")).unwrap_err();
assert!(err.contains("encrypted"));
}
}
+68
View File
@@ -0,0 +1,68 @@
//! Windows job object that kills the transient rclone daemon when the runner dies for ANY
//! reason. Task Scheduler's ExecutionTimeLimit hard-kills via TerminateProcess, which runs no
//! Rust destructors (DaemonGuard::drop never fires) and does not touch child processes — so a
//! hung, hard-killed runner would orphan its daemon until the next fire's stale-lock reap.
//! JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE ties the daemon's lifetime to this handle instead: the
//! kernel closes every handle of a terminated process, closing the job, killing the members.
use std::os::windows::io::AsRawHandle;
use windows_sys::Win32::Foundation::{CloseHandle, HANDLE};
use windows_sys::Win32::System::JobObjects::{
AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation,
SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
};
pub struct KillOnCloseJob {
handle: HANDLE,
}
// HANDLE is a raw pointer type, hence !Send by default; the handle itself is just a kernel
// object reference — closed exactly once (Drop) and never dereferenced.
unsafe impl Send for KillOnCloseJob {}
impl KillOnCloseJob {
/// Creates a kill-on-close job and assigns `child` to it. Best-effort by contract: on Err
/// the caller proceeds without the safety net (the graceful DaemonGuard shutdown and the
/// next run's stale-lock reap still apply, as before this existed).
pub fn assign(child: &std::process::Child) -> Result<Self, String> {
unsafe {
let handle = CreateJobObjectW(std::ptr::null(), std::ptr::null());
if handle.is_null() {
return Err(format!(
"CreateJobObject failed: {}",
std::io::Error::last_os_error()
));
}
let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed();
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
if SetInformationJobObject(
handle,
JobObjectExtendedLimitInformation,
&info as *const JOBOBJECT_EXTENDED_LIMIT_INFORMATION as *const core::ffi::c_void,
std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
) == 0
{
let error = std::io::Error::last_os_error();
CloseHandle(handle);
return Err(format!("SetInformationJobObject failed: {}", error));
}
if AssignProcessToJobObject(handle, child.as_raw_handle() as HANDLE) == 0 {
let error = std::io::Error::last_os_error();
CloseHandle(handle);
return Err(format!("AssignProcessToJobObject failed: {}", error));
}
Ok(Self { handle })
}
}
}
impl Drop for KillOnCloseJob {
fn drop(&mut self) {
// In the normal path the daemon is already down (DaemonGuard's graceful shutdown runs
// first); closing the last job handle only kills a survivor. On TerminateProcess the
// kernel performs this close for us — that is the entire point.
unsafe { CloseHandle(self.handle) };
}
}
+4 -2
View File
@@ -80,7 +80,8 @@
"deb": { "deb": {
"depends": [ "depends": [
"libayatana-appindicator3-1 | libappindicator3-1", "libayatana-appindicator3-1 | libappindicator3-1",
"zenity | kdialog | yad" "zenity | kdialog | yad",
"cron | cron-daemon"
], ],
"files": { "files": {
"/usr/share/flatpak.metainfo.xml": "flatpak.metainfo.xml" "/usr/share/flatpak.metainfo.xml": "flatpak.metainfo.xml"
@@ -89,7 +90,8 @@
"rpm": { "rpm": {
"depends": [ "depends": [
"(libappindicator-gtk3 or libayatana-appindicator-gtk3)", "(libappindicator-gtk3 or libayatana-appindicator-gtk3)",
"(zenity or kdialog or yad)" "(zenity or kdialog or yad)",
"(cronie or cron)"
] ]
} }
}, },
+104
View File
@@ -0,0 +1,104 @@
import { Select, SelectItem } from '@heroui/react'
import { useQuery } from '@tanstack/react-query'
import { open } from '@tauri-apps/plugin-dialog'
import { FolderOpenIcon } from 'lucide-react'
import { useMemo } from 'react'
import { formatErrorMessage } from '../../lib/errors'
import { probeRcloneBinaryOrThrow } from '../../lib/rclone/common'
import { listDownloadedVersions } from '../../lib/rclone/versions'
export const APP_DEFAULT_BINARY = 'app-default'
const CUSTOM_BINARY = 'custom'
/**
* Picker for the rclone binary a scheduled task runs with: "App default", any downloaded
* managed version, or a custom path (probed before acceptance).
*/
export default function BinarySelect({
value,
onChange,
onError,
label = 'rclone binary',
}: {
value: string
onChange: (path: string) => void
onError?: (message: string) => void
label?: string
}) {
const versionsQuery = useQuery({
queryKey: ['rclone', 'downloaded-versions'],
queryFn: listDownloadedVersions,
})
const knownBinaryPaths = useMemo(
() => (versionsQuery.data ?? []).map((version) => version.path),
[versionsQuery.data]
)
const isCustomBinary = value !== APP_DEFAULT_BINARY && !knownBinaryPaths.includes(value)
const binaryOptions = useMemo(
() => [
{ key: APP_DEFAULT_BINARY, label: 'App default', description: undefined },
...(versionsQuery.data ?? []).map((version) => ({
key: version.path,
label: `v${version.version}`,
description: version.path,
})),
{
key: CUSTOM_BINARY,
label: isCustomBinary ? 'Custom binary' : 'Custom…',
description: isCustomBinary ? value : undefined,
},
],
[versionsQuery.data, isCustomBinary, value]
)
const pickCustomBinary = async () => {
const selected = await open({
title: 'Select rclone binary',
multiple: false,
directory: false,
})
if (!selected) {
return
}
try {
await probeRcloneBinaryOrThrow(selected)
} catch (error) {
onError?.(formatErrorMessage(error, 'The selected file is not a valid rclone binary'))
return
}
onChange(selected)
}
return (
<Select
label={label}
labelPlacement="outside"
selectedKeys={[isCustomBinary ? CUSTOM_BINARY : value]}
onSelectionChange={(keys) => {
const key = Array.from(keys)[0]
if (key === CUSTOM_BINARY) {
pickCustomBinary()
} else if (typeof key === 'string' && key) {
onChange(key)
}
}}
items={binaryOptions}
>
{(option) => (
<SelectItem
key={option.key}
description={option.description}
startContent={
option.key === CUSTOM_BINARY ? (
<FolderOpenIcon className="w-3.5 h-3.5" />
) : undefined
}
>
{option.label}
</SelectItem>
)}
</Select>
)
}
+53
View File
@@ -0,0 +1,53 @@
import { Select, SelectItem } from '@heroui/react'
import { LockIcon } from 'lucide-react'
import type { ConfigFile } from '../../types/config'
/** Picker for the config file a scheduled task runs with (lock icon = encrypted config). */
export default function ConfigSelect({
configFiles,
value,
onChange,
label = 'Config file',
placeholder,
}: {
configFiles: ConfigFile[]
value: string | null
onChange: (id: string) => void
label?: string
placeholder?: string
}) {
return (
<Select
label={label}
labelPlacement="outside"
selectedKeys={value ? [value] : []}
onSelectionChange={(keys) => {
const id = Array.from(keys)[0]
if (typeof id === 'string') {
onChange(id)
}
}}
items={configFiles.filter((config) => !!config.id)}
placeholder={placeholder ?? 'Select a config'}
>
{(config) => (
<SelectItem
key={config.id!}
startContent={
config.isEncrypted ? (
<LockIcon className="w-3.5 h-3.5 text-warning" />
) : undefined
}
>
{config.label || config.id}
</SelectItem>
)}
</Select>
)
}
/** The scheduled runner cannot prompt for passwords — surface this before the task is saved. */
export function configPasswordMissing(configFiles: ConfigFile[], configId: string | null): boolean {
const config = configFiles.find((c) => c.id === configId)
return !!config?.isEncrypted && !config.pass && !config.passCommand
}
+13 -3
View File
@@ -7,6 +7,8 @@ import { startTransition, useCallback, useEffect, useMemo, useRef, useState } fr
interface CronEditorProps { interface CronEditorProps {
expression: string | null expression: string | null
onChange: (newExpression: string | null) => void onChange: (newExpression: string | null) => void
/** Platform-specific validation error from scheduler_validate_cron. */
error?: string | null
} }
interface CronFieldProps { interface CronFieldProps {
@@ -18,7 +20,7 @@ interface CronFieldProps {
const DEFAULT_OPTIONS = ['*', '*/5', '*/10', '*/15', '*/30'] const DEFAULT_OPTIONS = ['*', '*/5', '*/10', '*/15', '*/30']
export default function CronEditor({ expression, onChange }: CronEditorProps) { export default function CronEditor({ expression, onChange, error }: CronEditorProps) {
const [cronExpression, setCronExpression] = useState(expression) const [cronExpression, setCronExpression] = useState(expression)
const [minute, hour, dayOfMonth, month, dayOfWeek] = useMemo( const [minute, hour, dayOfMonth, month, dayOfWeek] = useMemo(
@@ -31,8 +33,7 @@ export default function CronEditor({ expression, onChange }: CronEditorProps) {
let description: string let description: string
try { try {
description = cronstrue.toString(cronExpression) description = cronstrue.toString(cronExpression)
description += description += '. Runs on a system schedule, even when the app is closed.'
'. Tasks are triggered when the UI is running, if the active config is the same.'
} catch { } catch {
description = 'Invalid cron expression' description = 'Invalid cron expression'
} }
@@ -60,6 +61,14 @@ export default function CronEditor({ expression, onChange }: CronEditorProps) {
onChange(cronExpression) onChange(cronExpression)
}, [cronExpression, onChange]) }, [cronExpression, onChange])
// Re-sync when the parent changes `expression` out from under us (the edit drawer coercing a
// cleared field to '* * * * *', or an Advanced-section reset to null). Without this the
// control keeps its stale internal value — showing "not scheduled" while the parent saves a
// real cron. Echoes of our own emitted value set the same string and no-op.
useEffect(() => {
setCronExpression(expression)
}, [expression])
return ( return (
<div className="flex flex-col w-full gap-2"> <div className="flex flex-col w-full gap-2">
<Input <Input
@@ -118,6 +127,7 @@ export default function CronEditor({ expression, onChange }: CronEditorProps) {
</div> </div>
<div className="text-sm text-neutral-500">{readableDescription}</div> <div className="text-sm text-neutral-500">{readableDescription}</div>
{!!error && <div className="text-sm text-danger-500">{error}</div>}
</div> </div>
) )
} }
+350 -62
View File
@@ -8,18 +8,33 @@ import {
DrawerContent, DrawerContent,
DrawerFooter, DrawerFooter,
DrawerHeader, DrawerHeader,
Input,
ScrollShadow, ScrollShadow,
Switch,
Tab,
Tabs,
cn, cn,
} from '@heroui/react' } from '@heroui/react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { platform } from '@tauri-apps/plugin-os' import { platform } from '@tauri-apps/plugin-os'
import CronExpressionParser from 'cron-parser' import { format, formatDistance } from 'date-fns'
import { format } from 'date-fns'
import { CalendarClockIcon } from 'lucide-react' import { CalendarClockIcon } from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { formatErrorMessage } from '../../lib/errors'
import { buildReadablePath } from '../../lib/format' import { buildReadablePath } from '../../lib/format'
import { useNow } from '../../lib/hooks' import { useNow } from '../../lib/hooks'
import {
DEFAULT_MAX_RUN_HOURS,
MAX_RUN_HOURS_LIMIT,
schedulerReadHistory,
schedulerReadLog,
updateScheduledTask as schedulerUpdateTask,
schedulerValidateCron,
} from '../../lib/scheduler'
import { useHostStore } from '../../store/host' import { useHostStore } from '../../store/host'
import type { ScheduledTask } from '../../types/schedules' import type { ScheduledTask } from '../../types/schedules'
import BinarySelect from './BinarySelect'
import ConfigSelect, { configPasswordMissing as isConfigPasswordMissing } from './ConfigSelect'
import CronEditor from './CronEditor' import CronEditor from './CronEditor'
export default function ScheduleEditDrawer({ export default function ScheduleEditDrawer({
@@ -31,15 +46,39 @@ export default function ScheduleEditDrawer({
onClose: () => void onClose: () => void
selectedTask: ScheduledTask selectedTask: ScheduledTask
}) { }) {
const updateScheduledTask = useHostStore((state) => state.updateScheduledTask) const queryClient = useQueryClient()
const configFiles = useHostStore((state) => state.configFiles)
const [cronExpression, setCronExpression] = useState(selectedTask.cron) const [cronExpression, setCronExpression] = useState(selectedTask.cron)
const [configId, setConfigId] = useState(selectedTask.configId)
const [binaryPath, setBinaryPath] = useState(selectedTask.binaryPath)
const [isEnabled, setIsEnabled] = useState(selectedTask.isEnabled)
const [verboseLogging, setVerboseLogging] = useState(selectedTask.verboseLogging ?? false)
const [runMode, setRunMode] = useState<'system' | 'user'>(selectedTask.runMode ?? 'user')
const [maxRunHours, setMaxRunHours] = useState(
String(selectedTask.maxRunHours ?? DEFAULT_MAX_RUN_HOURS)
)
const [logView, setLogView] = useState<'runner' | 'daemon'>('runner')
const [saveError, setSaveError] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
setCronExpression(selectedTask.cron) setCronExpression(selectedTask.cron)
setConfigId(selectedTask.configId)
setBinaryPath(selectedTask.binaryPath)
setIsEnabled(selectedTask.isEnabled)
setVerboseLogging(selectedTask.verboseLogging ?? false)
setRunMode(selectedTask.runMode ?? 'user')
setMaxRunHours(String(selectedTask.maxRunHours ?? DEFAULT_MAX_RUN_HOURS))
setSaveError(null)
} }
}, [isOpen, selectedTask.cron]) }, [isOpen, selectedTask])
const maxRunHoursNumber = Number(maxRunHours)
const maxRunHoursInvalid =
!Number.isInteger(maxRunHoursNumber) ||
maxRunHoursNumber < 1 ||
maxRunHoursNumber > MAX_RUN_HOURS_LIMIT
const source = useMemo( const source = useMemo(
() => () =>
@@ -56,32 +95,99 @@ export default function ScheduleEditDrawer({
// current time on every open (and kept fresh while open) — paused while closed. // current time on every open (and kept fresh while open) — paused while closed.
const now = useNow(isOpen ? 30_000 : null) const now = useNow(isOpen ? 30_000 : null)
const upcomingRuns = useMemo(() => { const cronValidation = useQuery({
try { queryKey: ['scheduler', 'validate-cron', cronExpression],
const parsed = CronExpressionParser.parse(cronExpression, { queryFn: () => schedulerValidateCron(cronExpression),
currentDate: new Date(now), enabled: isOpen && !!cronExpression,
// The response carries the next-runs preview anchored at fetch time; without refetching,
// a frequent schedule (e.g. every minute) drains all 5 entries past `now` while the
// drawer sits open.
refetchInterval: 30_000,
}) })
const runs: Date[] = [] const cronError =
for (let i = 0; i < 5; i++) { cronValidation.data && !cronValidation.data.valid
if (parsed.hasNext()) { ? (cronValidation.data.error ?? 'Invalid cron expression')
runs.push(parsed.next().toDate()) : null
}
}
return runs
} catch {
return []
}
}, [cronExpression, now])
const hasChanges = useMemo( const historyQuery = useQuery({
() => cronExpression !== selectedTask.cron, queryKey: ['scheduler', 'history', selectedTask.id],
[cronExpression, selectedTask.cron] queryFn: () => schedulerReadHistory(selectedTask.id, 10),
enabled: isOpen,
refetchInterval: 15_000,
})
const finishedRuns = useMemo(
() => (historyQuery.data ?? []).filter((line) => line.event === 'finished'),
[historyQuery.data]
) )
const handleSave = useCallback(() => { const selectedConfig = useMemo(
updateScheduledTask(selectedTask.id, { cron: cronExpression }) () => configFiles.find((config) => config.id === configId) ?? null,
[configFiles, configId]
)
const configMissing = !selectedConfig
const configPasswordMissing = isConfigPasswordMissing(configFiles, configId)
// From the validation query, i.e. computed in Rust by the runner's own cron matcher — a JS
// library here can (and did) predict fires real cron never performs (dom/dow star flag).
// The `now` filter keeps the list fresh between refetches of the 30s-anchored query.
const upcomingRuns = useMemo(
() =>
(cronValidation.data?.nextRuns ?? [])
.map((run) => new Date(run))
.filter((run) => run.getTime() > now),
[cronValidation.data, now]
)
const hasChanges = useMemo(
() =>
cronExpression !== selectedTask.cron ||
configId !== selectedTask.configId ||
binaryPath !== selectedTask.binaryPath ||
isEnabled !== selectedTask.isEnabled ||
verboseLogging !== (selectedTask.verboseLogging ?? false) ||
runMode !== (selectedTask.runMode ?? 'user') ||
maxRunHoursNumber !== (selectedTask.maxRunHours ?? DEFAULT_MAX_RUN_HOURS),
[
cronExpression,
configId,
binaryPath,
isEnabled,
verboseLogging,
runMode,
maxRunHoursNumber,
selectedTask,
]
)
const logQuery = useQuery({
queryKey: ['scheduler', 'log', selectedTask.id, logView],
queryFn: () => schedulerReadLog(selectedTask.id, logView),
enabled: isOpen,
refetchInterval: 5_000,
})
const saveMutation = useMutation({
mutationFn: async () => {
setSaveError(null)
await schedulerUpdateTask(selectedTask.id, {
cron: cronExpression,
configId,
binaryPath,
isEnabled,
verboseLogging,
runMode,
maxRunHours: maxRunHoursNumber,
})
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['scheduler'] })
onClose() onClose()
}, [selectedTask.id, cronExpression, updateScheduledTask, onClose]) },
onError: (error) => {
setSaveError(formatErrorMessage(error, 'Failed to save the schedule'))
},
})
return ( return (
<Drawer <Drawer
@@ -126,14 +232,21 @@ export default function ScheduleEditDrawer({
<DrawerBody className="py-0"> <DrawerBody className="py-0">
<ScrollShadow size={30} visibility="top"> <ScrollShadow size={30} visibility="top">
<div className="flex flex-col gap-6 pt-6 pb-10"> <div className="flex flex-col gap-6 pt-6 pb-10">
{selectedTask.lastRunError && ( {!!selectedTask.registrationError && (
<Alert <Alert
color="danger" color="danger"
variant="faded" variant="faded"
title="Last Run Error" title="Not registered with the system scheduler"
> >
<pre className="text-sm break-all whitespace-pre-wrap"> <pre className="text-sm break-all whitespace-pre-wrap">
{selectedTask.lastRunError} {selectedTask.registrationError}
</pre>
</Alert>
)}
{!!saveError && (
<Alert color="danger" variant="faded" title="Save failed">
<pre className="text-sm break-all whitespace-pre-wrap">
{saveError}
</pre> </pre>
</Alert> </Alert>
)} )}
@@ -159,42 +272,94 @@ export default function ScheduleEditDrawer({
</p> </p>
</div> </div>
)} )}
<div className="flex flex-col gap-1">
<p className="text-sm text-foreground-500">
Last Run
</p>
<p className="text-sm">
{selectedTask.lastRun
? format(
new Date(selectedTask.lastRun),
'PPpp'
)
: 'Never'}
</p>
</div> </div>
<div className="flex flex-col gap-1"> </div>
<p className="text-sm text-foreground-500">
Status <Divider />
</p>
<Chip <div className="flex flex-col gap-3">
size="sm" <h3 className="text-lg font-medium">Execution</h3>
variant="flat" <div className="grid grid-cols-2 gap-4">
color={ <ConfigSelect
selectedTask.isRunning configFiles={configFiles}
? 'success' value={configId}
: selectedTask.isEnabled onChange={setConfigId}
? 'primary' placeholder={
: 'warning' configMissing
? 'Config no longer exists'
: undefined
} }
/>
<BinarySelect
value={binaryPath}
onChange={(path) => {
setSaveError(null)
setBinaryPath(path)
}}
onError={setSaveError}
/>
</div>
{configMissing && (
<Alert color="danger" variant="faded" title="">
The config this task used no longer exists pick
another one.
</Alert>
)}
{configPasswordMissing && (
<Alert
color="warning"
variant="faded"
title="Encrypted config without a saved password"
> >
{selectedTask.isRunning This config is encrypted and has no saved password
? 'Running' or password command. The scheduled runner cannot
: selectedTask.isEnabled prompt for it, so runs will fail until you save the
? 'Enabled' password in Settings Config.
: 'Paused'} </Alert>
</Chip> )}
<Switch
size="sm"
color="primary"
isSelected={isEnabled}
onValueChange={setIsEnabled}
data-focus-visible="false"
>
Enabled
</Switch>
<div className="flex flex-col gap-1">
<span className="text-small">Run mode</span>
<Tabs
size="sm"
selectedKey={runMode}
onSelectionChange={(key) =>
setRunMode(key as 'system' | 'user')
}
data-focus-visible="false"
>
<Tab key="user" title="User" />
<Tab key="system" title="System" />
</Tabs>
<span className="text-tiny text-default-400">
{runMode === 'user'
? 'Runs only while you are logged in, inside your session — OS keychain passwords and session-mounted drives work; fires while logged out are skipped. On macOS it runs as Rclone UI, so protected folders work once you grant the app access.'
: 'Runs even while logged out, but outside your login session — no OS keychain or session-mounted drives, and protected folders on macOS need Full Disk Access for cron.'}
</span>
</div> </div>
<Switch
size="sm"
color="primary"
isSelected={verboseLogging}
onValueChange={setVerboseLogging}
data-focus-visible="false"
>
<div className="flex flex-col">
<span className="text-small">Verbose logging</span>
<span className="text-tiny text-default-400">
Log individual transfers to the rclone log
(grows faster)
</span>
</div> </div>
</Switch>
</div> </div>
<Divider /> <Divider />
@@ -246,8 +411,125 @@ export default function ScheduleEditDrawer({
onChange={(expr) => onChange={(expr) =>
setCronExpression(expr || '* * * * *') setCronExpression(expr || '* * * * *')
} }
error={cronError}
/> />
</div> </div>
<Divider />
<div className="flex flex-col gap-3">
<h3 className="text-lg font-medium">Advanced</h3>
<Input
type="number"
label="Max run time (hours)"
labelPlacement="outside"
min={1}
max={MAX_RUN_HOURS_LIMIT}
value={maxRunHours}
onValueChange={setMaxRunHours}
isInvalid={maxRunHoursInvalid}
errorMessage={`Enter a whole number of hours between 1 and ${MAX_RUN_HOURS_LIMIT}`}
description="A run still going after this long is stopped and marked failed."
className="max-w-64"
data-focus-visible="false"
/>
</div>
<Divider />
<div className="flex flex-col gap-3">
<h3 className="text-lg font-medium">Run History</h3>
{finishedRuns.length > 0 ? (
<div className="flex flex-col gap-2">
{finishedRuns.map((run) =>
run.event === 'finished' ? (
<div
key={run.runId}
className="flex items-center gap-3 text-sm"
>
<Chip
size="sm"
variant="flat"
color={
run.success
? 'success'
: 'danger'
}
>
{run.success ? 'OK' : 'Failed'}
</Chip>
<span>
{formatDistance(
new Date(run.ts),
new Date(now),
{ addSuffix: true }
)}
</span>
<span className="text-foreground-500">
{Math.round(run.durationMs / 1000)}s
</span>
{!!run.error && (
<span className="text-danger-500 line-clamp-1">
{run.error}
</span>
)}
</div>
) : null
)}
</div>
) : (
<p className="text-sm text-foreground-500">
This task hasn't run yet.
</p>
)}
</div>
<Divider />
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium">Logs</h3>
<div className="flex gap-1">
<Button
size="sm"
variant={
logView === 'runner' ? 'solid' : 'light'
}
color={
logView === 'runner' ? 'primary' : 'default'
}
onPress={() => setLogView('runner')}
data-focus-visible="false"
>
Runner
</Button>
<Button
size="sm"
variant={
logView === 'daemon' ? 'solid' : 'light'
}
color={
logView === 'daemon' ? 'primary' : 'default'
}
onPress={() => setLogView('daemon')}
data-focus-visible="false"
>
rclone
</Button>
</div>
</div>
{logQuery.data?.truncated && (
<p className="text-tiny text-default-400">
Showing the last 64 KB older lines are on disk.
</p>
)}
<pre className="p-3 overflow-auto font-mono whitespace-pre-wrap rounded-medium bg-content2 text-tiny max-h-64">
{logQuery.data?.content ||
(logView === 'runner'
? 'No runner output yet.'
: 'No rclone output yet.')}
</pre>
</div>
</div> </div>
</ScrollShadow> </ScrollShadow>
</DrawerBody> </DrawerBody>
@@ -262,8 +544,14 @@ export default function ScheduleEditDrawer({
</Button> </Button>
<Button <Button
color="primary" color="primary"
isDisabled={!hasChanges} isDisabled={
onPress={handleSave} !hasChanges ||
!!cronError ||
configMissing ||
maxRunHoursInvalid
}
isLoading={saveMutation.isPending}
onPress={() => saveMutation.mutate()}
data-focus-visible="false" data-focus-visible="false"
> >
SAVE CHANGES SAVE CHANGES
@@ -0,0 +1,139 @@
import { Button, cn } from '@heroui/react'
import { useQuery } from '@tanstack/react-query'
import { ChevronDownIcon, SlidersHorizontalIcon } from 'lucide-react'
import { useCallback, useState } from 'react'
import { LOCAL_HOST_ID } from '../../../lib/hosts'
import { schedulerValidateCron, useSchedulerSupported } from '../../../lib/scheduler'
import { useHostStore } from '../../../store/host'
import { usePersistedStore } from '../../../store/persisted'
import BinarySelect, { APP_DEFAULT_BINARY } from '../BinarySelect'
import ConfigSelect, { configPasswordMissing } from '../ConfigSelect'
import CronEditor from '../CronEditor'
export interface AdvancedSchedule {
cronExpression: string | null
setCronExpression: (expr: string | null) => void
binaryPath: string
setBinaryPath: (path: string) => void
/** null = use the active config at creation time. */
configId: string | null
setConfigId: (id: string) => void
reset: () => void
}
/** Page-lifted state for the Advanced section: the schedule the page's create flow reads. */
export function useAdvancedSchedule(): AdvancedSchedule {
const [cronExpression, setCronExpression] = useState<string | null>(null)
const [binaryPath, setBinaryPath] = useState<string>(APP_DEFAULT_BINARY)
const [configId, setConfigId] = useState<string | null>(null)
const reset = useCallback(() => {
setCronExpression(null)
setBinaryPath(APP_DEFAULT_BINARY)
setConfigId(null)
}, [])
return {
cronExpression,
setCronExpression,
binaryPath,
setBinaryPath,
configId,
setConfigId,
reset,
}
}
/**
* Collapsible "Advanced" block rendered under the path inputs on the operation pages: cron
* schedule plus the binary and config the scheduled task will run with. These apply to the
* SCHEDULED task only (live runs go through the app's shared daemon), so the section hides
* entirely where scheduling can't work (sandboxed installs, remote hosts).
*/
export default function AdvancedScheduleSection({ advanced }: { advanced: AdvancedSchedule }) {
const [expanded, setExpanded] = useState(false)
const [pickerError, setPickerError] = useState<string | null>(null)
const currentHostId = usePersistedStore((state) => state.currentHostId) ?? LOCAL_HOST_ID
const supportQuery = useSchedulerSupported()
const configFiles = useHostStore((state) => state.configFiles)
const activeConfigId = useHostStore((state) => state.activeConfigId)
const cronValidation = useQuery({
queryKey: ['scheduler', 'validate-cron', advanced.cronExpression],
queryFn: () => schedulerValidateCron(advanced.cronExpression ?? ''),
enabled: expanded && !!advanced.cronExpression,
})
const cronError =
advanced.cronExpression && cronValidation.data && !cronValidation.data.valid
? (cronValidation.data.error ?? 'Invalid cron expression')
: null
if (currentHostId !== LOCAL_HOST_ID || !(supportQuery.data?.supported ?? false)) {
return null
}
const effectiveConfigId = advanced.configId ?? activeConfigId
const passwordMissing = configPasswordMissing(configFiles, effectiveConfigId)
const hasSchedule = !!advanced.cronExpression
return (
<div className="flex flex-col gap-4 px-4">
<Button
variant="light"
size="sm"
className="self-start"
startContent={<SlidersHorizontalIcon className="w-4 h-4" />}
endContent={
<ChevronDownIcon
className={cn('w-4 h-4 transition-transform', expanded && 'rotate-180')}
/>
}
onPress={() => setExpanded(!expanded)}
data-focus-visible="false"
>
Advanced{hasSchedule ? ' — scheduled' : ''}
</Button>
{expanded && (
<div className="flex flex-col gap-4 p-4 border rounded-medium border-divider bg-content2/50">
<div className="flex flex-col gap-2">
<p className="text-sm font-semibold uppercase text-default-500">Schedule</p>
<CronEditor
expression={advanced.cronExpression}
onChange={advanced.setCronExpression}
error={cronError}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<ConfigSelect
configFiles={configFiles}
value={effectiveConfigId}
onChange={advanced.setConfigId}
/>
<BinarySelect
value={advanced.binaryPath}
onChange={(path) => {
setPickerError(null)
advanced.setBinaryPath(path)
}}
onError={setPickerError}
/>
</div>
<p className="text-tiny text-default-400">
The config and binary apply to the scheduled task; immediate runs use the
app's active config and binary.
</p>
{!!pickerError && <p className="text-sm text-danger-500">{pickerError}</p>}
{passwordMissing && (
<p className="text-sm text-warning-600">
This config is encrypted with no saved password scheduled runs will
fail until you save it in Settings Config.
</p>
)}
</div>
)}
</div>
)
}
+21 -1
View File
@@ -11,7 +11,10 @@ import { platform } from '@tauri-apps/plugin-os'
import { AnimatePresence, motion } from 'framer-motion' import { AnimatePresence, motion } from 'framer-motion'
import { ClockIcon, EyeIcon } from 'lucide-react' import { ClockIcon, EyeIcon } from 'lucide-react'
import { type ComponentProps, type ReactNode, useCallback, useMemo } from 'react' import { type ComponentProps, type ReactNode, useCallback, useMemo } from 'react'
import { LOCAL_HOST_ID } from '../../../lib/hosts'
import { useSchedulerSupported } from '../../../lib/scheduler'
import { openWindow } from '../../../lib/window' import { openWindow } from '../../../lib/window'
import { usePersistedStore } from '../../../store/persisted'
import type { Template } from '../../../types/template' import type { Template } from '../../../types/template'
import CommandInfoButton from '../CommandInfoButton' import CommandInfoButton from '../CommandInfoButton'
import CommandsDropdown from '../CommandsDropdown' import CommandsDropdown from '../CommandsDropdown'
@@ -72,6 +75,20 @@ export default function OperationFooter({
}) { }) {
const dropdownShadow = useMemo(() => (platform() === 'windows' ? 'none' : undefined), []) const dropdownShadow = useMemo(() => (platform() === 'windows' ? 'none' : undefined), [])
// Scheduling is OS-native and local-host-only; hide the affordance where it can't work
// (sandboxed installs, remote hosts).
const currentHostId = usePersistedStore((state) => state.currentHostId) ?? LOCAL_HOST_ID
const supportQuery = useSchedulerSupported()
// Treat unresolved support as unavailable (matches AdvancedScheduleSection and Schedules.tsx)
// — otherwise the Schedule button is enabled while the only cron-entry UI is still hidden.
const schedulingAvailable =
currentHostId === LOCAL_HOST_ID && (supportQuery.data?.supported ?? false)
const scheduleTooltip = schedulingAvailable
? 'Schedule task'
: currentHostId !== LOCAL_HOST_ID
? 'Scheduling is only available on your local machine'
: (supportQuery.data?.reason ?? 'Scheduling is not available on this system')
const handleStartPress = useCallback(() => { const handleStartPress = useCallback(() => {
setTimeout(() => onStart(), 100) setTimeout(() => onStart(), 100)
}, [onStart]) }, [onStart])
@@ -194,16 +211,19 @@ export default function OperationFooter({
</Button> </Button>
</Tooltip> </Tooltip>
) : null} ) : null}
<Tooltip content="Schedule task" placement="top" size="lg" color="foreground"> <Tooltip content={scheduleTooltip} placement="top" size="lg" color="foreground">
<div>
<Button <Button
size="lg" size="lg"
type="button" type="button"
color="primary" color="primary"
isIconOnly={true} isIconOnly={true}
isDisabled={!schedulingAvailable}
onPress={handleSchedulePress} onPress={handleSchedulePress}
> >
<ClockIcon className="size-6" /> <ClockIcon className="size-6" />
</Button> </Button>
</div>
</Tooltip> </Tooltip>
<CommandInfoButton content={helpContent} /> <CommandInfoButton content={helpContent} />
<CommandsDropdown currentCommand={operation} /> <CommandsDropdown currentCommand={operation} />
@@ -1,6 +1,5 @@
import { Accordion, AccordionItem, Avatar } from '@heroui/react' import { Accordion, AccordionItem, Avatar } from '@heroui/react'
import { import {
ClockIcon,
CopyIcon, CopyIcon,
DiamondPercentIcon, DiamondPercentIcon,
FilterIcon, FilterIcon,
@@ -14,7 +13,7 @@ import ShowMoreOptionsBanner from '../ShowMoreOptionsBanner'
// Avatar/indicator/title per option category — exactly what each page's accordion rendered. // Avatar/indicator/title per option category — exactly what each page's accordion rendered.
export const CATEGORY_META: Record< export const CATEGORY_META: Record<
'copy' | 'sync' | 'move' | 'bisync' | 'filters' | 'cron' | 'config' | 'remotes', 'copy' | 'sync' | 'move' | 'bisync' | 'filters' | 'config' | 'remotes',
{ {
title: string title: string
icon: ComponentType<{ className?: string }> icon: ComponentType<{ className?: string }>
@@ -33,7 +32,6 @@ export const CATEGORY_META: Record<
avatarIconClassName: 'text-success-foreground', avatarIconClassName: 'text-success-foreground',
}, },
filters: { title: 'Filters', icon: FilterIcon, avatarColor: 'danger' }, filters: { title: 'Filters', icon: FilterIcon, avatarColor: 'danger' },
cron: { title: 'Cron', icon: ClockIcon, avatarColor: 'warning' },
config: { title: 'Config', icon: WrenchIcon, avatarColor: 'default' }, config: { title: 'Config', icon: WrenchIcon, avatarColor: 'default' },
remotes: { title: 'Remotes', icon: ServerIcon, avatarClassName: 'bg-fuchsia-500' }, remotes: { title: 'Remotes', icon: ServerIcon, avatarClassName: 'bg-fuchsia-500' },
} }
@@ -50,7 +48,7 @@ export interface OptionsAccordionItemDef {
/** /**
* The option-group accordion shared by the operation pages: item scaffolding (Avatar, * The option-group accordion shared by the operation pages: item scaffolding (Avatar,
* indicator, title) comes from CATEGORY_META; each item's content (OptionsSection / * indicator, title) comes from CATEGORY_META; each item's content (OptionsSection /
* CronEditor / RemoteOptionsSection) stays page-supplied. `banner` wraps the accordion in the * RemoteOptionsSection) stays page-supplied. `banner` wraps the accordion in the
* relative div with the ShowMoreOptionsBanner (Copy/Sync/Move); Bisync/Delete/Purge omit it. * relative div with the ShowMoreOptionsBanner (Copy/Sync/Move); Bisync/Delete/Purge omit it.
*/ */
export default function OptionsAccordion({ export default function OptionsAccordion({
+14 -13
View File
@@ -1,25 +1,30 @@
import { useMutation } from '@tanstack/react-query' import { useMutation } from '@tanstack/react-query'
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
import cronstrue from 'cronstrue'
import { onErrorDialog } from '../../../lib/errors' import { onErrorDialog } from '../../../lib/errors'
import notify from '../../../lib/notify' import { notify } from '../../../lib/notifications'
import { useHostStore } from '../../../store/host' import { createScheduledTask } from '../../../lib/scheduler'
import type { ScheduledTask } from '../../../types/schedules' import type { ScheduledTask } from '../../../types/schedules'
/** /**
* The schedule mutation shared by the operation pages: page-specific validation (path checks, * The schedule mutation shared by the operation pages: page-specific validation (path checks,
* the Copy/Move multi-source license gate) cron validation native name prompt * the Copy/Move multi-source license gate) per-platform cron validation native name prompt
* addScheduledTask with the page-built args. `buildArgs` must return the EXACT persisted args * createScheduledTask, which persists the task and registers it with the OS scheduler. The
* shape the page's start function takes main.ts replays these verbatim. * headless runner replays the pre-serialized requests built from `buildArgs()` output.
*/ */
export function useScheduleTask<O extends ScheduledTask['operation']>({ export function useScheduleTask<O extends ScheduledTask['operation']>({
operation, operation,
cronExpression, cronExpression,
configId,
binaryPath,
buildArgs, buildArgs,
validate, validate,
}: { }: {
operation: O operation: O
cronExpression: string | null cronExpression: string | null
/** From the page's Advanced section; omitted/null = active config. */
configId?: string | null
/** From the page's Advanced section; omitted = 'app-default'. */
binaryPath?: string
buildArgs: () => Extract<ScheduledTask, { operation: O }>['args'] buildArgs: () => Extract<ScheduledTask, { operation: O }>['args']
validate?: () => void validate?: () => void
}) { }) {
@@ -31,12 +36,6 @@ export function useScheduleTask<O extends ScheduledTask['operation']>({
throw new Error('Please enter a cron expression') throw new Error('Please enter a cron expression')
} }
try {
cronstrue.toString(cronExpression)
} catch {
throw new Error('Invalid cron expression')
}
const name = await invoke<string | null>('prompt', { const name = await invoke<string | null>('prompt', {
title: 'Schedule Name', title: 'Schedule Name',
message: 'Enter a name for this schedule', message: 'Enter a name for this schedule',
@@ -47,11 +46,13 @@ export function useScheduleTask<O extends ScheduledTask['operation']>({
throw new Error('Schedule name is required') throw new Error('Schedule name is required')
} }
useHostStore.getState().addScheduledTask({ await createScheduledTask({
name, name,
operation, operation,
cron: cronExpression, cron: cronExpression,
args: buildArgs(), args: buildArgs(),
configId: configId ?? undefined,
binaryPath,
}) })
}, },
onSuccess: async () => { onSuccess: async () => {
+12 -12
View File
@@ -8,12 +8,14 @@ import { getOptionsSubtitle } from '../../lib/flags'
import { useFlags } from '../../lib/hooks' import { useFlags } from '../../lib/hooks'
import { startBisync } from '../../lib/rclone/api' import { startBisync } from '../../lib/rclone/api'
import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants' import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants'
import CronEditor from '../components/CronEditor'
import OperationWindowContent from '../components/OperationWindowContent' import OperationWindowContent from '../components/OperationWindowContent'
import OperationWindowFooter from '../components/OperationWindowFooter' import OperationWindowFooter from '../components/OperationWindowFooter'
import OptionsSection from '../components/OptionsSection' import OptionsSection from '../components/OptionsSection'
import { PathFinder } from '../components/PathFinder' import { PathFinder } from '../components/PathFinder'
import RemoteOptionsSection from '../components/RemoteOptionsSection' import RemoteOptionsSection from '../components/RemoteOptionsSection'
import AdvancedScheduleSection, {
useAdvancedSchedule,
} from '../components/operation/AdvancedScheduleSection'
import OperationFooter from '../components/operation/OperationFooter' import OperationFooter from '../components/operation/OperationFooter'
import OptionsAccordion, { import OptionsAccordion, {
type OptionsAccordionItemDef, type OptionsAccordionItemDef,
@@ -101,7 +103,7 @@ export default function Bisync() {
const [outerBisyncOptions, setOuterBisyncOptions] = useState<Record<string, boolean>>({}) const [outerBisyncOptions, setOuterBisyncOptions] = useState<Record<string, boolean>>({})
const [cronExpression, setCronExpression] = useState<string | null>(null) const advanced = useAdvancedSchedule()
const selectedRemotes = useMemo(() => [source, dest].filter(Boolean), [source, dest]) const selectedRemotes = useMemo(() => [source, dest].filter(Boolean), [source, dest])
@@ -139,7 +141,7 @@ export default function Bisync() {
return startBisync(buildStartArgs()) return startBisync(buildStartArgs())
}, },
onSuccess: () => { onSuccess: () => {
if (cronExpression) { if (advanced.cronExpression) {
scheduleTaskMutation.mutate() scheduleTaskMutation.mutate()
} }
}, },
@@ -151,7 +153,9 @@ export default function Bisync() {
const scheduleTaskMutation = useScheduleTask({ const scheduleTaskMutation = useScheduleTask({
operation: 'bisync', operation: 'bisync',
cronExpression, cronExpression: advanced.cronExpression,
configId: advanced.configId,
binaryPath: advanced.binaryPath,
validate: () => { validate: () => {
if (!source || !dest) { if (!source || !dest) {
throw new Error('Please select both a source and destination path') throw new Error('Please select both a source and destination path')
@@ -166,9 +170,9 @@ export default function Bisync() {
if (!dest) return 'Please select a destination path' if (!dest) return 'Please select a destination path'
if (source === dest) return 'Source and destination cannot be the same' if (source === dest) return 'Source and destination cannot be the same'
if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options' if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options'
if (cronExpression) return 'START AND SCHEDULE BISYNC' if (advanced.cronExpression) return 'START AND SCHEDULE BISYNC'
return 'START BISYNC' return 'START BISYNC'
}, [startBisyncMutation.isPending, source, dest, jsonError, cronExpression]) }, [startBisyncMutation.isPending, source, dest, jsonError, advanced.cronExpression])
const buttonIcon = useMemo(() => { const buttonIcon = useMemo(() => {
if (startBisyncMutation.isPending) return if (startBisyncMutation.isPending) return
@@ -316,11 +320,6 @@ export default function Bisync() {
/> />
), ),
}, },
{
key: 'cron',
category: 'cron',
children: <CronEditor expression={cronExpression} onChange={setCronExpression} />,
},
{ {
key: 'config', key: 'config',
category: 'config', category: 'config',
@@ -368,7 +367,6 @@ export default function Bisync() {
copyFlags, copyFlags,
filterGroup, filterGroup,
filterFlags, filterFlags,
cronExpression,
configGroup, configGroup,
configFlags, configFlags,
selectedRemotes, selectedRemotes,
@@ -426,6 +424,8 @@ export default function Bisync() {
setDestPath={setDest} setDestPath={setDest}
/> />
<AdvancedScheduleSection advanced={advanced} />
<OptionsAccordion items={accordionItems} /> <OptionsAccordion items={accordionItems} />
</OperationWindowContent> </OperationWindowContent>
+17 -17
View File
@@ -8,12 +8,14 @@ import { useFlags } from '../../lib/hooks'
import { startCopy, startDryRun } from '../../lib/rclone/api' import { startCopy, startDryRun } from '../../lib/rclone/api'
import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants' import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants'
import { usePersistedStore } from '../../store/persisted' import { usePersistedStore } from '../../store/persisted'
import CronEditor from '../components/CronEditor'
import OperationWindowContent from '../components/OperationWindowContent' import OperationWindowContent from '../components/OperationWindowContent'
import OperationWindowFooter from '../components/OperationWindowFooter' import OperationWindowFooter from '../components/OperationWindowFooter'
import OptionsSection from '../components/OptionsSection' import OptionsSection from '../components/OptionsSection'
import { MultiPathFinder } from '../components/PathFinder' import { MultiPathFinder } from '../components/PathFinder'
import RemoteOptionsSection from '../components/RemoteOptionsSection' import RemoteOptionsSection from '../components/RemoteOptionsSection'
import AdvancedScheduleSection, {
useAdvancedSchedule,
} from '../components/operation/AdvancedScheduleSection'
import OperationFooter from '../components/operation/OperationFooter' import OperationFooter from '../components/operation/OperationFooter'
import OptionsAccordion, { import OptionsAccordion, {
type OptionsAccordionItemDef, type OptionsAccordionItemDef,
@@ -40,7 +42,7 @@ Expand the accordion sections to customize your copy operation. Tap any chip on
Filters Include or exclude files by pattern, limit by size (max_size, min_size) or age (max_age, min_age). Filters Include or exclude files by pattern, limit by size (max_size, min_size) or age (max_age, min_age).
Cron Schedule this copy to run automatically at set intervals. The schedule only triggers while the app is running. Cron Schedule this copy to run automatically at set intervals. It runs on a system schedule, even when the app is closed.
Config Performance tuning: parallel transfers, checkers, buffer_size, bandwidth limits (bwlimit), and fast_list for faster directory listings on supported remotes. Config Performance tuning: parallel transfers, checkers, buffer_size, bandwidth limits (bwlimit), and fast_list for faster directory listings on supported remotes.
@@ -84,7 +86,7 @@ export default function Copy() {
const filterGroup = optionGroups.filter const filterGroup = optionGroups.filter
const configGroup = optionGroups.config const configGroup = optionGroups.config
const [cronExpression, setCronExpression] = useState<string | null>(null) const advanced = useAdvancedSchedule()
const selectedRemotes = useMemo( const selectedRemotes = useMemo(
() => [...(sources || []), dest].filter(Boolean), () => [...(sources || []), dest].filter(Boolean),
@@ -111,7 +113,7 @@ export default function Copy() {
return startCopy(buildArgs()) return startCopy(buildArgs())
}, },
onSuccess: () => { onSuccess: () => {
if (cronExpression) { if (advanced.cronExpression) {
scheduleTaskMutation.mutate() scheduleTaskMutation.mutate()
} }
}, },
@@ -123,7 +125,9 @@ export default function Copy() {
const scheduleTaskMutation = useScheduleTask({ const scheduleTaskMutation = useScheduleTask({
operation: 'copy', operation: 'copy',
cronExpression, cronExpression: advanced.cronExpression,
configId: advanced.configId,
binaryPath: advanced.binaryPath,
validate: () => { validate: () => {
if (!sources || sources.length === 0 || !dest) { if (!sources || sources.length === 0 || !dest) {
throw new Error('Please select both a source and destination path') throw new Error('Please select both a source and destination path')
@@ -160,9 +164,9 @@ export default function Copy() {
if (!dest) return 'Please select a destination path' if (!dest) return 'Please select a destination path'
if (sources.some((s) => s === dest)) return 'Source and destination cannot be the same' if (sources.some((s) => s === dest)) return 'Source and destination cannot be the same'
if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options' if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options'
if (cronExpression) return 'START AND SCHEDULE COPY' if (advanced.cronExpression) return 'START AND SCHEDULE COPY'
return 'START COPY' return 'START COPY'
}, [startCopyMutation.isPending, sources, dest, jsonError, cronExpression]) }, [startCopyMutation.isPending, sources, dest, jsonError, advanced.cronExpression])
const buttonIcon = useMemo(() => { const buttonIcon = useMemo(() => {
if (startCopyMutation.isPending) return if (startCopyMutation.isPending) return
@@ -204,11 +208,6 @@ export default function Copy() {
/> />
), ),
}, },
{
key: 'cron',
category: 'cron',
children: <CronEditor expression={cronExpression} onChange={setCronExpression} />,
},
{ {
key: 'config', key: 'config',
category: 'config', category: 'config',
@@ -258,7 +257,6 @@ export default function Copy() {
copyFlags, copyFlags,
filterFlags, filterFlags,
configFlags, configFlags,
cronExpression,
selectedRemotes, selectedRemotes,
] ]
) )
@@ -284,21 +282,21 @@ export default function Copy() {
const handleResetOptions = useCallback(() => { const handleResetOptions = useCallback(() => {
startTransition(() => { startTransition(() => {
resetJson() resetJson()
setCronExpression(null) advanced.reset()
startCopyMutation.reset() startCopyMutation.reset()
}) })
}, [resetJson, startCopyMutation.reset]) }, [advanced.reset, resetJson, startCopyMutation.reset])
const handleResetAll = useCallback(() => { const handleResetAll = useCallback(() => {
startTransition(() => { startTransition(() => {
resetJson() resetJson()
resetLocks() resetLocks()
setCronExpression(null) advanced.reset()
setSources(undefined) setSources(undefined)
setDest(undefined) setDest(undefined)
startCopyMutation.reset() startCopyMutation.reset()
}) })
}, [resetJson, resetLocks, startCopyMutation.reset]) }, [advanced.reset, resetJson, resetLocks, startCopyMutation.reset])
useEffect(() => { useEffect(() => {
console.log('[Copy] remoteOptions', remotesGroup.options) console.log('[Copy] remoteOptions', remotesGroup.options)
@@ -317,6 +315,8 @@ export default function Copy() {
setDestPath={setDest} setDestPath={setDest}
/> />
<AdvancedScheduleSection advanced={advanced} />
<OptionsAccordion banner={true} items={accordionItems} /> <OptionsAccordion banner={true} items={accordionItems} />
</OperationWindowContent> </OperationWindowContent>
+19 -18
View File
@@ -7,14 +7,16 @@ import { onErrorDialog } from '../../lib/errors'
import { getOptionsSubtitle } from '../../lib/flags' import { getOptionsSubtitle } from '../../lib/flags'
import { getRemoteName } from '../../lib/format' import { getRemoteName } from '../../lib/format'
import { useFlags, useRemoteConfig } from '../../lib/hooks' import { useFlags, useRemoteConfig } from '../../lib/hooks'
import notify from '../../lib/notify' import { notify } from '../../lib/notifications'
import { startDelete, startDryRun } from '../../lib/rclone/api' import { startDelete, startDryRun } from '../../lib/rclone/api'
import { RCLONE_CONFIG_DEFAULTS, SUPPORTS_PURGE } from '../../lib/rclone/constants' import { RCLONE_CONFIG_DEFAULTS, SUPPORTS_PURGE } from '../../lib/rclone/constants'
import CronEditor from '../components/CronEditor'
import OperationWindowContent from '../components/OperationWindowContent' import OperationWindowContent from '../components/OperationWindowContent'
import OperationWindowFooter from '../components/OperationWindowFooter' import OperationWindowFooter from '../components/OperationWindowFooter'
import OptionsSection from '../components/OptionsSection' import OptionsSection from '../components/OptionsSection'
import { PathField } from '../components/PathFinder' import { PathField } from '../components/PathFinder'
import AdvancedScheduleSection, {
useAdvancedSchedule,
} from '../components/operation/AdvancedScheduleSection'
import OperationFooter from '../components/operation/OperationFooter' import OperationFooter from '../components/operation/OperationFooter'
import OptionsAccordion, { import OptionsAccordion, {
type OptionsAccordionItemDef, type OptionsAccordionItemDef,
@@ -43,7 +45,7 @@ Expand the accordion sections to customize your delete operation. Tap any chip o
Config Performance tuning: parallel checkers, and other global rclone settings. Config Performance tuning: parallel checkers, and other global rclone settings.
Cron Schedule this delete to run automatically at set intervals. Useful for automated cleanup tasks. The schedule only triggers while the app is running. Cron Schedule this delete to run automatically at set intervals. Useful for automated cleanup tasks. It runs on a system schedule, even when the app is closed.
3. USE TEMPLATES (Optional) 3. USE TEMPLATES (Optional)
Tap the folder icon in the bottom bar to load or save option presets. Templates let you quickly apply common filter configurations for recurring cleanup tasks. Tap the folder icon in the bottom bar to load or save option presets. Templates let you quickly apply common filter configurations for recurring cleanup tasks.
@@ -59,7 +61,7 @@ export default function Delete() {
searchParams.get('initialSource') ? searchParams.get('initialSource')! : undefined searchParams.get('initialSource') ? searchParams.get('initialSource')! : undefined
) )
const [cronExpression, setCronExpression] = useState<string | null>(null) const advanced = useAdvancedSchedule()
const { const {
jsonError, jsonError,
@@ -108,7 +110,7 @@ export default function Delete() {
title: 'Success', title: 'Success',
body: 'Delete task started', body: 'Delete task started',
}) })
if (cronExpression) { if (advanced.cronExpression) {
scheduleTaskMutation.mutate() scheduleTaskMutation.mutate()
} }
}, },
@@ -119,7 +121,9 @@ export default function Delete() {
const scheduleTaskMutation = useScheduleTask({ const scheduleTaskMutation = useScheduleTask({
operation: 'delete', operation: 'delete',
cronExpression, cronExpression: advanced.cronExpression,
configId: advanced.configId,
binaryPath: advanced.binaryPath,
validate: () => { validate: () => {
if (!sourceFs) { if (!sourceFs) {
throw new Error('Please select a source path to delete') throw new Error('Please select a source path to delete')
@@ -147,9 +151,9 @@ export default function Delete() {
if (startDeleteMutation.isPending) return 'STARTING...' if (startDeleteMutation.isPending) return 'STARTING...'
if (!sourceFs || sourceFs.length === 0) return 'Please select a source path' if (!sourceFs || sourceFs.length === 0) return 'Please select a source path'
if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options' if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options'
if (cronExpression) return 'START AND SCHEDULE DELETE' if (advanced.cronExpression) return 'START AND SCHEDULE DELETE'
return 'START DELETE' return 'START DELETE'
}, [startDeleteMutation.isPending, sourceFs, jsonError, cronExpression]) }, [startDeleteMutation.isPending, sourceFs, jsonError, advanced.cronExpression])
const buttonIcon = useMemo(() => { const buttonIcon = useMemo(() => {
if (startDeleteMutation.isPending) return if (startDeleteMutation.isPending) return
@@ -190,13 +194,8 @@ export default function Delete() {
/> />
), ),
}, },
{
key: 'cron',
category: 'cron',
children: <CronEditor expression={cronExpression} onChange={setCronExpression} />,
},
], ],
[filterGroup, configGroup, globalFlags, filterFlags, configFlags, cronExpression] [filterGroup, configGroup, globalFlags, filterFlags, configFlags]
) )
const handleStart = useCallback( const handleStart = useCallback(
@@ -222,20 +221,20 @@ export default function Delete() {
const handleResetOptions = useCallback(() => { const handleResetOptions = useCallback(() => {
startTransition(() => { startTransition(() => {
resetJson() resetJson()
setCronExpression(null) advanced.reset()
startDeleteMutation.reset() startDeleteMutation.reset()
}) })
}, [resetJson, startDeleteMutation.reset]) }, [advanced.reset, resetJson, startDeleteMutation.reset])
const handleResetAll = useCallback(() => { const handleResetAll = useCallback(() => {
startTransition(() => { startTransition(() => {
resetJson() resetJson()
resetLocks() resetLocks()
setCronExpression(null) advanced.reset()
setSourceFs(undefined) setSourceFs(undefined)
startDeleteMutation.reset() startDeleteMutation.reset()
}) })
}, [resetJson, resetLocks, startDeleteMutation.reset]) }, [advanced.reset, resetJson, resetLocks, startDeleteMutation.reset])
return ( return (
<div className="flex flex-col h-screen gap-10"> <div className="flex flex-col h-screen gap-10">
@@ -264,6 +263,8 @@ export default function Delete() {
</Alert> </Alert>
)} )}
<AdvancedScheduleSection advanced={advanced} />
<OptionsAccordion items={accordionItems} /> <OptionsAccordion items={accordionItems} />
</OperationWindowContent> </OperationWindowContent>
+17 -17
View File
@@ -8,12 +8,14 @@ import { useFlags } from '../../lib/hooks'
import { startDryRun, startMove } from '../../lib/rclone/api' import { startDryRun, startMove } from '../../lib/rclone/api'
import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants' import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants'
import { usePersistedStore } from '../../store/persisted' import { usePersistedStore } from '../../store/persisted'
import CronEditor from '../components/CronEditor'
import OperationWindowContent from '../components/OperationWindowContent' import OperationWindowContent from '../components/OperationWindowContent'
import OperationWindowFooter from '../components/OperationWindowFooter' import OperationWindowFooter from '../components/OperationWindowFooter'
import OptionsSection from '../components/OptionsSection' import OptionsSection from '../components/OptionsSection'
import { MultiPathFinder } from '../components/PathFinder' import { MultiPathFinder } from '../components/PathFinder'
import RemoteOptionsSection from '../components/RemoteOptionsSection' import RemoteOptionsSection from '../components/RemoteOptionsSection'
import AdvancedScheduleSection, {
useAdvancedSchedule,
} from '../components/operation/AdvancedScheduleSection'
import OperationFooter from '../components/operation/OperationFooter' import OperationFooter from '../components/operation/OperationFooter'
import OptionsAccordion, { import OptionsAccordion, {
type OptionsAccordionItemDef, type OptionsAccordionItemDef,
@@ -44,7 +46,7 @@ Expand the accordion sections to customize your move operation. Tap any chip on
Filters Include or exclude files by pattern, limit by size (max_size, min_size) or age (max_age, min_age). Filters Include or exclude files by pattern, limit by size (max_size, min_size) or age (max_age, min_age).
Cron Schedule this move to run automatically at set intervals. The schedule only triggers while the app is running. Cron Schedule this move to run automatically at set intervals. It runs on a system schedule, even when the app is closed.
Config Performance tuning: parallel transfers, checkers, buffer_size, bandwidth limits (bwlimit), and fast_list for faster directory listings on supported remotes. Config Performance tuning: parallel transfers, checkers, buffer_size, bandwidth limits (bwlimit), and fast_list for faster directory listings on supported remotes.
@@ -88,7 +90,7 @@ export default function Move() {
const filterGroup = optionGroups.filter const filterGroup = optionGroups.filter
const configGroup = optionGroups.config const configGroup = optionGroups.config
const [cronExpression, setCronExpression] = useState<string | null>(null) const advanced = useAdvancedSchedule()
const selectedRemotes = useMemo( const selectedRemotes = useMemo(
() => [...(sources || []), dest].filter(Boolean), () => [...(sources || []), dest].filter(Boolean),
@@ -115,7 +117,7 @@ export default function Move() {
return startMove(buildArgs()) return startMove(buildArgs())
}, },
onSuccess: () => { onSuccess: () => {
if (cronExpression) { if (advanced.cronExpression) {
scheduleTaskMutation.mutate() scheduleTaskMutation.mutate()
} }
}, },
@@ -127,7 +129,9 @@ export default function Move() {
const scheduleTaskMutation = useScheduleTask({ const scheduleTaskMutation = useScheduleTask({
operation: 'move', operation: 'move',
cronExpression, cronExpression: advanced.cronExpression,
configId: advanced.configId,
binaryPath: advanced.binaryPath,
validate: () => { validate: () => {
if (!sources || sources.length === 0 || !dest) { if (!sources || sources.length === 0 || !dest) {
throw new Error('Please select both a source and destination path') throw new Error('Please select both a source and destination path')
@@ -164,9 +168,9 @@ export default function Move() {
if (!dest) return 'Please select a destination path' if (!dest) return 'Please select a destination path'
if (sources.some((s) => s === dest)) return 'Source and destination cannot be the same' if (sources.some((s) => s === dest)) return 'Source and destination cannot be the same'
if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options' if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options'
if (cronExpression) return 'START AND SCHEDULE MOVE' if (advanced.cronExpression) return 'START AND SCHEDULE MOVE'
return 'START MOVE' return 'START MOVE'
}, [startMoveMutation.isPending, sources, dest, jsonError, cronExpression]) }, [startMoveMutation.isPending, sources, dest, jsonError, advanced.cronExpression])
const buttonIcon = useMemo(() => { const buttonIcon = useMemo(() => {
if (startMoveMutation.isPending) return if (startMoveMutation.isPending) return
@@ -208,11 +212,6 @@ export default function Move() {
/> />
), ),
}, },
{
key: 'cron',
category: 'cron',
children: <CronEditor expression={cronExpression} onChange={setCronExpression} />,
},
{ {
key: 'config', key: 'config',
category: 'config', category: 'config',
@@ -262,7 +261,6 @@ export default function Move() {
filterFlags, filterFlags,
configFlags, configFlags,
copyFlags, copyFlags,
cronExpression,
selectedRemotes, selectedRemotes,
] ]
) )
@@ -288,21 +286,21 @@ export default function Move() {
const handleResetOptions = useCallback(() => { const handleResetOptions = useCallback(() => {
startTransition(() => { startTransition(() => {
resetJson() resetJson()
setCronExpression(null) advanced.reset()
startMoveMutation.reset() startMoveMutation.reset()
}) })
}, [resetJson, startMoveMutation.reset]) }, [advanced.reset, resetJson, startMoveMutation.reset])
const handleResetAll = useCallback(() => { const handleResetAll = useCallback(() => {
startTransition(() => { startTransition(() => {
resetJson() resetJson()
resetLocks() resetLocks()
setCronExpression(null) advanced.reset()
setSources(undefined) setSources(undefined)
setDest(undefined) setDest(undefined)
startMoveMutation.reset() startMoveMutation.reset()
}) })
}, [resetJson, resetLocks, startMoveMutation.reset]) }, [advanced.reset, resetJson, resetLocks, startMoveMutation.reset])
return ( return (
<div className="flex flex-col h-screen gap-10"> <div className="flex flex-col h-screen gap-10">
@@ -316,6 +314,8 @@ export default function Move() {
setDestPath={setDest} setDestPath={setDest}
/> />
<AdvancedScheduleSection advanced={advanced} />
<OptionsAccordion banner={true} items={accordionItems} /> <OptionsAccordion banner={true} items={accordionItems} />
</OperationWindowContent> </OperationWindowContent>
+19 -18
View File
@@ -7,11 +7,13 @@ import { getOptionsSubtitle } from '../../lib/flags'
import { useFlags } from '../../lib/hooks' import { useFlags } from '../../lib/hooks'
import { startPurge } from '../../lib/rclone/api' import { startPurge } from '../../lib/rclone/api'
import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants' import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants'
import CronEditor from '../components/CronEditor'
import OperationWindowContent from '../components/OperationWindowContent' import OperationWindowContent from '../components/OperationWindowContent'
import OperationWindowFooter from '../components/OperationWindowFooter' import OperationWindowFooter from '../components/OperationWindowFooter'
import OptionsSection from '../components/OptionsSection' import OptionsSection from '../components/OptionsSection'
import { PathField } from '../components/PathFinder' import { PathField } from '../components/PathFinder'
import AdvancedScheduleSection, {
useAdvancedSchedule,
} from '../components/operation/AdvancedScheduleSection'
import OperationFooter from '../components/operation/OperationFooter' import OperationFooter from '../components/operation/OperationFooter'
import OptionsAccordion, { import OptionsAccordion, {
type OptionsAccordionItemDef, type OptionsAccordionItemDef,
@@ -21,7 +23,7 @@ import { useScheduleTask } from '../components/operation/useScheduleTask'
const PATH_ALLOWED_KEYS: ('LOCAL_FS' | 'FAVORITES' | 'REMOTES')[] = ['REMOTES', 'FAVORITES'] const PATH_ALLOWED_KEYS: ('LOCAL_FS' | 'FAVORITES' | 'REMOTES')[] = ['REMOTES', 'FAVORITES']
const DEFAULT_EXPANDED_KEYS = ['config', 'cron'] const DEFAULT_EXPANDED_KEYS = ['config']
const HELP_CONTENT = `Removes a path and ALL of its contents. const HELP_CONTENT = `Removes a path and ALL of its contents.
@@ -41,7 +43,7 @@ Expand the accordion sections to customize your purge operation. Tap any chip on
Config The "checkers" option controls concurrency for backends that don't support server-side purge. Other global rclone settings are also available here. Config The "checkers" option controls concurrency for backends that don't support server-side purge. Other global rclone settings are also available here.
Cron Schedule this purge to run automatically at set intervals. Useful for automated cleanup of temporary folders. The schedule only triggers while the app is running. Cron Schedule this purge to run automatically at set intervals. Useful for automated cleanup of temporary folders. It runs on a system schedule, even when the app is closed.
3. USE TEMPLATES (Optional) 3. USE TEMPLATES (Optional)
Tap the folder icon in the bottom bar to load or save option presets. Tap the folder icon in the bottom bar to load or save option presets.
@@ -57,7 +59,7 @@ export default function Purge() {
searchParams.get('initialSource') ? searchParams.get('initialSource')! : undefined searchParams.get('initialSource') ? searchParams.get('initialSource')! : undefined
) )
const [cronExpression, setCronExpression] = useState<string | null>(null) const advanced = useAdvancedSchedule()
const { const {
jsonError, jsonError,
@@ -88,7 +90,7 @@ export default function Purge() {
return startPurge(buildArgs()) return startPurge(buildArgs())
}, },
onSuccess: async () => { onSuccess: async () => {
if (cronExpression) { if (advanced.cronExpression) {
scheduleTaskMutation.mutate() scheduleTaskMutation.mutate()
} }
}, },
@@ -99,7 +101,9 @@ export default function Purge() {
const scheduleTaskMutation = useScheduleTask({ const scheduleTaskMutation = useScheduleTask({
operation: 'purge', operation: 'purge',
cronExpression, cronExpression: advanced.cronExpression,
configId: advanced.configId,
binaryPath: advanced.binaryPath,
validate: () => { validate: () => {
if (!source) { if (!source) {
throw new Error('Please select a source path to purge') throw new Error('Please select a source path to purge')
@@ -112,9 +116,9 @@ export default function Purge() {
if (startPurgeMutation.isPending) return 'STARTING...' if (startPurgeMutation.isPending) return 'STARTING...'
if (!source) return 'Please select a source path' if (!source) return 'Please select a source path'
if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options' if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options'
if (cronExpression) return 'START AND SCHEDULE PURGE' if (advanced.cronExpression) return 'START AND SCHEDULE PURGE'
return 'START PURGE' return 'START PURGE'
}, [startPurgeMutation.isPending, source, jsonError, cronExpression]) }, [startPurgeMutation.isPending, source, jsonError, advanced.cronExpression])
const buttonIcon = useMemo(() => { const buttonIcon = useMemo(() => {
if (startPurgeMutation.isPending) return if (startPurgeMutation.isPending) return
@@ -140,13 +144,8 @@ export default function Purge() {
/> />
), ),
}, },
{
key: 'cron',
category: 'cron',
children: <CronEditor expression={cronExpression} onChange={setCronExpression} />,
},
], ],
[configGroup, globalFlags, configFlags, cronExpression] [configGroup, globalFlags, configFlags]
) )
const handleStart = useCallback(() => startPurgeMutation.mutate(), [startPurgeMutation.mutate]) const handleStart = useCallback(() => startPurgeMutation.mutate(), [startPurgeMutation.mutate])
@@ -167,20 +166,20 @@ export default function Purge() {
const handleResetOptions = useCallback(() => { const handleResetOptions = useCallback(() => {
startTransition(() => { startTransition(() => {
resetJson() resetJson()
setCronExpression(null) advanced.reset()
startPurgeMutation.reset() startPurgeMutation.reset()
}) })
}, [resetJson, startPurgeMutation.reset]) }, [advanced.reset, resetJson, startPurgeMutation.reset])
const handleResetAll = useCallback(() => { const handleResetAll = useCallback(() => {
startTransition(() => { startTransition(() => {
resetJson() resetJson()
resetLocks() resetLocks()
setCronExpression(null) advanced.reset()
setSource(undefined) setSource(undefined)
startPurgeMutation.reset() startPurgeMutation.reset()
}) })
}, [resetJson, resetLocks, startPurgeMutation.reset]) }, [advanced.reset, resetJson, resetLocks, startPurgeMutation.reset])
return ( return (
<div className="flex flex-col h-screen gap-10"> <div className="flex flex-col h-screen gap-10">
@@ -197,6 +196,8 @@ export default function Purge() {
showFiles={false} showFiles={false}
/> />
<AdvancedScheduleSection advanced={advanced} />
<OptionsAccordion <OptionsAccordion
defaultExpandedKeys={DEFAULT_EXPANDED_KEYS} defaultExpandedKeys={DEFAULT_EXPANDED_KEYS}
items={accordionItems} items={accordionItems}
+256 -60
View File
@@ -1,24 +1,69 @@
import { Card, CardBody, CardHeader, Input, Tooltip, useDisclosure } from '@heroui/react' import { Alert, Card, CardBody, CardHeader, Input, Tooltip, useDisclosure } from '@heroui/react'
import { Button, Chip } from '@heroui/react' import { Button, Chip } from '@heroui/react'
import { ask } from '@tauri-apps/plugin-dialog' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { ask, message } from '@tauri-apps/plugin-dialog'
import { platform } from '@tauri-apps/plugin-os' import { platform } from '@tauri-apps/plugin-os'
import CronExpressionParser from 'cron-parser'
import cronstrue from 'cronstrue' import cronstrue from 'cronstrue'
import { formatDistance } from 'date-fns' import { formatDistance } from 'date-fns'
import { AlertCircleIcon, Clock7Icon, PauseIcon, PlayIcon, Trash2Icon } from 'lucide-react' import {
AlertCircleIcon,
Clock7Icon,
PauseIcon,
PlayIcon,
StethoscopeIcon,
Trash2Icon,
ZapIcon,
} from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { formatErrorMessage, onErrorDialog } from '../../lib/errors'
import { buildReadablePath } from '../../lib/format' import { buildReadablePath } from '../../lib/format'
import { useNow } from '../../lib/hooks' import { useNow } from '../../lib/hooks'
import { LOCAL_HOST_ID } from '../../lib/hosts'
import {
type SchedulerTaskStatus,
schedulerDoctor,
removeScheduledTask as schedulerRemoveTask,
schedulerRunNow,
schedulerStatus,
updateScheduledTask as schedulerUpdateTask,
schedulerValidateCron,
setScheduledTaskEnabled,
useSchedulerSupported,
} from '../../lib/scheduler'
import { useHostStore } from '../../store/host' import { useHostStore } from '../../store/host'
import { usePersistedStore } from '../../store/persisted'
import type { ScheduledTask } from '../../types/schedules' import type { ScheduledTask } from '../../types/schedules'
import CommandsDropdown from '../components/CommandsDropdown' import CommandsDropdown from '../components/CommandsDropdown'
import ScheduleEditDrawer from '../components/ScheduleEditDrawer' import ScheduleEditDrawer from '../components/ScheduleEditDrawer'
export default function Schedules() { export default function Schedules() {
const scheduledTasks = useHostStore((state) => state.scheduledTasks) const scheduledTasks = useHostStore((state) => state.scheduledTasks)
const currentHostId = usePersistedStore((state) => state.currentHostId) ?? LOCAL_HOST_ID
const isLocalHost = currentHostId === LOCAL_HOST_ID
const supportQuery = useSchedulerSupported()
const schedulingAvailable = isLocalHost && (supportQuery.data?.supported ?? false)
const unavailableReason = isLocalHost
? (supportQuery.data?.reason ?? 'Scheduling is not available on this system.')
: 'Scheduling runs on your local machine only — switch to the local host to manage these tasks.'
const [selectedTask, setSelectedTask] = useState<ScheduledTask | null>(null) const [selectedTask, setSelectedTask] = useState<ScheduledTask | null>(null)
const { isOpen, onOpen, onClose } = useDisclosure() const { isOpen, onOpen, onClose } = useDisclosure()
const statusQuery = useQuery({
queryKey: ['scheduler', 'status'],
queryFn: () => schedulerStatus(LOCAL_HOST_ID),
enabled: schedulingAvailable,
refetchInterval: 5_000,
refetchOnWindowFocus: true,
})
const statusMap = useMemo(
() => new Map((statusQuery.data ?? []).map((status) => [status.taskId, status])),
[statusQuery.data]
)
const handleOpenDrawer = useCallback( const handleOpenDrawer = useCallback(
(task: ScheduledTask) => { (task: ScheduledTask) => {
setSelectedTask(task) setSelectedTask(task)
@@ -27,13 +72,38 @@ export default function Schedules() {
[onOpen] [onOpen]
) )
const doctorMutation = useMutation({
mutationFn: async () => {
const checks = await schedulerDoctor()
const report = checks
.map(
(check) =>
`${check.ok ? '✓' : '✗'} ${check.name}: ${check.detail}${check.fix ? `\n → ${check.fix}` : ''}`
)
.join('\n\n')
const hasFailure = checks.some((check) => !check.ok)
await message(report, {
title: 'Scheduling diagnostics',
kind: hasFailure ? 'warning' : 'info',
})
},
onError: async (error) => {
await message(formatErrorMessage(error, 'Diagnostics failed'), {
title: 'Scheduling diagnostics',
kind: 'error',
})
},
})
if (scheduledTasks.length === 0) { if (scheduledTasks.length === 0) {
return ( return (
<div className="flex flex-col items-center justify-center h-screen gap-8"> <div className="flex flex-col items-center justify-center h-screen gap-8">
<h1 className="max-w-md text-2xl font-bold text-center"> <h1 className="max-w-md text-2xl font-bold text-center">
You can schedule tasks to run later, when the UI is in the background. {schedulingAvailable || supportQuery.isLoading
? 'You can schedule tasks to run automatically, even while the app is closed.'
: unavailableReason}
</h1> </h1>
<CommandsDropdown title="New scheduled task" /> {schedulingAvailable && <CommandsDropdown title="New scheduled task" />}
</div> </div>
) )
} }
@@ -43,8 +113,36 @@ export default function Schedules() {
{platform() === 'macos' && ( {platform() === 'macos' && (
<div className="w-full h-10 border-b bg-content1 border-divider" /> <div className="w-full h-10 border-b bg-content1 border-divider" />
)} )}
{!schedulingAvailable && !supportQuery.isLoading && (
<Alert
color="warning"
title={unavailableReason}
radius="none"
classNames={{ base: 'flex-shrink-0' }}
/>
)}
{isLocalHost && (
<div className="flex justify-end flex-shrink-0 px-2 py-1">
<Button
size="sm"
variant="light"
startContent={<StethoscopeIcon className="w-4 h-4" />}
isLoading={doctorMutation.isPending}
onPress={() => doctorMutation.mutate()}
data-focus-visible="false"
>
Diagnostics
</Button>
</div>
)}
{scheduledTasks.map((task) => ( {scheduledTasks.map((task) => (
<TaskCard key={task.id} task={task} onOpenDrawer={handleOpenDrawer} /> <TaskCard
key={task.id}
task={task}
status={statusMap.get(task.id)}
schedulingAvailable={schedulingAvailable}
onOpenDrawer={handleOpenDrawer}
/>
))} ))}
{selectedTask && ( {selectedTask && (
<ScheduleEditDrawer isOpen={isOpen} onClose={onClose} selectedTask={selectedTask} /> <ScheduleEditDrawer isOpen={isOpen} onClose={onClose} selectedTask={selectedTask} />
@@ -55,15 +153,19 @@ export default function Schedules() {
function TaskCard({ function TaskCard({
task, task,
status,
schedulingAvailable,
onOpenDrawer, onOpenDrawer,
}: { task: ScheduledTask; onOpenDrawer: (task: ScheduledTask) => void }) { }: {
const [isBusy, setIsBusy] = useState(false) task: ScheduledTask
status?: SchedulerTaskStatus
schedulingAvailable: boolean
onOpenDrawer: (task: ScheduledTask) => void
}) {
const queryClient = useQueryClient()
const [isEditingName, setIsEditingName] = useState(false) const [isEditingName, setIsEditingName] = useState(false)
const [editingName, setEditingName] = useState(task.name) const [editingName, setEditingName] = useState(task.name)
const removeScheduledTask = useHostStore((state) => state.removeScheduledTask)
const updateScheduledTask = useHostStore((state) => state.updateScheduledTask)
useEffect(() => { useEffect(() => {
if (!isEditingName) { if (!isEditingName) {
setEditingName(task.name) setEditingName(task.name)
@@ -74,10 +176,19 @@ function TaskCard({
// their last dep change (e.g. a past occurrence kept showing as the "next run" forever). // their last dep change (e.g. a past occurrence kept showing as the "next run" forever).
const now = useNow() const now = useNow()
// Next-run preview comes from Rust (the runner's own cron matcher) — JS cron libraries
// disagree with real cron on dom/dow star semantics, so computing it here could predict
// fires the native schedule never performs. The query returns the next 5; the memo picks
// the first still in the future so the label stays fresh between refetches.
const nextRunsQuery = useQuery({
queryKey: ['scheduler', 'validate-cron', task.cron],
queryFn: () => schedulerValidateCron(task.cron),
refetchInterval: 60_000,
})
const nextRun = useMemo(() => { const nextRun = useMemo(() => {
const parsed = CronExpressionParser.parse(task.cron, { currentDate: new Date(now) }) const upcoming = nextRunsQuery.data?.nextRuns ?? []
return parsed.hasNext() ? parsed.next().toDate() : null return upcoming.map((run) => new Date(run)).find((run) => run.getTime() > now) ?? null
}, [task.cron, now]) }, [nextRunsQuery.data, now])
const source = useMemo( const source = useMemo(
() => ('source' in task.args ? task.args.source : task.args.sources[0]), () => ('source' in task.args ? task.args.source : task.args.sources[0]),
@@ -85,25 +196,85 @@ function TaskCard({
) )
const nextRunLabel = useMemo(() => { const nextRunLabel = useMemo(() => {
if (!task.isEnabled || !schedulingAvailable) {
return 'Paused'
}
if (nextRun) { if (nextRun) {
const distance = formatDistance(nextRun, new Date(now), { addSuffix: true }) const distance = formatDistance(nextRun, new Date(now), { addSuffix: true })
return distance.charAt(0).toUpperCase() + distance.slice(1) return distance.charAt(0).toUpperCase() + distance.slice(1)
} }
return 'Never' return 'Never'
}, [nextRun, now]) }, [nextRun, now, task.isEnabled, schedulingAvailable])
const isRunning = status?.running ?? false
const lastFinished = status?.lastFinished
const lastRunLabel = useMemo(() => { const lastRunLabel = useMemo(() => {
if (task.isRunning) { if (isRunning) {
return 'Running now' return 'Running now'
} }
if (task.lastRun) { if (lastFinished) {
const distance = formatDistance(new Date(task.lastRun), new Date(now), { const distance = formatDistance(new Date(lastFinished.ts), new Date(now), {
addSuffix: true, addSuffix: true,
}) })
return distance.charAt(0).toUpperCase() + distance.slice(1) return distance.charAt(0).toUpperCase() + distance.slice(1)
} }
return 'Never' return 'Never'
}, [task.isRunning, task.lastRun, now]) }, [isRunning, lastFinished, now])
const invalidateScheduler = () => queryClient.invalidateQueries({ queryKey: ['scheduler'] })
const runNowMutation = useMutation({
mutationFn: () => schedulerRunNow(task.id),
onSuccess: invalidateScheduler,
onError: onErrorDialog('Run now', 'Failed to start the task', { capture: false }),
})
const toggleMutation = useMutation({
mutationFn: async () => {
if (task.isEnabled) {
const answer = await ask('Are you sure you want to disable this task?')
if (!answer) {
return
}
await setScheduledTaskEnabled(task.id, false)
} else {
await setScheduledTaskEnabled(task.id, true)
}
},
onSuccess: invalidateScheduler,
onError: onErrorDialog('Schedule', 'Failed to update the task', { capture: false }),
})
const removeMutation = useMutation({
mutationFn: async () => {
const answer = await ask('Are you sure you want to remove this task?')
if (!answer) {
return
}
await schedulerRemoveTask(task.id)
},
onSuccess: invalidateScheduler,
onError: onErrorDialog('Schedule', 'Failed to remove the task', { capture: false }),
})
const commitName = (name: string | undefined) => {
setIsEditingName(false)
if (name === task.name) {
return
}
schedulerUpdateTask(task.id, { name }).catch((error) => {
console.error('[Schedules] rename failed', error)
})
}
const errorLine = task.registrationError
? `Not scheduled: ${task.registrationError}`
: status?.warning
? status.warning
: !isRunning && lastFinished && !lastFinished.success
? lastFinished.error || 'The last run failed'
: null
return ( return (
<Card <Card
@@ -136,6 +307,25 @@ function TaskCard({
> >
{task.operation.toUpperCase()} {task.operation.toUpperCase()}
</Chip> </Chip>
{task.runMode === 'system' && (
<Tooltip
content="Runs even while logged out — without your session's keychain, mounted drives, or (on macOS) protected folders"
placement="bottom"
size="lg"
color="foreground"
>
<Chip
isCloseable={false}
size="lg"
variant="flat"
radius="sm"
color="secondary"
className="h-10"
>
SYSTEM
</Chip>
</Tooltip>
)}
<div className="flex flex-col gap-0"> <div className="flex flex-col gap-0">
<Tooltip <Tooltip
content="Tap to edit the name" content="Tap to edit the name"
@@ -166,13 +356,11 @@ function TaskCard({
e.currentTarget.select() e.currentTarget.select()
}} }}
onBlur={() => { onBlur={() => {
updateScheduledTask(task.id, { name: editingName }) commitName(editingName)
setIsEditingName(false)
}} }}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
updateScheduledTask(task.id, { name: editingName }) commitName(editingName)
setIsEditingName(false)
e.currentTarget.blur() e.currentTarget.blur()
} else if (e.key === 'Escape') { } else if (e.key === 'Escape') {
setEditingName(task.name) setEditingName(task.name)
@@ -195,10 +383,10 @@ function TaskCard({
<div className="flex flex-col items-center justify-center gap-0.5"> <div className="flex flex-col items-center justify-center gap-0.5">
<Tooltip <Tooltip
content={ content={
task.isRunning isRunning
? undefined ? undefined
: task.lastRun : lastFinished
? new Date(task.lastRun).toLocaleDateString('en-US', { ? new Date(lastFinished.ts).toLocaleDateString('en-US', {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
weekday: 'short', weekday: 'short',
@@ -211,7 +399,7 @@ function TaskCard({
placement="bottom" placement="bottom"
size="lg" size="lg"
color="foreground" color="foreground"
isDisabled={task.isRunning} isDisabled={isRunning}
> >
<Chip <Chip
isCloseable={false} isCloseable={false}
@@ -219,9 +407,9 @@ function TaskCard({
variant="flat" variant="flat"
radius="sm" radius="sm"
color={ color={
task.isRunning isRunning
? 'success' ? 'success'
: task.lastRunError : lastFinished && !lastFinished.success
? 'danger' ? 'danger'
: 'default' : 'default'
} }
@@ -250,7 +438,11 @@ function TaskCard({
size="lg" size="lg"
variant="flat" variant="flat"
radius="sm" radius="sm"
color={'primary'} color={
task.isEnabled && schedulingAvailable
? 'primary'
: 'default'
}
> >
{nextRunLabel} {nextRunLabel}
</Chip> </Chip>
@@ -259,25 +451,30 @@ function TaskCard({
</div> </div>
</div> </div>
<div className="flex flex-row justify-end gap-2"> <div className="flex flex-row justify-end gap-2">
<Tooltip content="Run now" placement="bottom" size="lg" color="foreground">
<Button
isIconOnly={true}
color="success"
variant="flat"
isDisabled={
!schedulingAvailable ||
!task.isEnabled ||
isRunning ||
runNowMutation.isPending
}
size="sm"
onPress={() => runNowMutation.mutate()}
data-focus-visible="false"
>
<ZapIcon className="w-4 h-4" />
</Button>
</Tooltip>
<Button <Button
isIconOnly={true} isIconOnly={true}
color={task.isEnabled ? 'primary' : 'warning'} color={task.isEnabled ? 'primary' : 'warning'}
isDisabled={isBusy} isDisabled={!schedulingAvailable || toggleMutation.isPending}
size="sm" size="sm"
onPress={async () => { onPress={() => toggleMutation.mutate()}
setIsBusy(true)
if (task.isEnabled) {
const answer = await ask(
'Are you sure you want to disable this task? This will not stop the current run.'
)
if (answer) {
updateScheduledTask(task.id, { isEnabled: false })
}
} else {
updateScheduledTask(task.id, { isEnabled: true })
}
setIsBusy(false)
}}
data-focus-visible="false" data-focus-visible="false"
> >
{task.isEnabled ? ( {task.isEnabled ? (
@@ -289,18 +486,9 @@ function TaskCard({
<Button <Button
isIconOnly={true} isIconOnly={true}
color="danger" color="danger"
isDisabled={isBusy} isDisabled={removeMutation.isPending}
size="sm" size="sm"
onPress={async () => { onPress={() => removeMutation.mutate()}
setIsBusy(true)
const answer = await ask(
'Are you sure you want to remove this task?'
)
if (answer) {
removeScheduledTask(task.id)
}
setIsBusy(false)
}}
data-focus-visible="false" data-focus-visible="false"
> >
<Trash2Icon className="w-4 h-4" /> <Trash2Icon className="w-4 h-4" />
@@ -310,16 +498,16 @@ function TaskCard({
</CardHeader> </CardHeader>
<CardBody> <CardBody>
<div className="flex flex-row items-center justify-start gap-1 text-sm font-bold"> <div className="flex flex-row items-center justify-start gap-1 text-sm font-bold">
{task.lastRunError ? ( {errorLine ? (
<> <>
<AlertCircleIcon className="w-4 h-4 text-danger-600" /> <AlertCircleIcon className="w-4 h-4 text-danger-600" />
<p className="text-sm font-bold text-danger-600">{task.lastRunError}</p> <p className="text-sm font-bold text-danger-600">{errorLine}</p>
</> </>
) : ( ) : (
<> <>
<Clock7Icon className="w-4 h-4" /> <Clock7Icon className="w-4 h-4" />
<p className="text-sm font-bold truncate"> <p className="text-sm font-bold truncate">
{cronstrue.toString(task.cron)}. {safeCronDescription(task.cron)}
</p> </p>
</> </>
)} )}
@@ -328,3 +516,11 @@ function TaskCard({
</Card> </Card>
) )
} }
function safeCronDescription(cron: string) {
try {
return `${cronstrue.toString(cron)}.`
} catch {
return cron
}
}
+22 -3
View File
@@ -22,6 +22,7 @@ import {
import { MIN_RCLONE_VERSION } from '../../../lib/rclone/constants' import { MIN_RCLONE_VERSION } from '../../../lib/rclone/constants'
import { import {
type DownloadProgress, type DownloadProgress,
type DownloadedVersion,
activateRclonePath, activateRclonePath,
deleteVersion, deleteVersion,
downloadVersion, downloadVersion,
@@ -30,6 +31,7 @@ import {
listDownloadedVersions, listDownloadedVersions,
setPathIntegration, setPathIntegration,
} from '../../../lib/rclone/versions' } from '../../../lib/rclone/versions'
import { useHostStore } from '../../../store/host'
import { usePersistedStore } from '../../../store/persisted' import { usePersistedStore } from '../../../store/persisted'
import BaseSection from './BaseSection' import BaseSection from './BaseSection'
@@ -117,6 +119,25 @@ export default function BinarySection() {
}, },
}) })
const scheduledTasks = useHostStore((state) => state.scheduledTasks)
const handleDeleteVersion = async (v: DownloadedVersion) => {
// Schedules can pin a specific downloaded version by absolute path — deleting it would
// make every later run fail with "binary not found". Being the active global binary is
// not the only way a version can be in use. (Schedules are local-host-only, so the host
// store is authoritative here.)
const pinnedBy = scheduledTasks.filter((task) => task.binaryPath === v.path)
if (pinnedBy.length > 0) {
const names = pinnedBy.map((task) => task.name || task.operation).join(', ')
await message(
`This version is used by ${pinnedBy.length} scheduled task(s): ${names}. Change their rclone binary in the schedule settings first.`,
{ title: 'Version in use', kind: 'warning' }
)
return
}
deleteMutation.mutate(v.version)
}
const downloadedVersions = downloadedQuery.data ?? [] const downloadedVersions = downloadedQuery.data ?? []
const downloadedSet = useMemo( const downloadedSet = useMemo(
() => new Set(downloadedVersions.map((v) => v.version)), () => new Set(downloadedVersions.map((v) => v.version)),
@@ -191,9 +212,7 @@ export default function BinarySection() {
actionLabel="Use" actionLabel="Use"
isActivating={activateMutation.isPending} isActivating={activateMutation.isPending}
onActivate={() => activateMutation.mutate({ path: v.path })} onActivate={() => activateMutation.mutate({ path: v.path })}
onDelete={ onDelete={isActive ? undefined : () => handleDeleteVersion(v)}
isActive ? undefined : () => deleteMutation.mutate(v.version)
}
isDeleting={ isDeleting={
deleteMutation.isPending && deleteMutation.isPending &&
deleteMutation.variables === v.version deleteMutation.variables === v.version
+17 -17
View File
@@ -7,12 +7,14 @@ import { getOptionsSubtitle } from '../../lib/flags'
import { useFlags } from '../../lib/hooks' import { useFlags } from '../../lib/hooks'
import { startDryRun, startSync } from '../../lib/rclone/api' import { startDryRun, startSync } from '../../lib/rclone/api'
import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants' import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants'
import CronEditor from '../components/CronEditor'
import OperationWindowContent from '../components/OperationWindowContent' import OperationWindowContent from '../components/OperationWindowContent'
import OperationWindowFooter from '../components/OperationWindowFooter' import OperationWindowFooter from '../components/OperationWindowFooter'
import OptionsSection from '../components/OptionsSection' import OptionsSection from '../components/OptionsSection'
import { PathFinder } from '../components/PathFinder' import { PathFinder } from '../components/PathFinder'
import RemoteOptionsSection from '../components/RemoteOptionsSection' import RemoteOptionsSection from '../components/RemoteOptionsSection'
import AdvancedScheduleSection, {
useAdvancedSchedule,
} from '../components/operation/AdvancedScheduleSection'
import OperationFooter from '../components/operation/OperationFooter' import OperationFooter from '../components/operation/OperationFooter'
import OptionsAccordion, { import OptionsAccordion, {
type OptionsAccordionItemDef, type OptionsAccordionItemDef,
@@ -69,7 +71,7 @@ Expand the accordion sections to customize your sync operation. Tap any chip on
Filters Include or exclude files by pattern, limit by size (max_size, min_size) or age (max_age, min_age). Filters Include or exclude files by pattern, limit by size (max_size, min_size) or age (max_age, min_age).
Cron Schedule this sync to run automatically at set intervals. The schedule only triggers while the app is running. Cron Schedule this sync to run automatically at set intervals. It runs on a system schedule, even when the app is closed.
Config Performance tuning: parallel transfers, checkers, buffer_size, bandwidth limits (bwlimit), and fast_list for faster directory listings on supported remotes. Config Performance tuning: parallel transfers, checkers, buffer_size, bandwidth limits (bwlimit), and fast_list for faster directory listings on supported remotes.
@@ -113,7 +115,7 @@ export default function Sync() {
const filterGroup = optionGroups.filter const filterGroup = optionGroups.filter
const configGroup = optionGroups.config const configGroup = optionGroups.config
const [cronExpression, setCronExpression] = useState<string | null>(null) const advanced = useAdvancedSchedule()
const selectedRemotes = useMemo(() => [source, dest].filter(Boolean), [source, dest]) const selectedRemotes = useMemo(() => [source, dest].filter(Boolean), [source, dest])
@@ -137,7 +139,7 @@ export default function Sync() {
return startSync(buildArgs()) return startSync(buildArgs())
}, },
onSuccess: () => { onSuccess: () => {
if (cronExpression) { if (advanced.cronExpression) {
scheduleTaskMutation.mutate() scheduleTaskMutation.mutate()
} }
}, },
@@ -146,7 +148,9 @@ export default function Sync() {
const scheduleTaskMutation = useScheduleTask({ const scheduleTaskMutation = useScheduleTask({
operation: 'sync', operation: 'sync',
cronExpression, cronExpression: advanced.cronExpression,
configId: advanced.configId,
binaryPath: advanced.binaryPath,
validate: () => { validate: () => {
if (!source || !dest) { if (!source || !dest) {
throw new Error('Please select both a source and destination path') throw new Error('Please select both a source and destination path')
@@ -179,9 +183,9 @@ export default function Sync() {
if (!dest) return 'Please select a destination path' if (!dest) return 'Please select a destination path'
if (source === dest) return 'Source and destination cannot be the same' if (source === dest) return 'Source and destination cannot be the same'
if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options' if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options'
if (cronExpression) return 'START AND SCHEDULE SYNC' if (advanced.cronExpression) return 'START AND SCHEDULE SYNC'
return 'START SYNC' return 'START SYNC'
}, [startSyncMutation.isPending, source, dest, jsonError, cronExpression]) }, [startSyncMutation.isPending, source, dest, jsonError, advanced.cronExpression])
const buttonIcon = useMemo(() => { const buttonIcon = useMemo(() => {
if (startSyncMutation.isPending) return if (startSyncMutation.isPending) return
@@ -222,11 +226,6 @@ export default function Sync() {
/> />
), ),
}, },
{
key: 'cron',
category: 'cron',
children: <CronEditor expression={cronExpression} onChange={setCronExpression} />,
},
{ {
key: 'config', key: 'config',
category: 'config', category: 'config',
@@ -275,7 +274,6 @@ export default function Sync() {
syncFlags, syncFlags,
filterFlags, filterFlags,
configFlags, configFlags,
cronExpression,
selectedRemotes, selectedRemotes,
remotesGroup, remotesGroup,
] ]
@@ -302,21 +300,21 @@ export default function Sync() {
const handleResetOptions = useCallback(() => { const handleResetOptions = useCallback(() => {
startTransition(() => { startTransition(() => {
resetJson() resetJson()
setCronExpression(null) advanced.reset()
startSyncMutation.reset() startSyncMutation.reset()
}) })
}, [resetJson, startSyncMutation.reset]) }, [advanced.reset, resetJson, startSyncMutation.reset])
const handleResetAll = useCallback(() => { const handleResetAll = useCallback(() => {
startTransition(() => { startTransition(() => {
resetJson() resetJson()
resetLocks() resetLocks()
setCronExpression(null) advanced.reset()
setDest(undefined) setDest(undefined)
setSource(undefined) setSource(undefined)
startSyncMutation.reset() startSyncMutation.reset()
}) })
}, [resetJson, resetLocks, startSyncMutation.reset]) }, [advanced.reset, resetJson, resetLocks, startSyncMutation.reset])
return ( return (
<div className="flex flex-col h-screen gap-10"> <div className="flex flex-col h-screen gap-10">
@@ -332,6 +330,8 @@ export default function Sync() {
destOptions={DEST_OPTIONS} destOptions={DEST_OPTIONS}
/> />
<AdvancedScheduleSection advanced={advanced} />
<OptionsAccordion banner={true} items={accordionItems} /> <OptionsAccordion banner={true} items={accordionItems} />
</OperationWindowContent> </OperationWindowContent>
+38 -38
View File
@@ -66,12 +66,7 @@ interface HostState {
favoritePaths: { remote: string; path: string; added: number }[] favoritePaths: { remote: string; path: string; added: number }[]
scheduledTasks: ScheduledTask[] scheduledTasks: ScheduledTask[]
addScheduledTask: ( addScheduledTask: (task: Omit<ScheduledTask, 'id'>) => string
task: Omit<
ScheduledTask,
'id' | 'isRunning' | 'currentRunId' | 'lastRun' | 'configId' | 'isEnabled'
>
) => void
removeScheduledTask: (id: string) => void removeScheduledTask: (id: string) => void
updateScheduledTask: (id: string, task: Partial<ScheduledTask>) => void updateScheduledTask: (id: string, task: Partial<ScheduledTask>) => void
@@ -92,7 +87,7 @@ interface HostState {
export const useHostStore = create<HostState>()( export const useHostStore = create<HostState>()(
persist( persist(
(set, get) => ({ (set) => ({
remoteConfigs: {}, remoteConfigs: {},
setRemoteConfig: (remote: string, config: RemoteConfig) => setRemoteConfig: (remote: string, config: RemoteConfig) =>
set((state) => ({ set((state) => ({
@@ -111,32 +106,12 @@ export const useHostStore = create<HostState>()(
favoritePaths: [], favoritePaths: [],
scheduledTasks: [], scheduledTasks: [],
addScheduledTask: ( addScheduledTask: (task: Omit<ScheduledTask, 'id'>) => {
task: Omit< const id = crypto.randomUUID()
ScheduledTask,
'id' | 'isRunning' | 'currentRunId' | 'lastRun' | 'configId' | 'isEnabled'
>
) => {
const state = get()
const configId = state.activeConfigId
if (!configId) {
console.error('No active config file for scheduled task')
throw new Error('No active config file')
}
set((state) => ({ set((state) => ({
scheduledTasks: [ scheduledTasks: [...state.scheduledTasks, { ...task, id } as ScheduledTask],
...state.scheduledTasks,
{
...task,
id: crypto.randomUUID(),
isRunning: false,
isEnabled: true,
configId,
} as ScheduledTask,
],
})) }))
return id
}, },
removeScheduledTask: (id: string) => removeScheduledTask: (id: string) =>
set((state) => ({ set((state) => ({
@@ -182,22 +157,47 @@ export const useHostStore = create<HostState>()(
skipHydration: true, skipHydration: true,
version: 2, version: 2,
migrate: (persistedState, version) => { migrate: (persistedState, version) => {
// v1 stored the full active ConfigFile object; v2 stores just its id. Also handles if (!persistedState) {
// the version-1 blob written by the persisted-store's legacy migration, whose return persistedState
}
let state = persistedState as Record<string, unknown>
// - The full active ConfigFile object collapses to just its id. Also handles the
// version-1 blob written by the persisted-store's legacy migration, whose
// configFiles can be undefined. // configFiles can be undefined.
if (version < 2 && persistedState) { // - Scheduling moved to the OS scheduler. Runtime fields (isRunning/currentRunId/
const { activeConfigFile, configFiles, ...rest } = persistedState as { // lastRun/lastRunError) now live in the scheduler's run history; tasks gain a
// per-task binary. Pure reshape — OS registration happens in the startup
// reconcile.
if (version < 2) {
const { activeConfigFile, configFiles, ...rest } = state as {
activeConfigFile?: ConfigFile | null activeConfigFile?: ConfigFile | null
configFiles?: ConfigFile[] configFiles?: ConfigFile[]
[key: string]: unknown [key: string]: unknown
} }
return { const activeConfigId = activeConfigFile?.id ?? null
const tasks = (rest.scheduledTasks as Record<string, unknown>[]) ?? []
state = {
...rest, ...rest,
configFiles: configFiles ?? [], configFiles: configFiles ?? [],
activeConfigId: activeConfigFile?.id ?? null, activeConfigId,
scheduledTasks: tasks.map(
({ isRunning, currentRunId, lastRun, lastRunError, ...task }) => ({
...task,
// The old scheduler silently skipped tasks whose config wasn't
// the active one — those have effectively been dormant, and the
// OS scheduler would resurrect them. Migrate them as paused so
// re-enabling is an explicit user choice.
isEnabled:
(task.isEnabled ?? true) &&
(!activeConfigId || task.configId === activeConfigId),
binaryPath: 'app-default',
})
),
} }
} }
return persistedState
return state
}, },
} }
) )
+14
View File
@@ -1,6 +1,16 @@
import { shared } from 'use-broadcast-ts' import { shared } from 'use-broadcast-ts'
import { create } from 'zustand' import { create } from 'zustand'
// Registered by the start* functions in lib/rclone/api.ts; consumed by the main window's job
// watcher (lib/notifications.ts). Plain JSON only — values cross a BroadcastChannel.
export interface WatchedJob {
jobid: number
operation: 'copy' | 'move' | 'sync' | 'bisync' | 'delete' | 'purge' | 'batch'
sources?: string[]
destination?: string
startedAt: number
}
interface State { interface State {
startupStatus: startupStatus:
| null | null
@@ -21,6 +31,8 @@ interface State {
} | null } | null
dryRunJobIds: number[] dryRunJobIds: number[]
watchedJobs: Record<number, WatchedJob>
} }
export const useStore = create<State>()( export const useStore = create<State>()(
@@ -34,6 +46,8 @@ export const useStore = create<State>()(
cloudflaredTunnel: null, cloudflaredTunnel: null,
dryRunJobIds: [], dryRunJobIds: [],
watchedJobs: {},
}), }),
{ name: 'shared-store' } { name: 'shared-store' }
) )
+47
View File
@@ -0,0 +1,47 @@
// Mirrors src-tauri/src/notifications/catalog.rs (event ids/categories/severities) and
// targets.rs (NotificationTarget shape, persisted in notifications/targets.json). The Rust side
// is the source of truth — keep both in sync, and never rename an event id after release.
export type NotificationEventId =
| 'job.started'
| 'job.completed'
| 'job.failed'
| 'schedule.started'
| 'schedule.completed'
| 'schedule.failed'
| 'mount.failed'
| 'rclone.crashed'
| 'rclone.update-available'
| 'app.update-available'
export type NotificationSeverity = 'info' | 'success' | 'error'
export type NotificationCategory = 'transfers' | 'schedules' | 'system'
export type NotificationProvider = 'discord' | 'slack' | 'telegram' | 'webhook'
export interface NotificationEventMeta {
id: NotificationEventId
label: string
description: string
category: NotificationCategory
severity: NotificationSeverity
}
/** Returned by the `notifications_catalog` command. */
export interface NotificationCatalog {
categories: { id: NotificationCategory; label: string }[]
events: NotificationEventMeta[]
}
export interface NotificationTarget {
id: string
provider: NotificationProvider
name: string
url: string
isEnabled: boolean
events: NotificationEventId[]
createdAt: number
// Delivery status, written by the dispatcher after each send attempt.
lastSentAt?: number
lastError?: string
}
+35 -17
View File
@@ -1,45 +1,63 @@
import type { import type {
startBisync, BisyncArgs,
startCopy, CopyArgs,
startDelete, DeleteArgs,
startMove, MoveArgs,
startPurge, PurgeArgs,
startSync, SyncArgs,
} from '../lib/rclone/api' } from '../lib/rclone/requests'
export type ScheduledTask = { export type ScheduledTask = {
id: string id: string
name?: string name?: string
cron: string cron: string
isRunning: boolean
isEnabled: boolean isEnabled: boolean
currentRunId?: string /** The config file this task runs with (a lookup into the host's configFiles). */
lastRun?: string
lastRunError?: string
configId: string configId: string
/** 'app-default' or an absolute path to a specific rclone binary. */
binaryPath: 'app-default' | string
/** Raise the run's rclone daemon to INFO logging (per-transfer lines in the rclone log). */
verboseLogging?: boolean
/**
* Max run time in whole hours (1-120); the runner stops the job when it's exceeded.
* Default 24 when absent.
*/
maxRunHours?: number
/**
* 'user' (default, also when absent): only runs while the user is logged in, borrowing the
* session's context. 'system': runs even while logged out, but outside the login session
* no OS keychain, session-mounted drives, or (macOS) protected folders without a cron FDA
* grant.
*/
runMode?: 'system' | 'user'
/**
* Set when the last OS-registration attempt failed (cron unrepresentable on this platform,
* register error). Persisted so a disabled task can explain itself across restarts.
*/
registrationError?: string
} & ( } & (
| { | {
operation: 'delete' operation: 'delete'
args: Parameters<typeof startDelete>[0] args: DeleteArgs
} }
| { | {
operation: 'sync' operation: 'sync'
args: Parameters<typeof startSync>[0] args: SyncArgs
} }
| { | {
operation: 'copy' operation: 'copy'
args: Parameters<typeof startCopy>[0] args: CopyArgs
} }
| { | {
operation: 'move' operation: 'move'
args: Parameters<typeof startMove>[0] args: MoveArgs
} }
| { | {
operation: 'purge' operation: 'purge'
args: Parameters<typeof startPurge>[0] args: PurgeArgs
} }
| { | {
operation: 'bisync' operation: 'bisync'
args: Parameters<typeof startBisync>[0] args: BisyncArgs
} }
) )