From 2f98e8b7da8fa4f36b53aecf9afe3bd429c53c3c Mon Sep 17 00:00:00 2001 From: FTCHD <144691102+FTCHD@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:30:56 +0300 Subject: [PATCH] notifications --- lib/notifications.ts | 34 +- lib/notify.ts | 27 - lib/rclone/api.ts | 724 ++++---------------- lib/rclone/init.ts | 7 +- main.ts | 35 + src/components/JobDetailsDrawer.tsx | 10 +- src/components/NotificationTargetDrawer.tsx | 419 +++++++++++ src/components/icons/ProviderIcon.tsx | 48 ++ src/pages/Commander.tsx | 2 +- src/pages/Download.tsx | 2 +- src/pages/Settings/GeneralSection.tsx | 2 +- src/pages/Settings/NotificationsSection.tsx | 349 ++++++++++ src/pages/Settings/index.tsx | 15 + store/persisted.ts | 7 +- toolbar/actions.ts | 2 +- 15 files changed, 1061 insertions(+), 622 deletions(-) delete mode 100644 lib/notify.ts create mode 100644 src/components/NotificationTargetDrawer.tsx create mode 100644 src/components/icons/ProviderIcon.tsx create mode 100644 src/pages/Settings/NotificationsSection.tsx diff --git a/lib/notifications.ts b/lib/notifications.ts index 8f49a87..18c2c8f 100644 --- a/lib/notifications.ts +++ b/lib/notifications.ts @@ -206,7 +206,7 @@ export const NOTIFICATION_PROVIDERS: Record< label: 'Telegram', titleLabel: 'Telegram Bot', description: 'Message a chat via your bot', - urlPlaceholder: 'https://api.telegram.org/bot123456:ABC-DEF.../sendMessage', + urlPlaceholder: 'https://api.telegram.org/bot123456:ABC-DEF...', accentClass: 'text-sky-500', }, webhook: { @@ -223,7 +223,14 @@ 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. +// This is the STORED shape — the form collects only the pure bot URL (below) and the +// /sendMessage method is appended by buildTelegramUrl. const RE_TELEGRAM_SEND_MESSAGE = /^https:\/\/api\.telegram\.org\/bot\d+:[\w-]+\/sendMessage(\?.*)?$/ +// What the form accepts: the pure bot URL. A pasted full endpoint or trailing slash is +// tolerated (normalized away when the URL is built) rather than rejected. +const RE_TELEGRAM_BOT_URL = /^https:\/\/api\.telegram\.org\/bot\d+:[\w-]+(\/sendMessage)?\/?$/ +const RE_TELEGRAM_SEND_MESSAGE_SUFFIX = /\/sendMessage$/ +const RE_TRAILING_SLASHES = /\/+$/ export function validateWebhookUrl(provider: NotificationProvider, url: string): string | null { const trimmed = url.trim() @@ -278,14 +285,19 @@ 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. + * The Telegram form collects the pure bot URL and the chat id separately; the /sendMessage + * method and the chat_id are both OURS to add — 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())}` + const base = parsed + .toString() + .replace(RE_TRAILING_SLASHES, '') + .replace(RE_TELEGRAM_SEND_MESSAGE_SUFFIX, '') + return `${base}/sendMessage?chat_id=${encodeURIComponent(chatId.trim())}` } /** Inverse of buildTelegramUrl, for seeding the edit form from a stored URL. */ @@ -294,13 +306,17 @@ export function splitTelegramUrl(url: string): { baseUrl: string; chatId: string const parsed = new URL(url) const chatId = parsed.searchParams.get('chat_id') ?? '' parsed.search = '' - return { baseUrl: parsed.toString(), chatId } + const baseUrl = parsed + .toString() + .replace(RE_TRAILING_SLASHES, '') + .replace(RE_TELEGRAM_SEND_MESSAGE_SUFFIX, '') + return { baseUrl, chatId } } catch { return { baseUrl: url, chatId: '' } } } -/** Validates the drawer's Telegram URL field: base sendMessage endpoint, no query params. */ +/** Validates the drawer's Telegram URL field: the pure bot URL, no query params. */ export function validateTelegramBotUrl(url: string): string | null { const trimmed = url.trim() if (!trimmed) { @@ -309,8 +325,8 @@ export function validateTelegramBotUrl(url: string): string | null { 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" + if (!RE_TELEGRAM_BOT_URL.test(trimmed)) { + return "This doesn't look like a Telegram Bot API URL — expected https://api.telegram.org/bot" } return null } diff --git a/lib/notify.ts b/lib/notify.ts deleted file mode 100644 index b047fc6..0000000 --- a/lib/notify.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { message } from '@tauri-apps/plugin-dialog' -import { - isPermissionGranted, - requestPermission, - sendNotification, -} from '@tauri-apps/plugin-notification' - -export default 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', - }) - } -} diff --git a/lib/rclone/api.ts b/lib/rclone/api.ts index 3d71d67..3538d65 100644 --- a/lib/rclone/api.ts +++ b/lib/rclone/api.ts @@ -3,31 +3,53 @@ import { message } from '@tauri-apps/plugin-dialog' import { platform } from '@tauri-apps/plugin-os' import pRetry from 'p-retry' import { selectActiveConfigFile, useHostStore } from '../../store/host' -import { useStore } from '../../store/memory' +import { type WatchedJob, useStore } from '../../store/memory' import type { JobItem } from '../../types/jobs' import type { FlagValue } from '../../types/rclone' import { UserCancelledError, formatErrorMessage } from '../errors' import { getFsInfo } from '../format' +import { dispatchNotification } from '../notifications' import { restartActiveRclone, runRcloneCli } from './cli' import rclone, { rcloneAsync } from './client' import { parseRcloneOptions } from './common' +import { + type BisyncArgs, + type CopyArgs, + type DeleteArgs, + type MoveArgs, + type PurgeArgs, + type SyncArgs, + buildBisyncRequests, + buildCopyRequests, + buildDeleteRequests, + buildMoveRequests, + buildPurgeRequests, + buildSyncRequests, + serializeOptions, +} from './requests' const RE_BACKSLASH = /\\/g const RE_PATH_SEPARATOR = /[/\\]/ const RE_WINDOWS_EXTENDED_PATH = /(\/\/\?\/|\\\\\?\\)/ -const RE_WINDOWS_DRIVE_ROOT = /^:local:[a-zA-Z]:\/$/ const RE_WINDOWS_DRIVE_LETTER = /^[a-zA-Z]:$/ const RETRY_OPTIONS = { retries: 3, shouldRetry: ({ error }: { error: unknown }) => !(error instanceof UserCancelledError), } + +// Non-zero while a dry run is in flight. The start* functions capture this at submission time +// so a dry-run job is never registered with the watcher — checking it at registration time +// instead would wrongly suppress a real job that overlaps a concurrent dry run. +let dryRunDepth = 0 + export async function startDryRun(operation: () => Promise): Promise { await rclone('/options/set', { body: { main: { DryRun: true }, }, }) + dryRunDepth++ try { const result = await operation() if (typeof result === 'number') { @@ -37,6 +59,7 @@ export async function startDryRun(operation: () => Promise): Promise { } return result } finally { + dryRunDepth-- await rclone('/options/set', { body: { main: { DryRun: false }, @@ -45,71 +68,24 @@ export async function startDryRun(operation: () => Promise): Promise { } } -function serializeOptions( - remotePath: string, - options: { - remote?: Record - global?: Record - } +// Makes a freshly submitted job visible to the main window's job watcher (via the shared +// broadcast store), which emits the job started/completed/failed notifications. +// Called BEFORE the launch verification so jobs that fail within the first second still get +// a failure notification from the watcher. +function registerWatchedJob( + jobid: number, + job: Pick ) { - 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 + useStore.setState((state) => ({ + watchedJobs: { + ...state.watchedJobs, + [jobid]: { + ...job, + jobid, + startedAt: Date.now(), + }, + }, + })) } async function hasStat(path: string) { @@ -128,263 +104,50 @@ async function hasStat(path: string) { return !!r?.item } -export async function startCopy({ - sources, - destination, - options, -}: { - sources: string[] - destination: string - options: { - copy?: Record - config?: Record - filter?: Record - remotes?: Record> - } -}) { +export async function startCopy(args: CopyArgs) { console.log('[startCopy] starting', { - sources, - destination, - optionKeys: Object.keys(options), + sources: args.sources, + destination: args.destination, + optionKeys: Object.keys(args.options), }) - for (const source of sources) { + for (const source of args.sources) { const sourceExists = await hasStat(source) if (!sourceExists) { throw new Error(`Source does not exist, ${source} is missing`) } } - if ( - sources.length > 1 && - options.filter && - ('include' in options.filter || 'include_from' in options.filter) - ) { - throw new Error('Include rules are not supported with multiple sources') - } - - const mergedOptions = { - ...(options.config || {}), - ...(options.copy || {}), - ...(options.filter || {}), - } - - const pendingJobs: Parameters[0] = [] - const handledSourcePaths: Record = {} - const folderSources = sources.filter((path) => path.endsWith('/') || path.endsWith('\\')) - - console.log('[Copy] ======DST INFO====== ', destination, ' ====================') - const { - root: dstRoot, - dirPath: dstDirPath, - fullDirPath: dstFullDirPath, - remoteName: dstRemoteName, - } = getFsInfo(destination) - - console.log('[Copy] ======DST INFO====== ', destination, ' ====================') - - const dstOptions = - options.remotes && dstRemoteName && dstRemoteName in options.remotes - ? options.remotes[dstRemoteName] - : undefined - - for (const source of sources) { - console.log('[Copy] ======START====== ', source, ' ====================') - if (handledSourcePaths[source]) { - console.log('[Copy] skipping because source is already handled', source) - continue - } - - handledSourcePaths[source] = true - - console.log('[Copy] ======SRC INFO====== ', source, ' ====================') - - const { - root: srcRoot, - filePath: srcFilePath, - fullDirPath: srcFullDirPath, - type: srcType, - name: srcName, - remoteName: srcRemoteName, - } = getFsInfo(source) - - console.log('[Copy] ======SRC INFO====== ', source, ' ====================') - - const srcOptions = - options.remotes && srcRemoteName && srcRemoteName in options.remotes - ? options.remotes[srcRemoteName] - : undefined - - if (srcType === 'folder') { - const jobParams: Parameters[0][number] = { - _path: 'sync/copy', - srcFs: serializeOptions(srcFullDirPath, { - remote: srcOptions, - global: mergedOptions, - }), - dstFs: serializeOptions(`${dstFullDirPath}${srcName}`, { - remote: dstOptions, - }), - createEmptySrcDirs: true, - } - - pendingJobs.push(jobParams) - continue - } - - if (folderSources.some((folder) => source.startsWith(folder))) { - console.log( - '[Copy] skipping because source or parent folder is already handled', - source - ) - continue - } - - console.log('[Copy] ', source, 'srcRoot', srcRoot, srcFilePath) - console.log('[Copy] ', destination, 'dstRoot', dstRoot, dstDirPath) - - const jobParams: Parameters[0][number] = { - _path: 'operations/copyfile', - srcFs: serializeOptions(srcRoot, { - remote: srcOptions, - global: mergedOptions, - }), - srcRemote: srcFilePath, - dstFs: serializeOptions(dstRoot, { - remote: dstOptions, - }), - dstRemote: `${dstDirPath === '/' ? '' : dstDirPath}${srcName}`, - } - - pendingJobs.push(jobParams) - } - - console.log('[startCopy] submitting batch', { jobCount: pendingJobs.length }) - return startBatch(pendingJobs) + const [request] = buildCopyRequests(args) + console.log('[startCopy] submitting batch', { jobCount: request.body.inputs.length }) + return startBatch(request.body.inputs, { + operation: 'copy', + sources: args.sources, + destination: args.destination, + }) } -export async function startMove({ - sources, - destination, - options, -}: { - sources: string[] - destination: string - options: { - move?: Record - config?: Record - filter?: Record - remotes?: Record> - } -}) { +export async function startMove(args: MoveArgs) { console.log('[startMove] starting', { - sources, - destination, - optionKeys: Object.keys(options), + sources: args.sources, + destination: args.destination, + optionKeys: Object.keys(args.options), }) - for (const source of sources) { + for (const source of args.sources) { const sourceExists = await hasStat(source) if (!sourceExists) { throw new Error(`Source does not exist, ${source} is missing`) } } - if ( - sources.length > 1 && - options.filter && - ('include' in options.filter || 'include_from' in options.filter) - ) { - throw new Error('Include rules are not supported with multiple sources') - } - - const mergedOptions = { - ...(options.config || {}), - ...(options.move || {}), - ...(options.filter || {}), - } - - const pendingJobs: Parameters[0] = [] - 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 = - options.remotes && dstRemoteName && dstRemoteName in options.remotes - ? options.remotes[dstRemoteName] - : undefined - - for (const source of sources) { - if (handledSourcePaths[source]) { - console.log('[Move] skipping because source is already handled', source) - continue - } - - handledSourcePaths[source] = true - - const { - root: srcRoot, - filePath: srcFilePath, - fullDirPath: srcFullDirPath, - type: srcType, - name: srcName, - remoteName: srcRemoteName, - } = getFsInfo(source) - - const srcOptions = - options.remotes && srcRemoteName && srcRemoteName in options.remotes - ? options.remotes[srcRemoteName] - : undefined - - if (srcType === 'folder') { - const jobParams: Parameters[0][number] = { - _path: 'sync/move', - srcFs: serializeOptions(srcFullDirPath, { - remote: srcOptions, - global: mergedOptions, - }), - dstFs: serializeOptions(`${dstFullDirPath}${srcName}`, { - remote: dstOptions, - }), - createEmptySrcDirs: true, - } - - pendingJobs.push(jobParams) - continue - } - - if (folderSources.some((folder) => source.startsWith(folder))) { - console.log( - '[Move] skipping because source or parent folder is already handled', - source - ) - continue - } - - const jobParams: Parameters[0][number] = { - _path: 'operations/movefile', - srcFs: serializeOptions(srcRoot, { - remote: srcOptions, - global: mergedOptions, - }), - srcRemote: srcFilePath, - dstFs: serializeOptions(dstRoot, { - remote: dstOptions, - }), - dstRemote: `${dstDirPath === '/' ? '' : dstDirPath}${srcName}`, - } - - pendingJobs.push(jobParams) - } - - console.log('[startMove] submitting batch', { jobCount: pendingJobs.length }) - return startBatch(pendingJobs) + const [request] = buildMoveRequests(args) + console.log('[startMove] submitting batch', { jobCount: request.body.inputs.length }) + return startBatch(request.body.inputs, { + operation: 'move', + sources: args.sources, + destination: args.destination, + }) } /* JOBS */ @@ -589,7 +352,26 @@ export async function listTransfers() { } /* OPERATIONS */ -export async function startMount({ +// Wraps the mount flow so every caller (Mount page, tray, startup automounts) emits the +// mount.failed webhook event without per-site wiring. Rethrows for the caller's own handling. +export async function startMount(params: Parameters[0]) { + try { + return await startMountInner(params) + } catch (error) { + dispatchNotification('mount.failed', { + title: 'Mount failed', + body: `Failed to mount ${params.source}: ${formatErrorMessage(error, 'Unknown error')}`, + data: { + source: params.source, + destination: params.destination, + error: formatErrorMessage(error, String(error)), + }, + }) + throw error + } +} + +async function startMountInner({ source, destination, options, @@ -793,66 +575,24 @@ export async function startMount({ ) } -export async function startBisync({ - source, - destination, - options, -}: { - source: string - destination: string - options: { - config?: Record - bisync?: Record - filter?: Record - remotes?: Record> - outer?: Record - } -}) { - const sourceExists = await hasStat(source) - if (!sourceExists) { - throw new Error(`Source does not exist, ${source} is missing`) - } +// Shared submission path for the async query endpoints (/sync/sync, /sync/bisync): submit, +// register with the watcher, verify the launch didn't fail within the first second. +async function submitAsyncQuery( + endpoint: '/sync/sync' | '/sync/bisync', + body: Record, + watch: Pick +) { + // The builders emit body-form requests for the headless runner; the live client submits the + // same parameters as a query (rclone's RC treats them identically). + const { _async, ...query } = body - const mergedOptions = { - ...(options.config || {}), - ...(options.bisync || {}), - ...(options.filter || {}), - } - - const { fullDirPath: srcFullDirPath, remoteName: srcRemoteName } = getFsInfo(source) - const { fullDirPath: dstFullDirPath, remoteName: dstRemoteName } = getFsInfo(destination) - - const srcOptions = - options.remotes && srcRemoteName && srcRemoteName in options.remotes - ? options.remotes[srcRemoteName] - : undefined - - const dstOptions = - options.remotes && dstRemoteName && dstRemoteName in options.remotes - ? options.remotes[dstRemoteName] - : undefined + const submittedDuringDryRun = dryRunDepth > 0 const r = await pRetry( async () => - await rcloneAsync('/sync/bisync', { + await rcloneAsync(endpoint, { params: { - query: { - path1: serializeOptions(srcFullDirPath, { - global: mergedOptions, - remote: srcOptions, - }), - path2: serializeOptions(dstFullDirPath, { - remote: dstOptions, - }), - ...(options.outer && Object.keys(options.outer).length > 0 - ? Object.fromEntries( - Object.entries(options.outer).map(([key, value]) => [ - key, - Array.isArray(value) ? value.join(',') : value, - ]) - ) - : {}), - }, + query: query as any, }, }), RETRY_OPTIONS @@ -863,6 +603,10 @@ export async function startBisync({ throw new Error('Failed to start operation') } + if (!submittedDuringDryRun) { + registerWatchedJob(r.jobid, watch) + } + await new Promise((resolve) => setTimeout(resolve, 1000)) const jobStatus = await pRetry( @@ -892,112 +636,35 @@ export async function startBisync({ return r.jobid } -export async function startSync({ - source, - destination, - options, -}: { - source: string - destination: string - options: { - config?: Record - sync?: Record - filter?: Record - remotes?: Record> - } -}) { - const sourceExists = await hasStat(source) +export async function startBisync(args: BisyncArgs) { + const sourceExists = await hasStat(args.source) if (!sourceExists) { - throw new Error(`Source does not exist, ${source} is missing`) + throw new Error(`Source does not exist, ${args.source} is missing`) } - const mergedOptions = { - ...(options.config || {}), - ...(options.sync || {}), - ...(options.filter || {}), - } - - const { fullDirPath: srcFullDirPath, remoteName: srcRemoteName } = getFsInfo(source) - const { fullDirPath: dstFullDirPath, remoteName: dstRemoteName } = getFsInfo(destination) - - const srcOptions = - options.remotes && srcRemoteName && srcRemoteName in options.remotes - ? options.remotes[srcRemoteName] - : undefined - - const dstOptions = - options.remotes && dstRemoteName && dstRemoteName in options.remotes - ? options.remotes[dstRemoteName] - : undefined - - const r = await pRetry( - async () => - await rcloneAsync('/sync/sync', { - params: { - query: { - srcFs: serializeOptions(srcFullDirPath, { - global: mergedOptions, - remote: srcOptions, - }), - dstFs: serializeOptions(dstFullDirPath, { - remote: dstOptions, - }), - createEmptySrcDirs: true, - }, - }, - }), - { - retries: 3, - } - ) - - if (!r?.jobid) { - console.error('Failed to start job: missing jobid', r) - throw new Error('Failed to start operation') - } - - await new Promise((resolve) => setTimeout(resolve, 1000)) - - const jobStatus = await pRetry( - async () => - await rclone('/job/status', { - params: { - query: { - jobid: r.jobid, - }, - }, - }), - { - retries: 3, - } - ).catch(() => null) - - console.log('jobStatus', JSON.stringify(jobStatus, null, 2)) - - if (!jobStatus) { - console.error('Failed to start job:', r.jobid) - throw new Error('Failed to start operation') - } - - if (jobStatus.error) { - console.error('Failed to start job:', r.jobid, jobStatus.error) - throw new Error(jobStatus.error) - } - - return r.jobid + const [request] = buildBisyncRequests(args) + return submitAsyncQuery('/sync/bisync', request.body, { + operation: 'bisync', + sources: [args.source], + destination: args.destination, + }) } -export async function startDelete({ - sources, - options, -}: { - sources: string[] - options: { - filter?: Record - config?: Record - remotes?: Record> +export async function startSync(args: SyncArgs) { + const sourceExists = await hasStat(args.source) + if (!sourceExists) { + throw new Error(`Source does not exist, ${args.source} is missing`) } -}) { + + const [request] = buildSyncRequests(args) + return submitAsyncQuery('/sync/sync', request.body, { + operation: 'sync', + sources: [args.source], + destination: args.destination, + }) +} + +export async function startDelete({ sources, options }: DeleteArgs) { for (const source of sources) { const sourceExists = await hasStat(source) if (!sourceExists) { @@ -1005,87 +672,11 @@ export async function startDelete({ } } - if ( - sources.length > 1 && - options.filter && - ('include' in options.filter || 'include_from' in options.filter) - ) { - throw new Error('Include rules are not supported with multiple sources') - } - - const mergedOptions = { - ...(options.config || {}), - ...(options.filter || {}), - } - - const pendingJobs: Parameters[0] = [] - const handledSourcePaths: Record = {} - const folderSources = sources.filter((path) => path.endsWith('/') || path.endsWith('\\')) - - for (const source of sources) { - if (handledSourcePaths[source]) { - console.log('[Delete] skipping because source is already handled', source) - continue - } - - handledSourcePaths[source] = true - - const { - root: srcRoot, - filePath: srcFilePath, - type: srcType, - remoteName: srcRemoteName, - } = getFsInfo(source) - - const srcOptions = - options.remotes && srcRemoteName && srcRemoteName in options.remotes - ? options.remotes[srcRemoteName] - : undefined - - if (srcType === 'folder') { - const jobParams: Parameters[0][number] = { - _path: 'operations/delete', - fs: serializeOptions(source, { - global: mergedOptions, - remote: srcOptions, - }), - } - pendingJobs.push(jobParams) - continue - } - - if (folderSources.some((folder) => source.startsWith(folder))) { - console.log( - '[Delete] skipping because source or parent folder is already handled', - source - ) - continue - } - - const jobParams: Parameters[0][number] = { - _path: 'operations/deletefile', - fs: serializeOptions(srcRoot, { - global: mergedOptions, - remote: srcOptions, - }), - remote: srcFilePath, - } - pendingJobs.push(jobParams) - } - - return startBatch(pendingJobs) + const [request] = buildDeleteRequests({ sources, options }) + return startBatch(request.body.inputs, { operation: 'delete', sources }) } -export async function startPurge({ - sources, - options, -}: { - sources: string[] - options: { - config?: Record - remotes?: Record> - } -}) { +export async function startPurge({ sources, options }: PurgeArgs) { for (const source of sources) { const sourceExists = await hasStat(source) if (!sourceExists) { @@ -1093,45 +684,8 @@ export async function startPurge({ } } - const pendingJobs: Parameters[0] = [] - const handledSourcePaths: Record = {} - - for (const source of sources) { - if (handledSourcePaths[source]) { - console.log('[Purge] skipping because source is already handled', 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') - } - - const srcOptions = - options.remotes && srcRemoteName && srcRemoteName in options.remotes - ? options.remotes[srcRemoteName] - : undefined - - const jobParams: Parameters[0][number] = { - _path: 'operations/purge', - fs: serializeOptions(srcRoot, { - global: options.config, - remote: srcOptions, - }), - remote: srcDirPath, - } - pendingJobs.push(jobParams) - } - - return startBatch(pendingJobs) + const [request] = buildPurgeRequests({ sources, options }) + return startBatch(request.body.inputs, { operation: 'purge', sources }) } export async function startServe({ @@ -1175,13 +729,18 @@ export async function startServe({ }) } -export async function startBatch(inputs: ({ _path: string } & Record)[]) { +export async function startBatch( + inputs: ({ _path: string } & Record)[], + meta?: Partial> +) { console.log('[startBatch] starting batch operation', { inputCount: inputs.length, paths: inputs.map((i) => i._path), }) console.log('[startBatch] inputs', JSON.stringify(inputs, null, 2)) + const submittedDuringDryRun = dryRunDepth > 0 + const r = await pRetry( async () => await rclone('/job/batch', { @@ -1195,6 +754,14 @@ export async function startBatch(inputs: ({ _path: string } & Record setTimeout(resolve, 1000)) const jobStatus = await pRetry( @@ -1253,6 +820,7 @@ export async function startBatch(inputs: ({ _path: string } & Record { if (!persisted.autoUpdateRclone) { if (persisted.lastNotifiedRcloneVersion !== latest) { usePersistedStore.setState({ lastNotifiedRcloneVersion: latest }) + dispatchNotification('rclone.update-available', { + title: 'Rclone update available', + body: `rclone v${latest} is available. You can update from Settings → Binary.`, + data: { currentVersion: active.version, latestVersion: latest }, + }) await notify({ title: 'Rclone update available', body: `rclone v${latest} is available. You can update from Settings → Binary.`, diff --git a/main.ts b/main.ts index 2a7e737..8d22aa1 100644 --- a/main.ts +++ b/main.ts @@ -14,6 +14,12 @@ 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 { + clearWatchedJobs, + dispatchNotification, + initJobWatcher, + reconcileNotificationTargets, +} from './lib/notifications' import queryClient from './lib/query' import { listTransfers, startMount } from './lib/rclone/api' import rcloneClient from './lib/rclone/client' @@ -381,6 +387,9 @@ async function registerRcloneWindowListeners() { try { await killRcloneDaemon() + // Jobids do not survive a daemon restart — polling them would only 404. + clearWatchedJobs() + await startRclone() } catch (error) { console.error('[restart-rclone] failed to restart rclone', error) @@ -449,6 +458,19 @@ async function startRclone() { return } + // Awaited: the Windows branch below exits the app, so webhook delivery must finish + // first — but capped so an unreachable endpoint can't stall crash recovery. + // dispatchNotification never throws. Watched jobids died with the daemon. + await Promise.race([ + dispatchNotification('rclone.crashed', { + title: 'Rclone daemon crashed', + body: `rclone exited unexpectedly${payload.code !== null ? ` (code ${payload.code})` : ''}`, + data: { exitCode: payload.code }, + }), + new Promise((resolve) => setTimeout(resolve, 20_000)), + ]) + clearWatchedJobs() + if (platform() === 'windows') { return await exit(0) } @@ -718,6 +740,17 @@ async function checkVersion() { return } + dispatchNotification('app.update-available', { + title: 'Rclone UI update available', + body: `Version ${receivedUpdate.version} is available (current: ${currentVersion})`, + data: { + currentVersion, + latestVersion: receivedUpdate.version, + minimumVersion, + okVersion, + }, + }) + if (compareVersions(currentVersion, minimumVersion) < 0) { console.log('[checkVersion] currentVersion is outdated') await installUpdate(receivedUpdate, true) @@ -826,6 +859,8 @@ waitForHydration() .then(() => checkAlreadyRunning()) .then(() => startRclone()) .then(() => checkRclone()) + .then(() => reconcileNotificationTargets()) + .then(() => initJobWatcher()) .then(() => handleDeepLink()) .then(() => showStartup()) .then(() => startupMounts()) diff --git a/src/components/JobDetailsDrawer.tsx b/src/components/JobDetailsDrawer.tsx index 5a6988f..af297cb 100644 --- a/src/components/JobDetailsDrawer.tsx +++ b/src/components/JobDetailsDrawer.tsx @@ -17,9 +17,10 @@ import { platform } from '@tauri-apps/plugin-os' import { ExternalLinkIcon, RefreshCwIcon, SearchCheckIcon, SquareIcon } from 'lucide-react' import { useMemo, useState } from 'react' import { formatBytes } from '../../lib/format' -import notify from '../../lib/notify' +import { notify } from '../../lib/notifications' import { startBatch } from '../../lib/rclone/api' import rclone from '../../lib/rclone/client' +import { useStore } from '../../store/memory' import type { JobItem } from '../../types/jobs' export default function JobDetailsDrawer({ @@ -100,6 +101,13 @@ export default function JobDetailsDrawer({ const stopJobMutation = useMutation({ mutationFn: async (jobId: number) => { + // Un-watch before stopping: a stopped job finishes with an error, which would + // otherwise surface as a bogus "Transfer failed" webhook notification. + useStore.setState((state) => { + const watchedJobs = { ...state.watchedJobs } + delete watchedJobs[jobId] + return { watchedJobs } + }) await rclone('/job/stopgroup', { params: { query: { diff --git a/src/components/NotificationTargetDrawer.tsx b/src/components/NotificationTargetDrawer.tsx new file mode 100644 index 0000000..8c84446 --- /dev/null +++ b/src/components/NotificationTargetDrawer.tsx @@ -0,0 +1,419 @@ +import { + Button, + Checkbox, + CheckboxGroup, + Drawer, + DrawerBody, + DrawerContent, + DrawerFooter, + DrawerHeader, + Input, + Switch, + cn, +} from '@heroui/react' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { message } from '@tauri-apps/plugin-dialog' +import { platform } from '@tauri-apps/plugin-os' +import { useMemo, useState } from 'react' +import { + NOTIFICATION_PROVIDERS, + TELEGRAM_CHAT_ID_HELP, + addNotificationTarget, + buildTelegramUrl, + sendTestNotification, + splitTelegramUrl, + updateNotificationTarget, + validateTelegramBotUrl, + validateTelegramChatId, + validateWebhookUrl, +} from '../../lib/notifications' +import type { + NotificationCatalog, + NotificationEventId, + NotificationProvider, + NotificationTarget, +} from '../../types/notifications' +import ProviderIcon from './icons/ProviderIcon' + +// Single component for both add and edit — the forms are identical, only the header text, +// initial values, and the Rust command differ. State seeds from props at mount: the parent +// remounts this with a key per target/provider, and only renders it once the catalog query +// has data (the checkbox list derives from it). +export default function NotificationTargetDrawer({ + isOpen, + onClose, + provider, + target, + catalog, + existingTargets, +}: { + isOpen: boolean + onClose: () => void + provider: NotificationProvider + target?: NotificationTarget + catalog: NotificationCatalog + existingTargets: NotificationTarget[] +}) { + const providerMeta = NOTIFICATION_PROVIDERS[provider] + const isEditing = !!target + const isTelegram = provider === 'telegram' + + const queryClient = useQueryClient() + + const allEventIds = useMemo(() => catalog.events.map((event) => event.id), [catalog]) + + const [name, setName] = useState(target?.name ?? '') + // Telegram stores one merged URL (…/sendMessage?chat_id=…) but the form edits its two + // halves separately — the user never types query params by hand. + const [url, setUrl] = useState(() => + target && isTelegram ? splitTelegramUrl(target.url).baseUrl : (target?.url ?? '') + ) + const [chatId, setChatId] = useState(() => + target && isTelegram ? splitTelegramUrl(target.url).chatId : '' + ) + const [events, setEvents] = useState(target?.events ?? allEventIds) + const [isEnabled, setIsEnabled] = useState(target?.isEnabled ?? true) + const [urlTouched, setUrlTouched] = useState(false) + const [chatIdTouched, setChatIdTouched] = useState(false) + const [justTested, setJustTested] = useState(false) + + const urlError = useMemo(() => { + if (!urlTouched || !url.trim()) { + return null + } + return isTelegram ? validateTelegramBotUrl(url) : validateWebhookUrl(provider, url) + }, [provider, isTelegram, url, urlTouched]) + + const chatIdError = useMemo( + () => + isTelegram && chatIdTouched && chatId.trim() ? validateTelegramChatId(chatId) : null, + [isTelegram, chatId, chatIdTouched] + ) + + // The URL as it will be stored and POSTed — merged for Telegram, as typed otherwise. + const effectiveUrl = useMemo(() => { + if (!isTelegram) { + return url.trim() + } + if (validateTelegramBotUrl(url) || validateTelegramChatId(chatId)) { + return '' + } + return buildTelegramUrl(url, chatId) + }, [isTelegram, url, chatId]) + + const canSendTest = !!effectiveUrl && !validateWebhookUrl(provider, effectiveUrl) + + const isPlaintextUrl = provider === 'webhook' && url.trim().startsWith('http://') + + const allSelected = events.length === allEventIds.length + + const drawerTitle = `${isEditing ? 'Edit' : 'Add'} ${providerMeta.titleLabel}` + + const sendTestMutation = useMutation({ + mutationFn: async () => { + await sendTestNotification({ + provider, + url: effectiveUrl, + id: target?.id, + name: name.trim() || undefined, + }) + }, + onSuccess: () => { + setJustTested(true) + setTimeout(() => setJustTested(false), 2000) + }, + onError: async (error) => { + await message(error instanceof Error ? error.message : 'Unknown error occurred', { + title: 'Test failed', + kind: 'error', + }) + }, + // A saved target gets its outcome recorded in Rust — refresh the list's chips. + onSettled: () => queryClient.invalidateQueries({ queryKey: ['notifications', 'targets'] }), + }) + + const handleSave = async (close: () => void) => { + const trimmedName = name.trim() + + if (!trimmedName || !url.trim() || (isTelegram && !chatId.trim())) { + await message( + isTelegram + ? 'Name, bot URL and chat ID are required.' + : 'Name and webhook URL are required.', + { + title: 'Missing information', + kind: 'warning', + } + ) + return + } + + if (isTelegram) { + const fieldError = validateTelegramBotUrl(url) || validateTelegramChatId(chatId) + if (fieldError) { + await message(fieldError, { + title: 'Invalid Telegram configuration', + kind: 'warning', + }) + return + } + } + + const mergedUrl = isTelegram ? buildTelegramUrl(url, chatId) : url.trim() + + const validationError = validateWebhookUrl(provider, mergedUrl) + if (validationError) { + await message(validationError, { + title: 'Invalid webhook URL', + kind: 'warning', + }) + return + } + + // Early feedback from this window's snapshot — Rust re-checks race-safely on save. + const duplicateUrl = existingTargets.some( + (existing) => + existing.id !== target?.id && + existing.url.trim().toLowerCase() === mergedUrl.toLowerCase() + ) + if (duplicateUrl) { + await message('A webhook with this URL is already configured.', { + title: 'Duplicate webhook', + kind: 'warning', + }) + return + } + + if (events.length === 0) { + await message( + 'Select at least one event. To keep this webhook without notifications, use the Enabled switch instead.', + { + title: 'No events selected', + kind: 'warning', + } + ) + return + } + + try { + if (isEditing) { + await updateNotificationTarget(target.id, { + name: trimmedName, + url: mergedUrl, + events, + isEnabled, + }) + } else { + await addNotificationTarget({ + provider, + name: trimmedName, + url: mergedUrl, + events, + isEnabled, + }) + } + } catch (error) { + await message(error instanceof Error ? error.message : String(error), { + title: 'Save failed', + kind: 'error', + }) + return + } + + await queryClient.invalidateQueries({ queryKey: ['notifications', 'targets'] }) + close() + onClose() + } + + return ( + + + {(close) => ( + <> + + + {drawerTitle} + + +
+
+ + setUrlTouched(true)} + isRequired={true} + isInvalid={!!urlError} + errorMessage={urlError} + description={ + isPlaintextUrl + ? 'Unencrypted URL — the webhook payload will be sent in plaintext.' + : undefined + } + autoCapitalize="off" + autoComplete="off" + autoCorrect="off" + spellCheck="false" + type="url" + /> + {isTelegram && ( + setChatIdTouched(true)} + isRequired={true} + isInvalid={!!chatIdError} + errorMessage={chatIdError} + description={TELEGRAM_CHAT_ID_HELP} + autoCapitalize="off" + autoComplete="off" + autoCorrect="off" + spellCheck="false" + /> + )} +
+ +
+
+

