diff --git a/lib/notifications.ts b/lib/notifications.ts new file mode 100644 index 0000000..8f49a87 --- /dev/null +++ b/lib/notifications.ts @@ -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 { + return await invoke('notifications_catalog') +} + +export async function listNotificationTargets(): Promise { + return await invoke('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 { + return await invoke('notifications_add_target', { target }) +} + +export async function updateNotificationTarget( + id: string, + patch: Partial> +): Promise { + await invoke('notifications_update_target', { id, patch }) +} + +export async function removeNotificationTarget(id: string): Promise { + 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 } +): Promise { + 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 & { id?: string; name?: string } +): Promise { + 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 { + 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:"; 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/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/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 | 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() +const handledJobIds = new Set() +const statusFailures = new Map() + +/** + * 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) { + 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, + } +} diff --git a/lib/rclone/requests.ts b/lib/rclone/requests.ts new file mode 100644 index 0000000..111d0ff --- /dev/null +++ b/lib/rclone/requests.ts @@ -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 + config?: Record + filter?: Record + remotes?: Record> + } +} + +export interface MoveArgs { + sources: string[] + destination: string + options: { + move?: Record + config?: Record + filter?: Record + remotes?: Record> + } +} + +export interface SyncArgs { + source: string + destination: string + options: { + config?: Record + sync?: Record + filter?: Record + remotes?: Record> + } +} + +export interface BisyncArgs { + source: string + destination: string + options: { + config?: Record + bisync?: Record + filter?: Record + remotes?: Record> + outer?: Record + } +} + +export interface DeleteArgs { + sources: string[] + options: { + filter?: Record + config?: Record + remotes?: Record> + } +} + +export interface PurgeArgs { + sources: string[] + options: { + config?: Record + remotes?: Record> + } +} + +export type BatchInput = { _path: string } & Record + +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 +} + +// Encodes a path as an rclone connection string with inlined per-remote and global options: +// ",=\"v\",global.=\"gv\":". +export function serializeOptions( + remotePath: string, + options: { + remote?: Record + global?: Record + } +) { + 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) { + 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> | 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 +): BatchInput[] { + const { sources, destination, options } = args + + const inputs: BatchInput[] = [] + const handledSourcePaths: Record = {} + const folderSources = sources.filter((path) => path.endsWith('/') || path.endsWith('\\')) + + const { + root: dstRoot, + dirPath: dstDirPath, + fullDirPath: dstFullDirPath, + remoteName: dstRemoteName, + } = getFsInfo(destination) + + const dstOptions = 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 = {} + 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 = {} + + 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}`) + } +} diff --git a/lib/scheduler.ts b/lib/scheduler.ts new file mode 100644 index 0000000..5b266f0 --- /dev/null +++ b/lib/scheduler.ts @@ -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 { + if (!cachedSupport) { + cachedSupport = await invoke('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('scheduler_validate_cron', { cron }) +} + +export async function schedulerStatus(hostId: string) { + return invoke('scheduler_status', { hostId }) +} + +export async function schedulerReadHistory(taskId: string, limit?: number) { + return invoke('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('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 { + 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 { + 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 + + // 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 +): Promise { + 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 { + 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 { + 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 { + 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('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 { + 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) + } +} diff --git a/main.ts b/main.ts index ec0beea..575da37 100644 --- a/main.ts +++ b/main.ts @@ -9,34 +9,23 @@ import { debug, error, info, trace, warn } from '@tauri-apps/plugin-log' import { platform } from '@tauri-apps/plugin-os' import { exit, relaunch } from '@tauri-apps/plugin-process' import { type Update, check } from '@tauri-apps/plugin-updater' -import { CronExpressionParser } from 'cron-parser' import { defaultOptions } from 'tauri-plugin-sentry-api' import { getDeepLinkUrl, handleDeepLinkUrl } from './lib/deep' import { CLOSE_APP, RELAUNCH_APP, RESTART_RCLONE, type RestartRclonePayload } from './lib/events' import { LOCAL_HOST_ID, RC_PORT, getHostInfo, makeLocalHost } from './lib/hosts' import { validateLicense } from './lib/license' -import notify from './lib/notify' import queryClient from './lib/query' -import { - listTransfers, - startBisync, - startCopy, - startDelete, - startMount, - startMove, - startPurge, - startSync, -} from './lib/rclone/api' +import { listTransfers, startMount } from './lib/rclone/api' import rcloneClient from './lib/rclone/client' import { compareVersions } from './lib/rclone/common' import { initRclone } from './lib/rclone/init' +import { initScheduler } from './lib/scheduler' import { initTray } from './lib/tray' import { openSmallWindow } from './lib/window' import { initHostStore, useHostStore } from './store/host' import { waitForStoreHydration } from './store/lib' import { useStore } from './store/memory' import { selectCurrentHost, usePersistedStore } from './store/persisted' -import type { ScheduledTask } from './types/schedules' let rcloneListenersRegistered = false @@ -101,8 +90,11 @@ async function checkFlatpakPermissions() { const hasPermissions = await invoke('has_flatpak_permissions') if (hasPermissions) return + const overrideCommand = + 'flatpak override --user --filesystem=host --talk-name=org.freedesktop.Flatpak com.rcloneui.RcloneUI' + 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', kind: 'warning', @@ -112,7 +104,7 @@ async function checkFlatpakPermissions() { ) if (copyCommand) { - await writeText('flatpak override --user --filesystem=host com.rcloneui.RcloneUI') + await writeText(overrideCommand) } await exit() @@ -623,292 +615,6 @@ async function showStartup() { 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) { const confirmed = await ask( 'You are running an outdated version of Rclone UI. Please update to the latest version.', @@ -1104,6 +810,6 @@ waitForHydration() .then(() => handleDeepLink()) .then(() => showStartup()) .then(() => startupMounts()) - .then(() => resumeTasks()) + .then(() => initScheduler()) .then(() => initTray()) .catch(console.error) diff --git a/package-lock.json b/package-lock.json index 7f22c50..6c2ffe0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,6 @@ "@tauri-apps/plugin-store": "^2.4.3", "@tauri-apps/plugin-updater": "^2.10.1", "@tauri-apps/plugin-window-state": "^2.4.1", - "cron-parser": "^5.6.1", "cronstrue": "^3.24.0", "date-fns": "^4.4.0", "framer-motion": "^12.42.2", @@ -6522,18 +6521,6 @@ "dev": true, "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": { "version": "3.24.0", "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" } }, - "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": { "version": "4.19.0", "resolved": "https://registry.npmjs.org/media-chrome/-/media-chrome-4.19.0.tgz", diff --git a/package.json b/package.json index ca28e3c..94a81b7 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,6 @@ "@tauri-apps/plugin-store": "^2.4.3", "@tauri-apps/plugin-updater": "^2.10.1", "@tauri-apps/plugin-window-state": "^2.4.1", - "cron-parser": "^5.6.1", "cronstrue": "^3.24.0", "date-fns": "^4.4.0", "framer-motion": "^12.42.2", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 09a80a6..171c242 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -247,12 +247,16 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" name = "app" version = "3.6.1" dependencies = [ + "chrono", "cocoa", + "dirs 6.0.0", "fix-path-env", "flate2", "gtk", + "libc", "log", "machine-uid", + "notify-rust", "objc", "reqwest 0.13.3", "sentry", @@ -281,6 +285,7 @@ dependencies = [ "tauri-plugin-store", "tauri-plugin-updater", "tinyfiledialogs-rs", + "uuid", "windows-sys 0.59.0", "winreg 0.52.0", "x11rb", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index aa9bb72..e306abe 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -50,6 +50,18 @@ tauri-plugin-deep-link = "2.4.9" flate2 = "1.1.9" tar = "0.4.45" 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] cocoa = "0.26" @@ -62,4 +74,12 @@ gtk = "0.18" [target.'cfg(target_os = "windows")'.dependencies] 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", +] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2bed408..6c687c5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -12,8 +12,21 @@ mod shortcut; #[path = "../common/window.rs"] mod window; +mod notifications; +mod scheduler; 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::{ 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] fn has_flatpak_permissions() -> bool { - // Native app: no Flatpak permission needed. if !is_flatpak() { 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 { return false; }; @@ -127,6 +146,76 @@ fn has_flatpak_permissions() -> bool { 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) -> Result<(), String> { let timeout = timeout_ms.unwrap_or(5000); @@ -786,7 +875,26 @@ pub fn run() { zookeeper::download_rclone_version, zookeeper::update_path_pointer, 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| { #[cfg(target_os = "linux")] diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index aebdb65..f71ab3c 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,6 +1,40 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { + // Headless scheduled-task mode: `"Rclone UI" run-task [--host ]`, 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 = 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")] { let is_dri_present = std::path::Path::new("/dev/dri").exists(); diff --git a/src-tauri/src/notifications/catalog.rs b/src-tauri/src/notifications/catalog.rs new file mode 100644 index 0000000..4b76f2f --- /dev/null +++ b/src-tauri/src/notifications/catalog.rs @@ -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"); + } +} diff --git a/src-tauri/src/notifications/mod.rs b/src-tauri/src/notifications/mod.rs new file mode 100644 index 0000000..4e4579a --- /dev/null +++ b/src-tauri/src/notifications/mod.rs @@ -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, 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 { + 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, +) -> 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, + name: Option, +) -> 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))? +} diff --git a/src-tauri/src/notifications/os.rs b/src-tauri/src/notifications/os.rs new file mode 100644 index 0000000..c48b23f --- /dev/null +++ b/src-tauri/src/notifications/os.rs @@ -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"); + } +} diff --git a/src-tauri/src/notifications/targets.rs b/src-tauri/src/notifications/targets.rs new file mode 100644 index 0000000..0cc1e4d --- /dev/null +++ b/src-tauri/src/notifications/targets.rs @@ -0,0 +1,410 @@ +//! The Rust-owned notification-target store: `/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, + pub created_at: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_sent_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_error: Option, +} + +#[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, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TargetPatch { + pub name: Option, + pub url: Option, + pub events: Option>, + pub is_enabled: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct TargetsFile { + version: u32, + targets: Vec, +} + +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 { + 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 { + 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::().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, 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, String> { + let _lock = acquire_store_lock(dirs)?; + load_locked(dirs) +} + +pub fn add(dirs: &AppDirs, new: NewTarget) -> Result { + 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)]) { + 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); + } +} diff --git a/src-tauri/src/notifications/webhooks.rs b/src-tauri/src/notifications/webhooks.rs new file mode 100644 index 0000000..76d7359 --- /dev/null +++ b/src-tauri/src/notifications/webhooks.rs @@ -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('&', "&") + .replace('<', "<") + .replace('>', ">") +} + +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 { + 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!( + "{}\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 { + 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)> = 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, ×tamp) { + 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, ×tamp)?; + + 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) { + 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::().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 & 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"], "A <b>\nB & 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"); + } +} diff --git a/src-tauri/src/scheduler/cronconv.rs b/src-tauri/src/scheduler/cronconv.rs new file mode 100644 index 0000000..9e29c6c --- /dev/null +++ b/src-tauri/src/scheduler/cronconv.rs @@ -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, + /// 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 { + 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 { + 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 { + 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 { + if let Ok(v) = raw.parse::() { + 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, + count: usize, +) -> Vec { + 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::>() + .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, + pub hour: Option, + pub day: Option, + pub weekday: Option, + pub month: Option, +} + +pub fn to_launchd(spec: &CronSpec) -> Result, 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, Option)> = 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> { + 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), + /// Days 1-31 plus months 1-12 (all twelve when the cron month field is a wildcard). + Monthly { + days: BTreeSet, + months: BTreeSet, + }, + /// 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, + months: BTreeSet, + }, +} + +#[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, String> { + // Day-shape dimension. dom+dow both restricted → both trigger families (triggers OR). + let mut shapes: Vec = 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 = 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 × { + 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 { + if minute.wildcard { + return Some(1); + } + let values: Vec = 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 { + 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 = 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> = 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> = 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)); + } +} diff --git a/src-tauri/src/scheduler/crontab.rs b/src-tauri/src/scheduler/crontab.rs new file mode 100644 index 0000000..9a629d6 --- /dev/null +++ b/src-tauri/src/scheduler/crontab.rs @@ -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//…` 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: +//! '' run-task --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 ` 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 { + 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 " (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 { + 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 `), 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 { + 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 { + 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::>() + .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 { + 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) -> u32 { + let Ok(content) = CrontabBackend::read() else { + return 0; + }; + let ids: Vec = 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")); + } +} diff --git a/src-tauri/src/scheduler/history.rs b/src-tauri/src/scheduler/history.rs new file mode 100644 index 0000000..86aea3e --- /dev/null +++ b/src-tauri/src/scheduler/history.rs @@ -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, + #[serde(rename = "durationMs")] + duration_ms: u64, + #[serde(skip_serializing_if = "Option::is_none")] + jobids: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + stats: Option, + }, + 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 { + 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::(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, + /// 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, +} + +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 { + 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::(&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 { + 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::(&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::(&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::(&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, +} + +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 `.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); + } +} diff --git a/src-tauri/src/scheduler/jobfile.rs b/src-tauri/src/scheduler/jobfile.rs new file mode 100644 index 0000000..6208314 --- /dev/null +++ b/src-tauri/src/scheduler/jobfile.rs @@ -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, +} + +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 { + 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 { + 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::(&raw).map_err(|e| e.to_string())) + { + Ok(spec) => specs.push(spec), + Err(e) => log::warn!("skipping unreadable job file {}: {}", path.display(), e), + } + } + specs +} diff --git a/src-tauri/src/scheduler/launchd.rs b/src-tauri/src/scheduler/launchd.rs new file mode 100644 index 0000000..6b24fa0 --- /dev/null +++ b/src-tauri/src/scheduler/launchd.rs @@ -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 { + 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//