notifications
This commit is contained in:
+25
-9
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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.`,
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user