Files
rclone-ui/lib/menu.ts
T
2025-09-24 20:28:01 +02:00

637 lines
23 KiB
TypeScript

import * as Sentry from '@sentry/browser'
import { Menu, MenuItem, PredefinedMenuItem, Submenu } from '@tauri-apps/api/menu'
import { resolveResource, sep } from '@tauri-apps/api/path'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { ask, message, open } from '@tauri-apps/plugin-dialog'
import { exists, mkdir, remove } from '@tauri-apps/plugin-fs'
import { openPath } from '@tauri-apps/plugin-opener'
import { platform } from '@tauri-apps/plugin-os'
import { isDirectoryEmpty } from './fs'
import notify from './notify'
import {
cleanupRemote,
deleteRemote,
getRemote,
listMounts,
listServes,
mountRemote,
stopServe,
unmountRemote,
} from './rclone/api'
import { compareVersions, getRcloneVersion } from './rclone/common'
import { dialogGetMountPlugin, needsMountPlugin } from './rclone/mount'
import { usePersistedStore, useStore } from './store'
import { showDefaultTray, showLoadingTray } from './tray'
import { lockWindows, openFullWindow, openWindow, unlockWindows } from './window'
async function parseRemotes(remotes: string[]) {
console.log('[parseRemotes] remotes', remotes)
const persistedStoreState = usePersistedStore.getState()
console.log('[parseRemotes] listing mounts')
const currentMounts = await listMounts()
console.log('[parseRemotes] currentMounts', currentMounts)
const currentServes = await listServes()
console.log('[parseRemotes] currentServes', currentServes)
const parsedRemotes: Record<
string,
{ icon: string; items: (MenuItem | Submenu | PredefinedMenuItem)[] }
> = {}
for (const remote of remotes) {
console.log('[parseRemotes] remote', remote)
const remoteConfig = persistedStoreState.remoteConfigList?.[remote]
if (remoteConfig?.disabledActions?.includes('tray')) {
continue
}
const remoteInfo = await getRemote(remote).catch(null)
console.log('[parseRemotes] remoteInfo', remoteInfo?.provider, remoteInfo?.type)
const submenuItems: (MenuItem | Submenu | PredefinedMenuItem)[] = []
if (!remoteConfig?.disabledActions?.includes('tray-mount')) {
const mountMenuItem = await MenuItem.new({
id: `mount-${remote}`,
text: 'Quick Mount',
action: async () => {
await showLoadingTray()
try {
const needsPlugin = await needsMountPlugin()
if (needsPlugin) {
console.log('Mount plugin not installed')
await dialogGetMountPlugin()
return
}
console.log('Mount plugin installed')
await lockWindows()
console.log('remoteConfig', remoteConfig)
let selectedPath = remoteConfig?.defaultMountPoint || null
if (!selectedPath) {
await getCurrentWindow().setFocus()
selectedPath = await open({
title: `Select a directory to mount "${remote}"`,
multiple: false,
directory: true,
})
}
if (!selectedPath) {
// await resetMainWindow()
return
}
console.log('selectedPath', selectedPath)
let directoryExists: boolean | undefined
try {
directoryExists = await exists(selectedPath)
} catch (error) {
console.error('Error checking if directory exists:', error)
}
console.log('directoryExists', directoryExists)
const isPlatformWindows = platform() === 'windows'
if (directoryExists) {
const isEmpty = await isDirectoryEmpty(selectedPath)
if (!isEmpty) {
// await resetMainWindow()
await message(
'The selected directory must be empty to mount a remote.',
{
title: 'Mount Error',
kind: 'error',
}
)
return
}
if (isPlatformWindows) {
await remove(selectedPath)
}
} else if (!isPlatformWindows) {
try {
await mkdir(selectedPath)
} catch (error) {
Sentry.captureException(error)
console.error('Error creating directory:', error)
await message(
'Failed to create mount directory. Try creating it manually first.',
{
title: 'Mount Error',
kind: 'error',
}
)
return
}
}
// Mount the remote
await mountRemote({
remotePath: `${remote}:${remoteConfig?.defaultRemotePath || ''}`,
mountPoint: selectedPath,
mountOptions: remoteConfig?.mountDefaults,
vfsOptions: remoteConfig?.vfsDefaults,
})
await notify({
title: 'Mounted',
body: `Successfully mounted ${remote} to ${selectedPath}`,
})
if (!remoteConfig?.defaultMountPoint) {
const answer = await ask(
`Mount successful! Do you want to set ${selectedPath} as the default mount point for ${remote}? You can always change it later in Remote settings.`,
{
title: 'Set Default?',
okLabel: 'Set',
cancelLabel: 'Cancel',
}
)
if (answer) {
usePersistedStore.setState((state) => ({
remoteConfigList: {
...state.remoteConfigList,
[remote]: {
...state.remoteConfigList[remote],
defaultMountPoint: selectedPath,
},
},
}))
}
}
} catch (error) {
// await resetMainWindow()
Sentry.captureException(error)
console.error('Mount operation failed:', error)
await message(`Failed to mount ${remote}: ${error}`, {
title: 'Mount Error',
})
} finally {
await unlockWindows()
await showDefaultTray()
}
},
})
submenuItems.push(mountMenuItem)
}
if (!remoteConfig?.disabledActions?.includes('tray-browse')) {
const browseMenuItem = await MenuItem.new({
id: `browse-${remote}`,
text: 'Browse',
action: async () => {
// await openBrowser(`http://localhost:5572/[${remote}:]/`)
try {
await openFullWindow({
name: 'Browse',
url:
'browse.html?url=' +
encodeURIComponent(`http://localhost:5572/[${remote}:]/`),
})
} catch (error) {
Sentry.captureException(error)
await message('Could not open browse window. Please try again.', {
title: 'Error',
kind: 'error',
okLabel: 'OK',
})
}
},
})
submenuItems.push(browseMenuItem)
}
if (!remoteConfig?.disabledActions?.includes('tray-cleanup')) {
if (
remoteInfo?.provider &&
['s3', 'b2', 'box', 'drive', 'onedrive'].includes(remoteInfo.provider)
) {
const cleanupMenuItem = await MenuItem.new({
id: `cleanup-${remote}`,
text: 'Cleanup',
action: async () => {
try {
const confirmed = await ask(`Start cleanup on ${remote}?`, {
title: `Cleaning up ${remote}`,
kind: 'warning',
okLabel: 'Start',
cancelLabel: 'Cancel',
})
if (!confirmed) {
return
}
await cleanupRemote(remote)
await message(`Cleanup started for ${remote}`, {
title: 'Cleanup Started',
okLabel: 'OK',
})
} catch (error) {
await message(`Failed to start cleanup job, ${error}`, {
title: 'Cleanup Error',
kind: 'error',
okLabel: 'OK',
})
}
},
})
submenuItems.push(cleanupMenuItem)
}
}
if (!remoteConfig?.disabledActions?.includes('tray-remove')) {
const removeMenuItem = await MenuItem.new({
id: `remove-${remote}`,
text: 'Remove',
action: async () => {
await showLoadingTray()
const answer = await ask(
`Are you sure you want to remove ${remote}? This action cannot be reverted.`,
{ title: `Removing ${remote}`, kind: 'warning' }
)
if (!answer) {
return
}
await deleteRemote(remote)
await showDefaultTray()
},
})
submenuItems.push(removeMenuItem)
}
const currentRemoteMounts = currentMounts.filter(
(mount) => mount.Fs.split(':')[0] === remote
)
console.log('[parseRemotes] currentRemoteMounts', currentRemoteMounts)
for (const currentMount of currentRemoteMounts) {
console.log(
'[parseRemotes] Adding Unmount (' +
currentMount.MountPoint.split(sep()).pop() +
') for ',
remote
)
const unmountMenuItem = await MenuItem.new({
id: `unmount-${remote}-${currentMount.MountPoint}`,
text: 'Unmount (' + currentMount.MountPoint.split(sep()).pop() + ')',
action: async () => {
try {
await showLoadingTray()
await unmountRemote({ mountPoint: currentMount.MountPoint })
await message(
`Successfully unmounted ${remote} from ${currentMount.MountPoint.split('/').pop()}`,
{
title: 'Success',
}
)
} catch (error) {
Sentry.captureException(error)
console.error('Unmount operation failed:', error)
await message(`Failed to unmount ${remote}: ${error}`, {
kind: 'error',
title: 'Unmount Error',
})
} finally {
await showDefaultTray()
}
},
})
submenuItems.push(unmountMenuItem)
console.log(
'[parseRemotes] Adding Open (' +
currentMount.MountPoint.split('/').pop() +
') for ',
remote
)
const showLocationItem = await MenuItem.new({
id: `open-${remote}-${currentMount.MountPoint}`,
text: 'Open (' + currentMount.MountPoint.split('/').pop() + ')',
action: async () => {
console.log(
'[parseRemotes] Opening (' + currentMount.MountPoint.split('/').pop() + ')'
)
try {
await openPath(currentMount.MountPoint)
} catch (error) {
Sentry.captureException(error)
console.error('Error opening path:', error)
await message(`Failed to open ${currentMount.MountPoint} (${error})`, {
title: 'Open Error',
kind: 'error',
})
}
},
})
submenuItems.push(showLocationItem)
}
for (const currentServe of currentServes) {
// console.log('[parseRemotes] Adding Stop Serve (' + currentServe.Addr.split(':')[0] + ') for ', remote)
const stopServeMenuItem = await MenuItem.new({
id: `stop-serve-${currentServe.id}`,
text: 'Stop Serve',
action: async () => {
try {
await showLoadingTray()
await stopServe(currentServe.id)
await message(`Successfully stopped serve ${currentServe.id}`, {
title: 'Success',
})
} catch (error) {
Sentry.captureException(error)
console.error('Stop serve operation failed:', error)
await message(`Failed to stop serve ${currentServe.id}: ${error}`, {
kind: 'error',
title: 'Stop Serve Error',
})
} finally {
await showDefaultTray()
}
},
})
submenuItems.push(stopServeMenuItem)
}
let iconPath = await resolveResource('icons/favicon/icon.png')
try {
if (remoteInfo?.provider) {
iconPath = await resolveResource(`icons/small/providers/${remoteInfo.provider}.png`)
} else if (remoteInfo?.type) {
iconPath = await resolveResource(`icons/small/backends/${remoteInfo?.type}.png`)
}
} catch (error) {
Sentry.captureException(new Error(`Error resolving icon path for ${remote}: ${error}`))
Sentry.captureException(error)
console.error('Error resolving icon path for', remote, error)
}
parsedRemotes[remote] = {
icon: iconPath,
items: submenuItems,
}
}
return parsedRemotes
}
export async function buildMenu() {
console.log('[buildMenu]')
const storeState = useStore.getState()
const persistedStoreState = usePersistedStore.getState()
const remotes = storeState.remotes
const menuItems: (MenuItem | Submenu | PredefinedMenuItem)[] = []
if (remotes.length === 0) {
const noRemotesMenuItem = await MenuItem.new({
id: 'no-remotes',
text: 'Add Remotes in Settings!',
enabled: false,
})
menuItems.push(noRemotesMenuItem)
} else if (remotes.length > 5) {
const parsedRemotes = await parseRemotes(remotes)
const submenuItems: (MenuItem | Submenu | PredefinedMenuItem)[] = []
for (const r in parsedRemotes) {
const sub = await Submenu.new({
items: parsedRemotes[r].items,
icon: parsedRemotes[r].icon,
text: r,
})
submenuItems.push(sub)
}
const sub = await Submenu.new({
items: submenuItems,
text: 'Remotes',
})
menuItems.push(sub)
} else {
const parsedRemotes = await parseRemotes(remotes)
for (const r in parsedRemotes) {
const sub = await Submenu.new({
items: parsedRemotes[r].items,
icon: parsedRemotes[r].icon,
text: r,
})
menuItems.push(sub)
}
}
await PredefinedMenuItem.new({
item: 'Separator',
}).then((item) => {
menuItems.push(item)
})
if (!persistedStoreState.disabledActions?.includes('tray-mount')) {
const mountToMenuItem = await MenuItem.new({
id: 'mount',
text: 'Mount',
action: async () => {
await openWindow({
name: 'Mount',
url: '/mount',
})
},
})
menuItems.push(mountToMenuItem)
}
if (!persistedStoreState.disabledActions?.includes('tray-copy')) {
const copyMenuItem = await MenuItem.new({
id: 'copy',
text: 'Copy',
action: async () => {
await openWindow({
name: 'Copy',
url: '/copy',
})
},
})
menuItems.push(copyMenuItem)
}
if (!persistedStoreState.disabledActions?.includes('tray-sync')) {
const syncMenuItem = await MenuItem.new({
id: 'sync',
text: 'Sync',
action: async () => {
await openWindow({
name: 'Sync',
url: '/sync',
})
},
})
menuItems.push(syncMenuItem)
}
const allowsAdditional =
!persistedStoreState.disabledActions?.includes('tray-move') ||
!persistedStoreState.disabledActions?.includes('tray-serve') ||
!persistedStoreState.disabledActions?.includes('tray-purge') ||
!persistedStoreState.disabledActions?.includes('tray-delete')
if (allowsAdditional) {
const commandsSubmenuItems: (MenuItem | Submenu | PredefinedMenuItem)[] = []
if (!persistedStoreState.disabledActions?.includes('tray-move')) {
const moveMenuItem = await MenuItem.new({
id: 'move',
text: 'Move',
action: async () => {
await openWindow({
name: 'Move',
url: '/move',
})
},
})
commandsSubmenuItems.push(moveMenuItem)
}
const rcloneVersion = await getRcloneVersion()
if (
rcloneVersion?.yours &&
compareVersions(rcloneVersion.yours, '1.70.0') === 1 &&
!persistedStoreState.disabledActions?.includes('tray-serve')
) {
const serveToMenuItem = await MenuItem.new({
id: 'serve',
text: 'Serve',
action: async () => {
await openWindow({
name: 'Serve',
url: '/serve',
})
},
})
commandsSubmenuItems.push(serveToMenuItem)
}
if (!persistedStoreState.disabledActions?.includes('tray-purge')) {
const purgeMenuItem = await MenuItem.new({
id: 'purge',
text: 'Purge',
action: async () => {
await openWindow({
name: 'Purge',
url: '/purge',
})
},
})
commandsSubmenuItems.push(purgeMenuItem)
}
if (!persistedStoreState.disabledActions?.includes('tray-delete')) {
const deleteMenuItem = await MenuItem.new({
id: 'delete',
text: 'Delete',
action: async () => {
await openWindow({
name: 'Delete',
url: '/delete',
})
},
})
commandsSubmenuItems.push(deleteMenuItem)
}
const commandsSubmenu = await Submenu.new({
items: commandsSubmenuItems,
text: 'Commands',
})
menuItems.push(commandsSubmenu)
}
await PredefinedMenuItem.new({
item: 'Separator',
}).then((item) => {
menuItems.push(item)
})
const jobsMenuItem = await MenuItem.new({
id: 'jobs',
text: 'Jobs',
action: async () => {
await openWindow({
name: 'Jobs',
url: '/jobs',
})
},
})
menuItems.push(jobsMenuItem)
const cronMenuItem = await MenuItem.new({
id: 'cron',
text: 'Cron',
action: async () => {
await openWindow({
name: 'Cron',
url: '/cron',
})
},
})
menuItems.push(cronMenuItem)
await PredefinedMenuItem.new({
item: 'Separator',
}).then((item) => {
menuItems.push(item)
})
const settingsItem = await MenuItem.new({
id: 'settings',
text: 'Settings',
action: async () => {
await openWindow({
name: 'Settings',
url: '/settings',
})
},
})
menuItems.push(settingsItem)
const quitItem = await MenuItem.new({
id: 'quit',
text: 'Quit',
action: async () => {
await getCurrentWindow().emit('close-app')
},
})
menuItems.push(quitItem)
return await Menu.new({
id: 'main-menu',
items: menuItems,
})
}