Signed-off-by: FTCHD <144691102+FTCHD@users.noreply.github.com>
This commit is contained in:
FTCHD
2025-09-15 11:00:17 +02:00
parent 81b0b3da79
commit 4d3dcf1632
22 changed files with 391 additions and 209 deletions
+1 -2
View File
@@ -188,11 +188,10 @@ async function parseRemotes(remotes: string[]) {
})
} catch (error) {
Sentry.captureException(error)
await ask('Could not open browse window. Please try again.', {
await message('Could not open browse window. Please try again.', {
title: 'Error',
kind: 'error',
okLabel: 'OK',
cancelLabel: '',
})
}
},
+1
View File
@@ -19,6 +19,7 @@ const SUPPORTED_BACKENDS = [
'box',
'webdav',
'onedrive',
'http',
]
function getAuthHeader() {
+8 -1
View File
@@ -9,7 +9,8 @@ import { fetch } from '@tauri-apps/plugin-http'
import { platform } from '@tauri-apps/plugin-os'
import { exit } from '@tauri-apps/plugin-process'
import { Command } from '@tauri-apps/plugin-shell'
import { usePersistedStore } from '../store'
import { usePersistedStore, useStore } from '../store'
import { openSmallWindow } from '../window'
import {
getConfigPath,
getDefaultPath,
@@ -25,6 +26,12 @@ export async function initRclone(args: string[]) {
// rclone not available, let's download it
if (!system && !internal) {
usePersistedStore.setState({ isFirstOpen: false })
useStore.setState({ startupStatus: 'initializing' })
await openSmallWindow({
name: 'Startup',
url: '/startup',
})
const success = await provisionRclone()
if (!success) {
await new Promise((resolve) => setTimeout(resolve, 1000))
+4
View File
@@ -40,6 +40,8 @@ interface State {
setRemotes: (remotes: string[]) => void
addRemote: (remote: string) => void
removeRemote: (remote: string) => void
startupStatus: null | 'initializing' | 'initialized'
}
interface PersistedState {
@@ -136,6 +138,8 @@ export const useStore = create<State>()(
set((state) => ({ remotes: [...state.remotes, remote] })),
removeRemote: (remote: string) =>
set((state) => ({ remotes: state.remotes.filter((r) => r !== remote) })),
startupStatus: null,
}),
{ name: 'shared-store' }
)
+47 -6
View File
@@ -27,7 +27,7 @@ export async function openFullWindow({
name: string
url: string
}) {
console.log('[openFullWindow]')
console.log('[openFullWindow] ', name, url)
const w = new WebviewWindow(name, {
height: 0,
@@ -41,6 +41,8 @@ export async function openFullWindow({
decorations: true,
url: url,
theme: 'dark',
// @ts-expect-error
backgroundThrottling: 'disabled',
})
let monitor = await currentMonitor()
@@ -57,8 +59,7 @@ export async function openFullWindow({
}
if (platform() === 'windows') {
// windows merges the space for the taskbar
// subtract from the height to have it show
// subtract from the height to correct for the taskbar
size.height -= 100
}
@@ -81,7 +82,7 @@ export async function openWindow({
width?: number
height?: number
}) {
console.log('[openWindow]')
console.log('[openWindow] ', name, url)
const isFirstWindow = useStore.getState().firstWindow
@@ -98,12 +99,13 @@ export async function openWindow({
decorations: false,
url: url,
theme: 'dark',
// parent: 'main',
// @ts-expect-error
backgroundThrottling: 'disabled',
})
await getMainTray().then((t) => t?.setVisible(false))
await getLoadingTray().then((t) => t?.setVisible(true))
await new Promise((resolve) => setTimeout(resolve, isFirstWindow ? 1000 : 150))
await new Promise((resolve) => setTimeout(resolve, isFirstWindow ? 900 : 150))
await getLoadingTray().then((t) => t?.setVisible(false))
await getMainTray().then((t) => t?.setVisible(true))
@@ -118,6 +120,45 @@ export async function openWindow({
return w
}
export async function openSmallWindow({
name,
url,
}: {
name: string
url: string
}) {
console.log('[openSmallWindow] ', name, url)
const isFirstWindow = useStore.getState().firstWindow
const w = new WebviewWindow(name, {
height: 0,
width: 0,
resizable: false,
visibleOnAllWorkspaces: false,
alwaysOnTop: true,
visible: false,
focus: true,
title: name,
decorations: false,
url: url,
theme: 'dark',
closable: false,
// @ts-expect-error
backgroundThrottling: 'disabled',
})
await new Promise((resolve) => setTimeout(resolve, isFirstWindow ? 900 : 150))
await w.setSize(new LogicalSize(800, 500))
await w.center()
await w.show()
useStore.setState({ firstWindow: false })
return w
}
export async function lockWindows(ids?: string[]) {
const windows = await getAllWindows()
const lockedWindows = ids ? windows.filter((w) => ids.includes(w.label)) : windows
+7 -8
View File
@@ -23,6 +23,7 @@ import {
import { initRclone } from './lib/rclone/init'
import { usePersistedStore, useStore } from './lib/store'
import { initLoadingTray, initTray, rebuildTrayMenu } from './lib/tray'
import { openSmallWindow } from './lib/window'
import type { ScheduledTask } from './types/task'
try {
@@ -73,13 +74,12 @@ async function validateInstance() {
const isOnline = navigator.onLine
if (!isOnline && platform() !== 'linux') {
await ask(
await message(
'You are not connected to the internet. Please check your connection and try again.',
{
title: 'Error',
kind: 'error',
okLabel: 'Exit',
cancelLabel: '',
}
)
return await exit(0)
@@ -148,11 +148,10 @@ async function startRclone() {
])
} catch (error) {
Sentry.captureException(error)
await ask(error.message || 'Failed to start rclone, please try again later.', {
await message(error.message || 'Failed to start rclone, please try again later.', {
title: 'Error',
kind: 'error',
okLabel: 'Exit',
cancelLabel: '',
})
return await exit(0)
}
@@ -282,10 +281,10 @@ async function startupMounts() {
async function onboardUser() {
const firstOpen = usePersistedStore.getState().isFirstOpen
if (firstOpen) {
await message('Rclone has initialized, you can now find it in the tray menu!', {
title: 'Welcome to Rclone UI',
kind: 'info',
okLabel: 'Got it',
useStore.setState({ startupStatus: 'initialized' })
await openSmallWindow({
name: 'Startup',
url: '/startup',
})
usePersistedStore.setState({ isFirstOpen: false })
}
+4 -4
View File
@@ -1,12 +1,12 @@
{
"name": "s-tray",
"version": "1.9.3",
"name": "rclone-ui",
"version": "2.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "s-tray",
"version": "1.9.3",
"name": "rclone-ui",
"version": "2.0.0",
"dependencies": {
"@heroui/react": "^2.7.11",
"@sentry/browser": "^10.8.0",
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "s-tray",
"name": "rclone-ui",
"private": true,
"version": "1.9.3",
"version": "2.0.0",
"type": "module",
"scripts": {
"dev": "node scripts/buildExternal.js && vite",
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 530 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

+1 -1
View File
@@ -103,7 +103,7 @@ checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100"
[[package]]
name = "app"
version = "1.9.3"
version = "2.0.0"
dependencies = [
"cocoa",
"fix-path-env",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "app"
version = "1.9.3"
version = "2.0.0"
description = "A Tauri App"
authors = ["you"]
license = ""
+2 -3
View File
@@ -13,9 +13,8 @@
"Jobs",
"Cron",
"Browse",
"Test",
"Test2",
"Test3"
"Startup",
"Test"
],
"permissions": [
"core:default",
+2 -2
View File
@@ -2,8 +2,8 @@
"$schema": "https://schema.tauri.app/config/2",
"productName": "Rclone UI",
"mainBinaryName": "Rclone UI",
"version": "1.9.3",
"identifier": "com.stray.app",
"version": "2.0.0",
"identifier": "com.rclone.ui",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:1420",
+5
View File
@@ -12,6 +12,7 @@ import Jobs from './pages/Jobs'
import Mount from './pages/Mount'
import Move from './pages/Move'
import Settings from './pages/Settings'
import Startup from './pages/Startup'
import Sync from './pages/Sync'
import Test from './pages/Test'
@@ -39,6 +40,10 @@ const router = createBrowserRouter([
path: '/',
element: <Home />,
},
{
path: '/startup',
element: <Startup />,
},
{
path: '/settings',
element: <Settings />,
+216 -178
View File
@@ -4,6 +4,7 @@ import {
CardBody,
Checkbox,
Chip,
Divider,
Dropdown,
DropdownItem,
DropdownMenu,
@@ -50,7 +51,12 @@ import {
} from 'lucide-react'
import { type DetailedHTMLProps, type HTMLAttributes, useEffect, useRef, useState } from 'react'
import { revokeMachineLicense, validateLicense } from '../../lib/license'
import { deleteRemote, getVersion as getCliVersion, getVersion } from '../../lib/rclone/api'
import {
deleteRemote,
getVersion as getCliVersion,
getRemote,
getVersion,
} from '../../lib/rclone/api'
import { getConfigPath, getDefaultPaths } from '../../lib/rclone/common'
import { usePersistedStore, useStore } from '../../lib/store'
import { triggerTrayRebuild } from '../../lib/tray'
@@ -339,7 +345,7 @@ function GeneralSection() {
title: 'Update',
kind: 'info',
okLabel: 'Restart',
cancelLabel: '',
cancelLabel: 'Later',
})
if (!answer) {
@@ -599,134 +605,124 @@ function LicenseSection() {
<div className="flex flex-col gap-8">
<BaseHeader title="License" />
<div className="flex flex-row justify-center w-full gap-8 px-8">
<div className="flex flex-col items-end w-2/6 gap-2 bg-transparent-500">
<h3 className="font-medium">Activate</h3>
</div>
<div className="flex flex-row justify-center w-full gap-2 px-8">
<Input
placeholder="Enter license key"
value={licenseKeyInput}
onChange={(e) => setLicenseKeyInput(e.target.value)}
size="lg"
isDisabled={!isLicenseEditable || isActivating}
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
spellCheck="false"
endContent={licenseValid && <CheckIcon className="w-5 h-5 text-green-500" />}
data-focus-visible="false"
fullWidth={true}
/>
<div className="flex flex-col w-4/6 gap-2 bg-transparent-500">
<Input
placeholder="Enter license key"
value={licenseKeyInput}
onChange={(e) => setLicenseKeyInput(e.target.value)}
{!licenseValid && (
<Button
isLoading={isActivating}
size="lg"
isDisabled={!isLicenseEditable || isActivating}
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
spellCheck="false"
endContent={
licenseValid && <CheckIcon className="w-5 h-5 text-green-500" />
}
onPress={async () => {
if (!licenseKeyInput) {
await message('Please enter a license key', {
title: 'Error',
kind: 'error',
})
return
}
setIsActivating(true)
try {
await validateLicense(licenseKeyInput)
} catch (e) {
if (e instanceof Error) {
await message(e.message, {
title: 'Error',
kind: 'error',
okLabel: 'Ok',
})
return
}
await message('An error occurred. Please try again.', {
title: 'Error',
kind: 'error',
okLabel: 'Ok',
})
}
setIsActivating(false)
await message('Your license has been successfully activated.', {
title: 'Congrats!',
kind: 'info',
})
}}
data-focus-visible="false"
/>
>
Activate
</Button>
)}
{licenseValid && (
<Button
isLoading={isRevoking}
color="danger"
variant="ghost"
onPress={async () => {
// usePersistedStore.setState({
// licenseKey: undefined,
// licenseValid: false,
// })
// return
{!licenseValid && (
<Button
fullWidth={true}
isLoading={isActivating}
onPress={async () => {
if (!licenseKeyInput) {
await message('Please enter a license key', {
title: 'Error',
kind: 'error',
})
return
const answer = await ask(
'Are you sure you want to deactivate your license? You can always activate it again later.',
{
title: 'Deactivate License',
kind: 'warning',
}
setIsActivating(true)
try {
await validateLicense(licenseKeyInput)
} catch (e) {
if (e instanceof Error) {
await ask(e.message, {
title: 'Error',
kind: 'error',
okLabel: 'Ok',
cancelLabel: '',
})
return
}
)
await ask('An error occurred. Please try again.', {
if (!answer) {
return
}
setIsRevoking(true)
try {
await revokeMachineLicense(licenseKeyInput)
} catch (e) {
if (e instanceof Error) {
await message(e.message, {
title: 'Error',
kind: 'error',
okLabel: 'Ok',
cancelLabel: '',
})
}
setIsActivating(false)
await message('Your license has been successfully activated.', {
title: 'Congrats!',
kind: 'info',
})
}}
data-focus-visible="false"
>
Activate
</Button>
)}
{licenseValid && (
<Button
fullWidth={true}
isLoading={isRevoking}
color="danger"
variant="ghost"
onPress={async () => {
// usePersistedStore.setState({
// licenseKey: undefined,
// licenseValid: false,
// })
// return
const answer = await ask(
'Are you sure you want to deactivate your license? You can always activate it again later.',
{
title: 'Deactivate License',
kind: 'warning',
}
)
if (!answer) {
return
}
setIsRevoking(true)
try {
await revokeMachineLicense(licenseKeyInput)
} catch (e) {
if (e instanceof Error) {
await ask(e.message, {
title: 'Error',
kind: 'error',
okLabel: 'Ok',
cancelLabel: '',
})
return
}
await ask('An error occurred. Please try again.', {
title: 'Error',
kind: 'error',
okLabel: 'Ok',
cancelLabel: '',
})
}
setIsRevoking(false)
await message('Your license has been successfully deactivated.', {
title: 'License deactivated',
kind: 'info',
await message('An error occurred. Please try again.', {
title: 'Error',
kind: 'error',
okLabel: 'Ok',
})
}}
data-focus-visible="false"
>
Deactivate
</Button>
)}
</div>
}
setIsRevoking(false)
await message('Your license has been successfully deactivated.', {
title: 'License deactivated',
kind: 'info',
})
}}
data-focus-visible="false"
>
Deactivate
</Button>
)}
</div>
<Divider />
<div
className={cn(
'w-full overflow-hidden border-0 border-red-500 left-28 h-[470px] opacity-0 transition-opacity duration-300 ease-in-out',
@@ -777,9 +773,9 @@ function RemotesSection() {
endContent={
<Button
onPress={async () => {
if (!licenseValid && remotes.length >= 3) {
if (!licenseValid && remotes.length >= 4) {
await message(
'Community version does not support adding more than 3 remotes.',
'Community version does not support adding more than 4 remotes.',
{
title: 'Missing license',
kind: 'error',
@@ -802,69 +798,32 @@ function RemotesSection() {
/>
<div className="flex flex-col gap-2 p-4">
{remotes.map((remote) => (
<Card
<RemoteCard
key={remote}
shadow="sm"
isBlurred={true}
className="border-none bg-background/60 dark:bg-default-100/60"
>
<CardBody>
<div className="flex items-center justify-between">
<span>{remote}</span>
<div className="flex flex-row items-center gap-2">
<Button
onPress={() => {
setPickedRemote(remote)
setDefaultsDrawerOpen(true)
}}
// isIconOnly={true}
color="primary"
variant="flat"
data-focus-visible="false"
>
{/* <CableIcon className="w-4 h-4" /> */}
Edit Defaults
</Button>
<Button
onPress={() => {
setPickedRemote(remote)
setEditingDrawerOpen(true)
}}
// isIconOnly={true}
// color="primary"
variant="faded"
data-focus-visible="false"
>
{/* <PencilIcon className="w-4 h-4" /> */}
Config
</Button>
<Button
isIconOnly={true}
color="danger"
variant="light"
size="sm"
onPress={async () => {
const confirmation = await ask(
`Are you sure you want to remove ${remote}? This action cannot be reverted.`,
{ title: `Removing ${remote}`, kind: 'warning' }
)
remote={remote}
onDefaultsPress={() => {
setPickedRemote(remote)
setDefaultsDrawerOpen(true)
}}
onConfigPress={() => {
setPickedRemote(remote)
setEditingDrawerOpen(true)
}}
onDeletePress={async () => {
const confirmation = await ask(
`Are you sure you want to remove ${remote}? This action cannot be reverted.`,
{ title: `Removing ${remote}`, kind: 'warning' }
)
if (!confirmation) {
return
}
if (!confirmation) {
return
}
await deleteRemote(remote)
removeRemote(remote)
await triggerTrayRebuild()
}}
data-focus-visible="false"
>
<Trash2Icon className="w-4 h-4" />
</Button>
</div>
</div>
</CardBody>
</Card>
await deleteRemote(remote)
removeRemote(remote)
await triggerTrayRebuild()
}}
/>
))}
</div>
@@ -906,6 +865,86 @@ function RemotesSection() {
)
}
function RemoteCard({
remote,
onDefaultsPress,
onConfigPress,
onDeletePress,
}: {
remote: string
onDefaultsPress: () => void
onConfigPress: () => void
onDeletePress: () => void
}) {
const [type, setType] = useState<string | null>(null)
const imageUrl = type ? `/icons/${type}.png` : undefined
useEffect(() => {
const loadRemoteConfig = async () => {
try {
const remoteInfo = await getRemote(remote)
setType(remoteInfo.type)
} catch (error) {
console.error('[RemoteCard] Failed to load remote config:', error)
}
}
loadRemoteConfig()
}, [remote])
return (
<Card
key={remote}
shadow="sm"
isBlurred={true}
className="border-none bg-background/60 dark:bg-default-100/60"
isPressable={true}
onPress={onConfigPress}
>
<CardBody>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<img src={imageUrl} className="object-contain w-10 h-10" alt={remote} />
<p className="text-large">{remote}</p>
</div>
<div className="flex flex-row items-center gap-2">
<Button
onPress={onDefaultsPress}
// isIconOnly={true}
color="primary"
variant="flat"
data-focus-visible="false"
>
{/* <CableIcon className="w-4 h-4" /> */}
Edit Defaults
</Button>
<Button
onPress={onConfigPress}
// isIconOnly={true}
// color="primary"
variant="faded"
data-focus-visible="false"
>
{/* <PencilIcon className="w-4 h-4" /> */}
Config
</Button>
<Button
isIconOnly={true}
color="danger"
variant="light"
size="sm"
onPress={onDeletePress}
data-focus-visible="false"
>
<Trash2Icon className="w-4 h-4" />
</Button>
</div>
</div>
</CardBody>
</Card>
)
}
function ConfigSection() {
const licenseValid = usePersistedStore((state) => state.licenseValid)
@@ -1076,11 +1115,10 @@ function ConfigSection() {
}
if (configFile.id === 'default') {
await ask('Default config cannot be deleted', {
await message('Default config cannot be deleted', {
title: 'Error',
kind: 'warning',
okLabel: 'OK',
cancelLabel: '',
})
return
}
+78
View File
@@ -0,0 +1,78 @@
import { Button, Divider } from '@heroui/react'
import { useEffect, useState } from 'react'
import { useStore } from '../../lib/store'
const GREETINGS = [
'Hello',
'こんにちは',
'Salut',
'Cześć',
'Hej',
'Bonjour',
'Olá',
'Ciao',
'你好',
'Hallo',
'Merhaba',
'مرحباً',
]
export default function Startup() {
const [greetingIndex, setGreetingIndex] = useState(0)
const startupStatus = useStore((state) => state.startupStatus)
const isInitialized = startupStatus === 'initialized'
useEffect(() => {
const intervalId = setInterval(() => {
setGreetingIndex((previousIndex) => (previousIndex + 1) % GREETINGS.length)
}, 2500)
return () => clearInterval(intervalId)
}, [])
return (
<div className="flex flex-col h-screen rounded-lg">
<img src="/banner.png" alt="Rclone UI" className="w-full h-auto p-5" />
<Divider />
<div className="flex flex-col w-full h-full justify-evenly">
<div className="flex flex-col items-center w-full gap-8 overflow-visible">
{isInitialized && (
<p className="ml-2 text-2xl">
Rclone has initialized, you can find it in the tray menu!
</p>
)}
{!isInitialized && (
<p className="ml-2 text-3xl">
<span
key={greetingIndex}
className="inline-block align-middle animate-fade-in-up"
>
{GREETINGS[greetingIndex]}
</span>{' '}
<span className="inline-block align-middle">👋</span>
</p>
)}
</div>
<div className="flex flex-col items-center w-full bg-red-500/0">
{isInitialized ? (
<Button
className="w-full max-w-md py-8 text-large"
variant="shadow"
color="primary"
size="lg"
onPress={() => {}}
>
START
</Button>
) : (
<p className="uppercase text-small animate-pulse">Rclone is initalizing</p>
)}
</div>
</div>
</div>
)
}
+12 -1
View File
@@ -8,7 +8,18 @@ export default {
'./node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}',
],
theme: {
extend: {},
extend: {
keyframes: {
'fade-in-up': {
'0%': { opacity: '0', transform: 'translateY(24px)' },
'60%': { opacity: '1' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
},
animation: {
'fade-in-up': 'fade-in-up 900ms cubic-bezier(0.22, 1, 0.36, 1)',
},
},
},
plugins: [heroui()],
}