From 58ca8a92fa5fa1176f1af90cd8f6b0da656ca00f Mon Sep 17 00:00:00 2001 From: FTCHD <144691102+FTCHD@users.noreply.github.com> Date: Tue, 5 Aug 2025 05:24:03 +0300 Subject: [PATCH] Cron (view, pause, remove, resume tasks) --- lib/store.ts | 49 +++++ main.ts | 121 ++++++++++++- src-tauri/capabilities/default.json | 1 + src/main.tsx | 5 + src/pages/Cron.tsx | 267 ++++++++++++++++++++++++++++ 5 files changed, 441 insertions(+), 2 deletions(-) create mode 100644 src/pages/Cron.tsx diff --git a/lib/store.ts b/lib/store.ts index 2a9face..d3eebd4 100644 --- a/lib/store.ts +++ b/lib/store.ts @@ -3,6 +3,7 @@ import { shared } from 'use-broadcast-ts' import { create } from 'zustand' import { type StateStorage, createJSONStorage, persist } from 'zustand/middleware' import type { ConfigFile } from '../types/config' +import type { ScheduledTask } from '../types/task' // const { LazyStore } = window.__TAURI__.store const store = new LazyStore('store.json') @@ -61,6 +62,16 @@ interface PersistedState { isFirstOpen: boolean setIsFirstOpen: (isFirstOpen: boolean) => void + scheduledTasks: ScheduledTask[] + addScheduledTask: ( + task: Omit< + ScheduledTask, + 'id' | 'isRunning' | 'currentRunId' | 'lastRun' | 'configId' | 'isEnabled' + > + ) => void + removeScheduledTask: (id: string) => void + updateScheduledTask: (id: string, task: Partial) => void + configFiles: ConfigFile[] addConfigFile: (configFile: ConfigFile) => void removeConfigFile: (id: string) => void @@ -146,6 +157,44 @@ export const usePersistedStore = create()( isFirstOpen: true, setIsFirstOpen: (isFirstOpen: boolean) => set((_) => ({ isFirstOpen })), + scheduledTasks: [], + addScheduledTask: ( + task: Omit< + ScheduledTask, + 'id' | 'isRunning' | 'currentRunId' | 'lastRun' | 'configId' | 'isEnabled' + > + ) => { + const state = usePersistedStore.getState() + const configId = state.activeConfigFile?.id + + if (!configId) { + throw new Error('No active config file') + } + + set((state) => ({ + scheduledTasks: [ + ...state.scheduledTasks, + { + ...task, + id: crypto.randomUUID(), + isRunning: false, + isEnabled: true, + configId, + }, + ], + })) + }, + removeScheduledTask: (id: string) => + set((state) => ({ + scheduledTasks: state.scheduledTasks.filter((t) => t.id !== id), + })), + updateScheduledTask: (id: string, task: Partial) => + set((state) => ({ + scheduledTasks: state.scheduledTasks.map((t) => + t.id === id ? { ...t, ...task } : t + ), + })), + configFiles: [], addConfigFile: (configFile: ConfigFile) => set((state) => ({ diff --git a/main.ts b/main.ts index df4dac9..39c1390 100644 --- a/main.ts +++ b/main.ts @@ -3,12 +3,13 @@ import { ask, message } from '@tauri-apps/plugin-dialog' import { debug, error, info, trace, warn } from '@tauri-apps/plugin-log' import { platform } from '@tauri-apps/plugin-os' import { exit } from '@tauri-apps/plugin-process' -import type { Command } from '@tauri-apps/plugin-shell' +import { CronExpressionParser } from 'cron-parser' import { validateLicense } from './lib/license' -import { listRemotes, mountRemote, unmountAllRemotes } from './lib/rclone/api' +import { listRemotes, mountRemote, startCopy, startSync, unmountAllRemotes } from './lib/rclone/api' import { initRclone } from './lib/rclone/init' import { usePersistedStore, useStore } from './lib/store' import { initLoadingTray, initTray, rebuildTrayMenu } from './lib/tray' +import type { ScheduledTask } from './types/task' // forward console logs in webviews to the tauri logger, so they show up in terminal function forwardConsole( @@ -226,6 +227,121 @@ async function onboardUser() { usePersistedStore.setState({ isFirstOpen: false }) } } + +const MAX_INT_MS = 2_147_483_647 +let hasScheduledTasks = false +async function resumeTasks() { + console.log('resuming tasks') + + if (hasScheduledTasks) { + return + } + + const scheduledTasks = usePersistedStore.getState().scheduledTasks + const activeConfigId = usePersistedStore.getState().activeConfigFile?.id + + if (!activeConfigId) { + return + } + + for (const task of scheduledTasks) { + if (task.isRunning) { + usePersistedStore.getState().updateScheduledTask(task.id, { + isRunning: false, + currentRunId: undefined, + error: 'Task closed prematurely', + }) + continue + } + + if (task.configId !== activeConfigId) { + continue + } + + try { + const cronInterval = CronExpressionParser.parse(task.cron) + const nextRun = cronInterval.next().toDate() + const difference = nextRun.getTime() - Date.now() + + if (difference <= MAX_INT_MS && difference > 0) { + setTimeout(() => { + console.log('running task', task) + handleTask(task) + }, difference) + console.log('scheduled task', task.type, task.id, nextRun) + } + } catch (error) { + console.error('Error scheduling task:', error) + } + } + + hasScheduledTasks = true +} + +async function handleTask(task: ScheduledTask) { + const currentTask = usePersistedStore.getState().scheduledTasks.find((t) => t.id === task.id) + + if (!currentTask) { + return + } + + if (currentTask.isRunning) { + return + } + + const freshRunId = crypto.randomUUID() + + usePersistedStore.getState().updateScheduledTask(task.id, { + isRunning: true, + currentRunId: freshRunId, + lastRun: new Date().toISOString(), + }) + + console.log('running task', task.type, task.id) + + const currentRunId = usePersistedStore + .getState() + .scheduledTasks.find((t) => t.id === task.id)?.currentRunId + + if (currentRunId !== freshRunId) { + return + } + + try { + const { srcFs, dstFs, _config, _filter } = task.args + + switch (task.type) { + case 'delete': + break + case 'copy': + await startCopy({ + srcFs, + dstFs, + _config, + _filter, + }) + break + case 'sync': + await startSync({ + srcFs, + dstFs, + _config, + _filter, + }) + break + default: + break + } + } catch (err) { + console.error('Failed to start task:', err) + usePersistedStore.getState().updateScheduledTask(task.id, { + isRunning: false, + currentRunId: undefined, + error: err instanceof Error ? err.message : 'Unknown error', + }) + } +} + getCurrentWindow().listen('tauri://close-requested', async (e) => { console.log('(main) window close requested') }) @@ -253,5 +369,6 @@ initLoadingTray() .then(() => startRclone()) .then(() => onboardUser()) .then(() => startupMounts()) + .then(() => resumeTasks()) .then(() => initTray()) .catch(console.error) diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 4a0a4fc..8ea872c 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -9,6 +9,7 @@ "Mount", "Settings", "Jobs", + "Cron", "Browse", "Test", "Test2", diff --git a/src/main.tsx b/src/main.tsx index a8b66cc..b0f64e8 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -6,6 +6,7 @@ import './global.css' import { HeroUIProvider } from '@heroui/react' import { debug, error, info, trace, warn } from '@tauri-apps/plugin-log' import Copy from './pages/Copy' +import Cron from './pages/Cron' import Jobs from './pages/Jobs' import Mount from './pages/Mount' import Settings from './pages/Settings' @@ -56,6 +57,10 @@ const router = createBrowserRouter([ path: '/jobs', element: , }, + { + path: '/cron', + element: , + }, { path: '/test', element: , diff --git a/src/pages/Cron.tsx b/src/pages/Cron.tsx new file mode 100644 index 0000000..7faa567 --- /dev/null +++ b/src/pages/Cron.tsx @@ -0,0 +1,267 @@ +import { Card, CardBody, CardHeader, Tooltip } from '@heroui/react' +import { Button, Chip } from '@heroui/react' +import { ask } from '@tauri-apps/plugin-dialog' +import CronExpressionParser from 'cron-parser' +import cronstrue from 'cronstrue' +import { formatDistance } from 'date-fns' +import { AlertCircleIcon, Clock7Icon, PauseIcon, PlayIcon, Trash2Icon } from 'lucide-react' +import { useMemo, useState } from 'react' +import { buildReadablePath } from '../../lib/format' +import { usePersistedStore } from '../../lib/store' +import { openWindow } from '../../lib/window' +import type { ScheduledTask } from '../../types/task' + +export default function Cron() { + const scheduledTasks = usePersistedStore((state) => state.scheduledTasks) + + if (scheduledTasks.length === 0) { + return ( +
+