+ Events +

+ +
+ + setEvents(value as NotificationEventId[]) + } + aria-label="Events that trigger this webhook" + > +
+ {catalog.categories.map((category) => ( +
+

+ {category.label} +

+ {catalog.events + .filter( + (event) => + event.category === category.id + ) + .map((event) => ( + +
+ + {event.label} + + + {event.description} + +
+
+ ))} +
+ ))} +
+
+
+ +
+ +
+ Enabled + + Deliver notifications to this webhook + +
+
+
+
+
+ + + + + + + )} +
+
+ ) +} diff --git a/src/components/icons/ProviderIcon.tsx b/src/components/icons/ProviderIcon.tsx new file mode 100644 index 0000000..e15891c --- /dev/null +++ b/src/components/icons/ProviderIcon.tsx @@ -0,0 +1,48 @@ +import { WebhookIcon } from 'lucide-react' +import type { NotificationProvider } from '../../../types/notifications' + +export default function ProviderIcon({ + provider, + className, +}: { + provider: NotificationProvider + className?: string +}) { + if (provider === 'discord') { + return + } + if (provider === 'slack') { + return + } + if (provider === 'telegram') { + return + } + return +} + +// Inline monochrome brand glyphs (path data from simple-icons, CC0). Bundled SVG instead of +// public/ PNGs so they follow currentColor and theme correctly in light/dark. + +function DiscordIcon({ className }: { className?: string }) { + return ( + + ) +} + +function TelegramIcon({ className }: { className?: string }) { + return ( + + ) +} + +function SlackIcon({ className }: { className?: string }) { + return ( + + ) +} diff --git a/src/pages/Commander.tsx b/src/pages/Commander.tsx index 93192b8..8ee9235 100644 --- a/src/pages/Commander.tsx +++ b/src/pages/Commander.tsx @@ -39,7 +39,7 @@ import { onErrorDialog, reportError } from '../../lib/errors' import { getFsInfo } from '../../lib/format' // import { Document, Page, pdfjs } from 'react-pdf' import { formatBytes } from '../../lib/format.ts' -import notify from '../../lib/notify' +import { notify } from '../../lib/notifications' import { startCopy, startMove } from '../../lib/rclone/api' import rclone from '../../lib/rclone/client' import { openWindow } from '../../lib/window' diff --git a/src/pages/Download.tsx b/src/pages/Download.tsx index ef9c86d..47f2831 100644 --- a/src/pages/Download.tsx +++ b/src/pages/Download.tsx @@ -9,7 +9,7 @@ import pRetry from 'p-retry' import { startTransition, useEffect, useMemo, useState } from 'react' import { useSearchParams } from 'react-router-dom' import { onErrorDialog } from '../../lib/errors' -import notify from '../../lib/notify' +import { notify } from '../../lib/notifications' import rclone from '../../lib/rclone/client' import CommandInfoButton from '../components/CommandInfoButton' import CommandsDropdown from '../components/CommandsDropdown' diff --git a/src/pages/Settings/GeneralSection.tsx b/src/pages/Settings/GeneralSection.tsx index 4db729f..264bccb 100644 --- a/src/pages/Settings/GeneralSection.tsx +++ b/src/pages/Settings/GeneralSection.tsx @@ -10,7 +10,7 @@ import { type Update, check } from '@tauri-apps/plugin-updater' import { EyeIcon } from 'lucide-react' import { startTransition, useEffect, useMemo, useState } from 'react' import { RELAUNCH_APP, emitToMain } from '../../../lib/events' -import notify from '../../../lib/notify' +import { notify } from '../../../lib/notifications' import { usePersistedStore } from '../../../store/persisted' import BaseSection from './BaseSection' diff --git a/src/pages/Settings/NotificationsSection.tsx b/src/pages/Settings/NotificationsSection.tsx new file mode 100644 index 0000000..ba421a9 --- /dev/null +++ b/src/pages/Settings/NotificationsSection.tsx @@ -0,0 +1,349 @@ +import { + Button, + Card, + CardBody, + Chip, + Dropdown, + DropdownItem, + DropdownMenu, + DropdownTrigger, + Switch, + Tooltip, + cn, +} from '@heroui/react' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { ask, message } from '@tauri-apps/plugin-dialog' +import { platform } from '@tauri-apps/plugin-os' +import { + PencilIcon, + PlusIcon, + SendIcon, + SettingsIcon, + Trash2Icon, + TriangleAlertIcon, +} from 'lucide-react' +import { useMemo, useState } from 'react' +import { + FREE_MAX_TARGETS, + NOTIFICATION_PROVIDERS, + maskWebhookUrl, + removeNotificationTarget, + sendTestNotification, + updateNotificationTarget, + useNotificationTargets, + useNotificationsCatalog, +} from '../../../lib/notifications' +import { usePersistedStore } from '../../../store/persisted' +import type { + NotificationCatalog, + NotificationProvider, + NotificationTarget, +} from '../../../types/notifications' +import NotificationTargetDrawer from '../../components/NotificationTargetDrawer' +import ProviderIcon from '../../components/icons/ProviderIcon' +import BaseSection from './BaseSection' + +const PROVIDER_ORDER: NotificationProvider[] = ['discord', 'slack', 'telegram', 'webhook'] + +export default function NotificationsSection() { + // Targets live in a Rust-owned store (notifications/targets.json) shared with the headless + // runner — polled so runner-recorded lastSentAt/lastError show up here. + const targetsQuery = useNotificationTargets() + const catalogQuery = useNotificationsCatalog() + const licenseValid = usePersistedStore((state) => state.licenseValid) + + const [addingProvider, setAddingProvider] = useState(null) + const [editingTarget, setEditingTarget] = useState(null) + + const notificationTargets = targetsQuery.data ?? [] + + const sortedTargets = useMemo( + () => [...notificationTargets].sort((a, b) => b.createdAt - a.createdAt), + [notificationTargets] + ) + + const drawerProvider = editingTarget?.provider ?? addingProvider + + const handleAddPress = async (provider: NotificationProvider) => { + // Creation-time gate only — the launch reconcile (lib/notifications.ts) is what + // disables over-limit targets when a license lapses. + if (!licenseValid && notificationTargets.length >= FREE_MAX_TARGETS) { + await message( + `Community version does not support more than ${FREE_MAX_TARGETS} notification webhooks. Activate a license for unlimited webhooks.`, + { + title: 'Missing license', + kind: 'error', + } + ) + return + } + setAddingProvider(provider) + } + + return ( + +
+
+

