notifications

This commit is contained in:
FTCHD
2026-07-11 21:30:56 +03:00
parent 7e98a78b6d
commit 2f98e8b7da
15 changed files with 1061 additions and 622 deletions
+25 -9
View File
@@ -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<botid>:<token>"; 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<token>/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<token>"
}
return null
}
-27
View File
@@ -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',
})
}
}
+146 -578
View File
@@ -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<T>(operation: () => Promise<T>): Promise<T> {
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<T>(operation: () => Promise<T>): Promise<T> {
}
return result
} finally {
dryRunDepth--
await rclone('/options/set', {
body: {
main: { DryRun: false },
@@ -45,71 +68,24 @@ export async function startDryRun<T>(operation: () => Promise<T>): Promise<T> {
}
}
function serializeOptions(
remotePath: string,
options: {
remote?: Record<string, FlagValue>
global?: Record<string, FlagValue>
}
// 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<WatchedJob, 'operation' | 'sources' | 'destination'>
) {
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<string, FlagValue>
config?: Record<string, FlagValue>
filter?: Record<string, FlagValue>
remotes?: Record<string, Record<string, FlagValue>>
}
}) {
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<typeof startBatch>[0] = []
const handledSourcePaths: Record<string, true> = {}
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<typeof startBatch>[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<typeof startBatch>[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<string, FlagValue>
config?: Record<string, FlagValue>
filter?: Record<string, FlagValue>
remotes?: Record<string, Record<string, FlagValue>>
}
}) {
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<typeof startBatch>[0] = []
const handledSourcePaths: Record<string, true> = {}
const folderSources = sources.filter((path) => path.endsWith('/') || path.endsWith('\\'))
const {
root: dstRoot,
dirPath: dstDirPath,
fullDirPath: dstFullDirPath,
remoteName: dstRemoteName,
} = getFsInfo(destination)
const dstOptions =
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<typeof startBatch>[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<typeof startBatch>[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<typeof startMountInner>[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<string, FlagValue>
bisync?: Record<string, FlagValue>
filter?: Record<string, FlagValue>
remotes?: Record<string, Record<string, FlagValue>>
outer?: Record<string, FlagValue>
}
}) {
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<string, any>,
watch: Pick<WatchedJob, 'operation' | 'sources' | 'destination'>
) {
// 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<string, FlagValue>
sync?: Record<string, FlagValue>
filter?: Record<string, FlagValue>
remotes?: Record<string, Record<string, FlagValue>>
}
}) {
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<string, FlagValue>
config?: Record<string, FlagValue>
remotes?: Record<string, Record<string, FlagValue>>
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<typeof startBatch>[0] = []
const handledSourcePaths: Record<string, true> = {}
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<typeof startBatch>[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<typeof startBatch>[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<string, FlagValue>
remotes?: Record<string, Record<string, FlagValue>>
}
}) {
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<typeof startBatch>[0] = []
const handledSourcePaths: Record<string, true> = {}
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<typeof startBatch>[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<string, any>)[]) {
export async function startBatch(
inputs: ({ _path: string } & Record<string, any>)[],
meta?: Partial<Pick<WatchedJob, 'operation' | 'sources' | 'destination'>>
) {
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<string, any
console.log('[startBatch] job created', { jobid: r.jobid })
if (!submittedDuringDryRun) {
registerWatchedJob(r.jobid, {
operation: meta?.operation ?? 'batch',
sources: meta?.sources,
destination: meta?.destination,
})
}
await new Promise((resolve) => setTimeout(resolve, 1000))
const jobStatus = await pRetry(
@@ -1253,6 +820,7 @@ export async function startBatch(inputs: ({ _path: string } & Record<string, any
}
console.log('[startBatch] SUCCESS', { jobid: r.jobid })
return r.jobid
}
+6 -1
View File
@@ -10,7 +10,7 @@ import { selectActiveConfigFile, useHostStore } from '../../store/host'
import { useStore } from '../../store/memory'
import { usePersistedStore } from '../../store/persisted'
import { getConfigParentFolder } from '../format'
import notify from '../notify'
import { dispatchNotification, notify } from '../notifications'
import { openSmallWindow } from '../window'
import { buildRcloneEnv } from './cli'
import {
@@ -384,6 +384,11 @@ async function maybeAutoUpdateRclone(currentPath: string): Promise<string> {
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.`,
+35
View File
@@ -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())
+9 -1
View File
@@ -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: {
+419
View File
@@ -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<NotificationEventId[]>(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 (
<Drawer
isOpen={isOpen}
placement="bottom"
size="full"
onClose={onClose}
hideCloseButton={true}
>
<DrawerContent
className={cn(
'bg-content1/80 backdrop-blur-md dark:bg-content1/90',
platform() === 'macos' && 'pt-5'
)}
>
{(close) => (
<>
<DrawerHeader className="flex items-center gap-2">
<ProviderIcon
provider={provider}
className={cn('size-5', providerMeta.accentClass)}
/>
<span>{drawerTitle}</span>
</DrawerHeader>
<DrawerBody>
<div className="flex flex-col gap-8">
<section className="flex flex-col gap-4">
<Input
label="Name"
labelPlacement="outside"
placeholder="e.g. Team alerts channel"
value={name}
onValueChange={setName}
isRequired={true}
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
spellCheck="false"
/>
<Input
label={isTelegram ? 'Bot URL' : 'Webhook URL'}
labelPlacement="outside"
placeholder={providerMeta.urlPlaceholder}
value={url}
onValueChange={setUrl}
onBlur={() => 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 && (
<Input
label="Chat ID"
labelPlacement="outside"
placeholder="123456789 or -1001234567890 or @channelname"
value={chatId}
onValueChange={setChatId}
onBlur={() => setChatIdTouched(true)}
isRequired={true}
isInvalid={!!chatIdError}
errorMessage={chatIdError}
description={TELEGRAM_CHAT_ID_HELP}
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
spellCheck="false"
/>
)}
</section>
<section className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold uppercase text-default-500">
Events
</p>
<Button
size="sm"
variant="light"
onPress={() =>
setEvents(allSelected ? [] : allEventIds)
}
data-focus-visible="false"
>
{allSelected ? 'Deselect all' : 'Select all'}
</Button>
</div>
<CheckboxGroup
value={events}
onValueChange={(value) =>
setEvents(value as NotificationEventId[])
}
aria-label="Events that trigger this webhook"
>
<div className="flex flex-col gap-6">
{catalog.categories.map((category) => (
<div
key={category.id}
className="flex flex-col gap-2"
>
<p className="text-xs font-semibold uppercase text-default-400">
{category.label}
</p>
{catalog.events
.filter(
(event) =>
event.category === category.id
)
.map((event) => (
<Checkbox
key={event.id}
value={event.id}
>
<div className="flex flex-col">
<span className="text-small">
{event.label}
</span>
<span className="text-tiny text-default-400">
{event.description}
</span>
</div>
</Checkbox>
))}
</div>
))}
</div>
</CheckboxGroup>
</section>
<section className="flex flex-col gap-4">
<Switch
size="sm"
color="primary"
isSelected={isEnabled}
onValueChange={setIsEnabled}
data-focus-visible="false"
>
<div className="flex flex-col">
<span className="text-small">Enabled</span>
<span className="text-tiny text-default-400">
Deliver notifications to this webhook
</span>
</div>
</Switch>
</section>
</div>
</DrawerBody>
<DrawerFooter>
<Button
className="mr-auto"
variant="faded"
color="primary"
isLoading={sendTestMutation.isPending}
isDisabled={!canSendTest}
onPress={() => sendTestMutation.mutate()}
data-focus-visible="false"
>
{justTested ? 'Sent ✓' : 'Send Test'}
</Button>
<Button
color="danger"
variant="light"
onPress={() => {
close()
onClose()
}}
data-focus-visible="false"
>
Cancel
</Button>
<Button
color="primary"
onPress={() => handleSave(close)}
data-focus-visible="false"
>
{isEditing ? 'Save Changes' : `Add ${providerMeta.titleLabel}`}
</Button>
</DrawerFooter>
</>
)}
</DrawerContent>
</Drawer>
)
}
+48
View File
@@ -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 <DiscordIcon className={className} />
}
if (provider === 'slack') {
return <SlackIcon className={className} />
}
if (provider === 'telegram') {
return <TelegramIcon className={className} />
}
return <WebhookIcon className={className} />
}
// 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 (
<svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden="true">
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
</svg>
)
}
function TelegramIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden="true">
<path d="M11.944 0A12 12 0 000 12a12 12 0 0012 12 12 12 0 0012-12A12 12 0 0012 0a12 12 0 00-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 01.171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
</svg>
)
}
function SlackIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden="true">
<path d="M5.042 15.165a2.528 2.528 0 01-2.52 2.523A2.528 2.528 0 010 15.165a2.527 2.527 0 012.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 012.521-2.52 2.527 2.527 0 012.521 2.52v6.313A2.528 2.528 0 018.834 24a2.528 2.528 0 01-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 01-2.521-2.52A2.528 2.528 0 018.834 0a2.528 2.528 0 012.521 2.522v2.52H8.834zM8.834 6.313a2.528 2.528 0 012.521 2.521 2.528 2.528 0 01-2.521 2.521H2.522A2.528 2.528 0 010 8.834a2.528 2.528 0 012.522-2.521h6.312zM18.956 8.834a2.528 2.528 0 012.522-2.521A2.528 2.528 0 0124 8.834a2.528 2.528 0 01-2.522 2.521h-2.522V8.834zM17.688 8.834a2.528 2.528 0 01-2.523 2.521 2.527 2.527 0 01-2.52-2.521V2.522A2.527 2.527 0 0115.165 0a2.528 2.528 0 012.523 2.522v6.312zM15.165 18.956a2.528 2.528 0 012.523 2.522A2.528 2.528 0 0115.165 24a2.527 2.527 0 01-2.52-2.522v-2.522h2.52zM15.165 17.688a2.527 2.527 0 01-2.52-2.523 2.526 2.526 0 012.52-2.52h6.313A2.527 2.527 0 0124 15.165a2.528 2.528 0 01-2.522 2.523h-6.313z" />
</svg>
)
}
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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'
+349
View File
@@ -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<NotificationProvider | null>(null)
const [editingTarget, setEditingTarget] = useState<NotificationTarget | null>(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 (
<BaseSection
header={{
title: 'Notifications',
}}
>
<div className="flex flex-col gap-6 px-4 pb-10">
<section className="flex flex-col gap-4">
<p className="text-sm font-semibold uppercase text-default-500">Add New</p>
<div className="grid grid-cols-2 gap-2.5">
{PROVIDER_ORDER.map((provider) => (
<ProviderCard
key={provider}
provider={provider}
onPress={() => handleAddPress(provider)}
/>
))}
</div>
</section>
<section className="flex flex-col gap-2.5">
{sortedTargets.map((target) => (
<NotificationTargetCard
key={target.id}
target={target}
catalog={catalogQuery.data}
onEdit={() => setEditingTarget(target)}
/>
))}
{sortedTargets.length === 0 && !targetsQuery.isLoading && (
<p className="py-10 text-sm text-center text-default-500">
No notification webhooks configured yet. Pick a provider above to add
one.
</p>
)}
</section>
</div>
{!!drawerProvider && !!catalogQuery.data && (
<NotificationTargetDrawer
key={editingTarget?.id ?? addingProvider ?? 'closed'}
isOpen={true}
onClose={() => {
setAddingProvider(null)
setEditingTarget(null)
}}
provider={drawerProvider}
target={editingTarget ?? undefined}
catalog={catalogQuery.data}
existingTargets={notificationTargets}
/>
)}
</BaseSection>
)
}
function ProviderCard({
provider,
onPress,
}: {
provider: NotificationProvider
onPress: () => void
}) {
const providerMeta = NOTIFICATION_PROVIDERS[provider]
return (
<Card
shadow="sm"
isPressable={true}
onPress={onPress}
className="h-24 bg-content2"
data-focus-visible="false"
>
<CardBody className="relative flex flex-row items-center gap-3 px-4">
<PlusIcon className="absolute w-4 h-4 top-3 right-3 text-default-400" />
<ProviderIcon
provider={provider}
className={cn('size-8 shrink-0', providerMeta.accentClass)}
/>
<div className="flex flex-col gap-0.5 text-left">
<p className="font-medium">{providerMeta.label}</p>
<p className="text-small text-default-500">{providerMeta.description}</p>
</div>
</CardBody>
</Card>
)
}
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 (
<Card
shadow="sm"
isBlurred={true}
className="h-20 border-[0.5px] dark:border-none border-divider bg-content3/50 dark:bg-content2/90"
isPressable={true}
onPress={onEdit}
data-focus-visible="false"
>
<CardBody className={cn(!target.isEnabled && 'opacity-60')}>
<div className="flex items-center justify-between h-full">
<div className="flex items-center gap-4">
<ProviderIcon
provider={target.provider}
className={cn('ml-2 size-8 shrink-0', providerMeta.accentClass)}
/>
<div className="flex flex-col gap-0.5 text-left">
<p className="font-light text-large">{target.name}</p>
<p className="font-mono text-small text-default-500">
{maskWebhookUrl(target.url)}
</p>
</div>
</div>
<div className="flex items-center justify-end gap-4">
{!!target.lastError && (
<Tooltip
content={`Last delivery failed: ${target.lastError}`}
color="warning"
size="lg"
>
<TriangleAlertIcon className="size-5 text-warning" />
</Tooltip>
)}
<Chip size="sm" radius="sm" variant="flat">
{eventsLabel}
</Chip>
<Switch
size="sm"
color="primary"
isSelected={target.isEnabled}
onValueChange={(isEnabled) => toggleMutation.mutate(isEnabled)}
aria-label={`Enable ${target.name}`}
data-focus-visible="false"
/>
<Dropdown shadow={platform() === 'windows' ? 'none' : undefined}>
<DropdownTrigger>
<Button
type="button"
color="default"
isIconOnly={true}
radius="full"
variant="light"
>
<SettingsIcon className="opacity-50 size-8 hover:opacity-100" />
</Button>
</DropdownTrigger>
<DropdownMenu
onAction={async (key) => {
const keyAsString = key as string
if (keyAsString === 'edit') {
onEdit()
} else if (keyAsString === 'test') {
sendTestMutation.mutate()
} else if (keyAsString === 'delete') {
await handleDelete()
}
}}
>
<DropdownItem
startContent={<PencilIcon className="w-4 h-4" />}
key="edit"
>
Edit
</DropdownItem>
<DropdownItem
startContent={<SendIcon className="w-4 h-4" />}
key="test"
>
Send Test
</DropdownItem>
<DropdownItem
startContent={<Trash2Icon className="w-4 h-4" />}
key="delete"
color="danger"
>
Delete
</DropdownItem>
</DropdownMenu>
</Dropdown>
</div>
</div>
</CardBody>
</Card>
)
}
+15
View File
@@ -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() {
>
<ProxySection />
</Tab>
<Tab
key="notifications"
title={
<div className="flex items-center gap-2">
<BellIcon className="w-5 h-5" />
<span>Notifications</span>
</div>
}
data-focus-visible="false"
className="w-full max-h-screen p-0 overflow-scroll overscroll-none"
>
<NotificationsSection />
</Tab>
<Tab
key="mobile"
title={
+5 -2
View File
@@ -8,7 +8,6 @@ import { createJSONStorage, persist } from 'zustand/middleware'
import type { Host } from '../lib/hosts'
import type { SERVE_TYPES } from '../lib/rclone/constants'
import type { ConfigFile } from '../types/config'
import type { ScheduledTask } from '../types/schedules'
import type { Template } from '../types/template'
import type { RemoteConfig as HostRemoteConfig } from './host'
import { createTauriStateStorage } from './lib'
@@ -60,7 +59,8 @@ interface PersistedStateV1 {
startOnBoot: boolean
scheduledTasks: ScheduledTask[]
// Legacy v1 field; the v1→v2 migration never reads it (host stores own scheduling data).
scheduledTasks: unknown[]
templates: TemplateV1[]
@@ -93,6 +93,9 @@ interface PersistedStateV2 {
templates: Template[]
// Notification targets are NOT here: they live in a Rust-owned store
// (notifications/targets.json) so the headless scheduler runner can read AND write them.
hosts: Host[]
currentHostId: string | null
setCurrentHost: (id: Host['id']) => void
+1 -1
View File
@@ -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'