No scheduled tasks found

+
+ + +
+
+ ) + } + + return ( +
+ {scheduledTasks.map((task) => ( + + ))} +
+ ) +} + +function TaskCard({ task }: { task: ScheduledTask }) { + const [isBusy, setIsBusy] = useState(false) + + const removeScheduledTask = usePersistedStore((state) => state.removeScheduledTask) + const updateScheduledTask = usePersistedStore((state) => state.updateScheduledTask) + + const nextRun = useMemo(() => { + const parsed = CronExpressionParser.parse(task.cron) + if (!parsed.hasNext()) { + return null + } + return parsed.next().toDate() + }, [task.cron]) + + const lastRunLabel = useMemo(() => { + if (task.isRunning) { + return 'Running now' + } + if (task.lastRun) { + const distance = formatDistance(new Date(task.lastRun), new Date(), { + addSuffix: true, + }) + return distance.charAt(0).toUpperCase() + distance.slice(1) + } + return 'Never' + }, [task.isRunning, task.lastRun]) + + const nextRunLabel = useMemo(() => { + if (!nextRun) { + return 'Never' + } + + const distance = formatDistance(nextRun, new Date(), { + addSuffix: true, + }) + + return distance.charAt(0).toUpperCase() + distance.slice(1) + }, [nextRun]) + + return ( + + +
+
+ + {task.type.toUpperCase()} + +
+
+ {buildReadablePath(task.args.srcFs, 'short')} +
+
+ {buildReadablePath(task.args.dstFs)} +
+
+
+
+
+ + + {lastRunLabel} + + +

Last run

+
+
+ + + {nextRunLabel} + + +

Next run

+
+
+
+ + +
+
+
+ +
+ {task.error ? ( + <> + +

{task.error}

+ + ) : ( + <> + +

+ {cronstrue.toString(task.cron)}. +

+ + )} +
+
+
+ ) +}