Add New

+
+ {PROVIDER_ORDER.map((provider) => ( + handleAddPress(provider)} + /> + ))} +
+
+ +
+ {sortedTargets.map((target) => ( + setEditingTarget(target)} + /> + ))} + {sortedTargets.length === 0 && !targetsQuery.isLoading && ( +

+ No notification webhooks configured yet. Pick a provider above to add + one. +

+ )} +
+
+ {!!drawerProvider && !!catalogQuery.data && ( + { + setAddingProvider(null) + setEditingTarget(null) + }} + provider={drawerProvider} + target={editingTarget ?? undefined} + catalog={catalogQuery.data} + existingTargets={notificationTargets} + /> + )} +
+ ) +} + +function ProviderCard({ + provider, + onPress, +}: { + provider: NotificationProvider + onPress: () => void +}) { + const providerMeta = NOTIFICATION_PROVIDERS[provider] + + return ( + + + + +
+

{providerMeta.label}

+

{providerMeta.description}

+
+
+
+ ) +} + +function NotificationTargetCard({ + target, + catalog, + onEdit, +}: { + target: NotificationTarget + catalog: NotificationCatalog | undefined + onEdit: () => void +}) { + const providerMeta = NOTIFICATION_PROVIDERS[target.provider] + const queryClient = useQueryClient() + const invalidateTargets = () => + queryClient.invalidateQueries({ queryKey: ['notifications', 'targets'] }) + + const eventsLabel = useMemo( + () => + catalog && target.events.length === catalog.events.length + ? 'All events' + : `${target.events.length} ${target.events.length === 1 ? 'event' : 'events'}`, + [target.events, catalog] + ) + + const sendTestMutation = useMutation({ + mutationFn: async () => { + await sendTestNotification(target) + }, + onSuccess: async () => { + await message('Test notification sent successfully.', { + title: target.name, + kind: 'info', + }) + }, + onError: async (error) => { + await message(error instanceof Error ? error.message : 'Unknown error occurred', { + title: 'Test failed', + kind: 'error', + }) + }, + // Success or failure, Rust recorded lastSentAt/lastError — refresh the warning chip. + onSettled: invalidateTargets, + }) + + const toggleMutation = useMutation({ + mutationFn: async (isEnabled: boolean) => { + await updateNotificationTarget(target.id, { isEnabled }) + }, + onError: async (error) => { + await message(error instanceof Error ? error.message : 'Unknown error occurred', { + title: 'Update failed', + kind: 'error', + }) + }, + onSettled: invalidateTargets, + }) + + const deleteMutation = useMutation({ + mutationFn: async () => { + await removeNotificationTarget(target.id) + }, + onError: async (error) => { + await message(error instanceof Error ? error.message : 'Unknown error occurred', { + title: 'Delete failed', + kind: 'error', + }) + }, + onSettled: invalidateTargets, + }) + + const handleDelete = async () => { + const confirmation = await ask( + `Are you sure you want to remove ${target.name}? This action cannot be reverted.`, + { + title: `Removing ${target.name}`, + kind: 'warning', + } + ) + + if (!confirmation) { + return + } + + deleteMutation.mutate() + } + + return ( + + +
+
+ +
+

{target.name}

+

+ {maskWebhookUrl(target.url)} +

+
+
+
+ {!!target.lastError && ( + + + + )} + + {eventsLabel} + + toggleMutation.mutate(isEnabled)} + aria-label={`Enable ${target.name}`} + data-focus-visible="false" + /> + + + + + { + const keyAsString = key as string + + if (keyAsString === 'edit') { + onEdit() + } else if (keyAsString === 'test') { + sendTestMutation.mutate() + } else if (keyAsString === 'delete') { + await handleDelete() + } + }} + > + } + key="edit" + > + Edit + + } + key="test" + > + Send Test + + } + key="delete" + color="danger" + > + Delete + + + +
+
+
+
+ ) +} diff --git a/src/pages/Settings/index.tsx b/src/pages/Settings/index.tsx index 04a96ce..ae4b5f2 100644 --- a/src/pages/Settings/index.tsx +++ b/src/pages/Settings/index.tsx @@ -5,6 +5,7 @@ import { message } from '@tauri-apps/plugin-dialog' import { openUrl } from '@tauri-apps/plugin-opener' import { platform } from '@tauri-apps/plugin-os' import { + BellIcon, CodeIcon, CogIcon, EyeIcon, @@ -30,6 +31,7 @@ import GeneralSection from './GeneralSection' import HostsSection from './HostsSection' import LicenseSection from './LicenseSection' import MobileSection from './MobileSection' +import NotificationsSection from './NotificationsSection' import ProxySection from './ProxySection' import RemotesSection from './RemotesSection' import ToolbarSection from './ToolbarSection' @@ -287,6 +289,19 @@ export default function Settings() { > + + + Notifications + + } + data-focus-visible="false" + className="w-full max-h-screen p-0 overflow-scroll overscroll-none" + > + + void diff --git a/toolbar/actions.ts b/toolbar/actions.ts index eedb5d2..fc6d11f 100644 --- a/toolbar/actions.ts +++ b/toolbar/actions.ts @@ -4,7 +4,7 @@ import { ask, message } from '@tauri-apps/plugin-dialog' import { openUrl, revealItemInDir } from '@tauri-apps/plugin-opener' import { reportError } from '../lib/errors' import { CLOSE_APP, emitToMain } from '../lib/events' -import notify from '../lib/notify' +import { notify } from '../lib/notifications' import queryClient from '../lib/query' import type { fetchMountList, fetchServeList } from '../lib/rclone/api' import rclone from '../lib/rclone/client'