settings, updater, license, autostart (wip), styling

This commit is contained in:
FTCHD
2025-01-31 16:18:45 +02:00
parent ce1228ae5a
commit 3100df252b
12 changed files with 1483 additions and 377 deletions
+1 -1
View File
@@ -7,7 +7,7 @@
<title>Rclone UI</title>
</head>
<body>
<body class="select-none">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
+83
View File
@@ -0,0 +1,83 @@
import { invoke } from '@tauri-apps/api/core'
import { fetch } from '@tauri-apps/plugin-http'
import { usePersistedStore } from './store'
export async function validateLicense(licenseKey: string) {
let id
try {
id = await invoke('get_uid')
} catch (e) {
console.error(JSON.stringify(e))
throw new Error('Failed to build unique identifier. Please try again later.')
}
if (!id) {
throw new Error('Failed to build unique identifier. Please try again later.')
}
const validationResponse = await fetch('https://rcloneui.com/api/v1/validate', {
method: 'POST',
body: JSON.stringify({
licenseKey,
id,
}),
})
.then((r) => r.json())
.catch((e) => {
console.error(JSON.stringify(e))
throw new Error('Failed to validate license. Are you connected to the internet?')
})
// console.log(JSON.stringify(validationResponse))
if (validationResponse.error) {
throw new Error(validationResponse.error)
}
if (!validationResponse.valid) {
throw new Error('Invalid license key. Please check your license key and try again.')
}
usePersistedStore.setState({ licenseKey, licenseValid: true })
}
export async function revokeLicense(licenseKey: string) {
let id
try {
id = await invoke('get_uid')
} catch (e) {
console.error(JSON.stringify(e))
throw new Error('Failed to build unique identifier. Please try again later.')
}
if (!id) {
throw new Error('Failed to build unique identifier. Please try again later.')
}
const revocationResponse = await fetch('https://rcloneui.com/api/v1/revoke', {
method: 'POST',
body: JSON.stringify({
licenseKey,
id,
}),
})
.then((r) => r.json())
.catch((e) => {
console.error(JSON.stringify(e))
throw new Error('Failed to revoke license. Are you connected to the internet?')
})
// console.log(JSON.stringify(revocationResponse))
if (revocationResponse.error) {
throw new Error(revocationResponse.error)
}
if (!revocationResponse.revoked) {
throw new Error('Failed to revoke license. Please check your license key and try again.')
}
usePersistedStore.setState({ licenseKey: undefined, licenseValid: false })
}
+75 -4
View File
@@ -1,12 +1,12 @@
import { getCurrentWindow } from '@tauri-apps/api/window'
import { confirm, message } from '@tauri-apps/plugin-dialog'
import {} from '@tauri-apps/plugin-fs'
import { ask, message } from '@tauri-apps/plugin-dialog'
import { debug, error, info, trace, warn } from '@tauri-apps/plugin-log'
import { platform } from '@tauri-apps/plugin-os'
import { exit } from '@tauri-apps/plugin-process'
import { validateLicense } from './lib/license'
import { listRemotes } from './lib/rclone/api'
import { initRclone } from './lib/rclone/init'
import { useStore } from './lib/store'
import { usePersistedStore, useStore } from './lib/store'
import { initLoadingTray, initTray, rebuildTrayMenu } from './lib/tray'
// forward console logs in webviews to the tauri logger, so they show up in terminal
@@ -29,6 +29,61 @@ forwardConsole('info', info)
forwardConsole('warn', warn)
forwardConsole('error', error)
async function waitForHydration() {
console.log('waiting for store hydration')
await new Promise((resolve) => setTimeout(resolve, 50))
if (!usePersistedStore.persist.hasHydrated()) {
await waitForHydration()
}
console.log('store hydrated')
}
async function validateInstance() {
const isOnline = navigator.onLine
if (!isOnline) {
await ask(
'You are not connected to the internet. Please check your internet connection and try again.',
{
title: 'Error',
kind: 'error',
okLabel: 'Exit',
cancelLabel: '',
}
)
return await exit(0)
}
const licenseKey = usePersistedStore.getState().licenseKey
if (!licenseKey) {
usePersistedStore.setState({ licenseValid: false })
return
}
try {
await validateLicense(licenseKey)
} catch (e) {
if (e instanceof Error) {
await ask(e.message, {
title: 'Error',
kind: 'error',
okLabel: 'Exit',
cancelLabel: '',
})
await exit(0)
return
}
await ask('An error occurred while validating your license. Please try again.', {
title: 'Error',
kind: 'error',
okLabel: 'Exit',
cancelLabel: '',
})
await exit(0)
}
}
async function startRclone() {
try {
const remotes = await listRemotes()
@@ -43,9 +98,11 @@ async function startRclone() {
try {
rclone = await initRclone()
} catch (error) {
await confirm(error.message || 'Failed to provision rclone, please try again later.', {
await ask(error.message || 'Failed to provision rclone, please try again later.', {
title: 'Error',
kind: 'error',
okLabel: 'Exit',
cancelLabel: '',
})
return await exit(0)
}
@@ -110,10 +167,24 @@ getCurrentWindow().listen('tauri://close-requested', async (e) => {
getCurrentWindow().listen('rebuild-tray', async (e) => {
console.log('(main) window rebuild-tray requested')
// wait for store to be updated
await new Promise((resolve) => setTimeout(resolve, 250))
await rebuildTrayMenu()
})
// function handleNetworkStatusChange() {
// console.log('Network status changed. Online:', navigator.onLine)
// // rebuildTrayMenu().catch(console.error)
// }
// window.addEventListener('online', handleNetworkStatusChange)
// window.addEventListener('offline', handleNetworkStatusChange)
initLoadingTray()
.then(() => waitForHydration())
.then(() => validateInstance())
.then(() => startRclone())
.then(() => initTray())
.catch(console.error)
+18 -9
View File
@@ -10,6 +10,7 @@
"dependencies": {
"@nextui-org/react": "^2.6.11",
"@tauri-apps/api": "~2.2.0",
"@tauri-apps/plugin-autostart": "^2.2.0",
"@tauri-apps/plugin-dialog": "^2.2.0",
"@tauri-apps/plugin-fs": "^2.2.0",
"@tauri-apps/plugin-http": "^2.2.0",
@@ -17,10 +18,10 @@
"@tauri-apps/plugin-notification": "^2.2.1",
"@tauri-apps/plugin-opener": "^2.2.5",
"@tauri-apps/plugin-os": "^2.2.0",
"@tauri-apps/plugin-positioner": "^2.2.0",
"@tauri-apps/plugin-process": "^2.2.0",
"@tauri-apps/plugin-shell": "^2.2.0",
"@tauri-apps/plugin-store": "^2.2.0",
"@tauri-apps/plugin-updater": "^2.4.0",
"@tauri-apps/plugin-window-state": "^2.2.0",
"framer-motion": "^11.17.0",
"lucide-react": "^0.471.0",
@@ -4185,6 +4186,14 @@
"node": ">= 10"
}
},
"node_modules/@tauri-apps/plugin-autostart": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-autostart/-/plugin-autostart-2.2.0.tgz",
"integrity": "sha512-TzVcDZdOvdot0avkpstUWJKKEl4cyxLpFB9DZZRW5zH8k+Bv8IVJmO0zyYuw+7oKlGdHOINbD/7Je7GHMViw5w==",
"dependencies": {
"@tauri-apps/api": "^2.0.0"
}
},
"node_modules/@tauri-apps/plugin-dialog": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.2.0.tgz",
@@ -4241,14 +4250,6 @@
"@tauri-apps/api": "^2.0.0"
}
},
"node_modules/@tauri-apps/plugin-positioner": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-positioner/-/plugin-positioner-2.2.0.tgz",
"integrity": "sha512-3JIWqV4U1US4nmM4PGmAHODq0ltkJ91MyANR033elKSSpX3AqKnVQnLz76xThBFHjChJrZwsXASpc2Inpu+cTg==",
"dependencies": {
"@tauri-apps/api": "^2.0.0"
}
},
"node_modules/@tauri-apps/plugin-process": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-process/-/plugin-process-2.2.0.tgz",
@@ -4273,6 +4274,14 @@
"@tauri-apps/api": "^2.0.0"
}
},
"node_modules/@tauri-apps/plugin-updater": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.4.0.tgz",
"integrity": "sha512-BkeKN2WObAjobf2G77HyW/DxAfI0In+VSqWGnw/0cVPlM+VmA7fw9dKUnSunryZOG7ys9y07tj7FQa1ABMXGZQ==",
"dependencies": {
"@tauri-apps/api": "^2.0.0"
}
},
"node_modules/@tauri-apps/plugin-window-state": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-window-state/-/plugin-window-state-2.2.0.tgz",
+2
View File
@@ -21,6 +21,7 @@
"dependencies": {
"@nextui-org/react": "^2.6.11",
"@tauri-apps/api": "~2.2.0",
"@tauri-apps/plugin-autostart": "^2.2.0",
"@tauri-apps/plugin-dialog": "^2.2.0",
"@tauri-apps/plugin-fs": "^2.2.0",
"@tauri-apps/plugin-http": "^2.2.0",
@@ -31,6 +32,7 @@
"@tauri-apps/plugin-process": "^2.2.0",
"@tauri-apps/plugin-shell": "^2.2.0",
"@tauri-apps/plugin-store": "^2.2.0",
"@tauri-apps/plugin-updater": "^2.4.0",
"@tauri-apps/plugin-window-state": "^2.2.0",
"framer-motion": "^11.17.0",
"lucide-react": "^0.471.0",
+552 -292
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -35,9 +35,12 @@ tauri-plugin-notification = { version = "2.0.0", features = [ "windows7-compat"
tauri-plugin-os = "2"
fix-path-env = { git = "https://github.com/tauri-apps/fix-path-env-rs" }
zip = "0.6.6"
machine-uid = "0.5.3"
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-autostart = "2"
tauri-plugin-single-instance = "2.2.0"
tauri-plugin-updater = "2"
[target."cfg(target_os = \"macos\")".dependencies]
cocoa = "0.26"
+12
View File
@@ -242,6 +242,15 @@
},
"opener:default",
"updater:allow-download",
"updater:allow-download-and-install",
"updater:allow-check",
"updater:allow-install",
"autostart:allow-enable",
"autostart:allow-disable",
"autostart:allow-is-enabled",
{
"identifier": "http:default",
"allow": [
@@ -253,6 +262,9 @@
},
{
"url": "https://github.com/**"
},
{
"url": "https://rcloneui.com/**"
}
]
},
+46 -41
View File
@@ -6,71 +6,76 @@
// cwd: String,
// }
use machine_uid;
use std::fs::{self, File};
use std::path::Path;
use zip::ZipArchive;
#[tauri::command]
fn unzip_file(zip_path: &str, output_folder: &str) -> Result<(), String> {
// Open the zip file
let file = File::open(zip_path).map_err(|e| e.to_string())?;
// Create output directory if it doesn't exist
fs::create_dir_all(output_folder).map_err(|e| e.to_string())?;
// Create ZIP archive reader
let mut archive = ZipArchive::new(file).map_err(|e| e.to_string())?;
// Extract everything
for i in 0..archive.len() {
let mut file = archive.by_index(i).map_err(|e| e.to_string())?;
let outpath = Path::new(output_folder).join(file.name());
if file.name().ends_with('/') {
fs::create_dir_all(&outpath).map_err(|e| e.to_string())?;
} else {
if let Some(p) = outpath.parent() {
fs::create_dir_all(p).map_err(|e| e.to_string())?;
}
let mut outfile = File::create(&outpath).map_err(|e| e.to_string())?;
std::io::copy(&mut file, &mut outfile).map_err(|e| e.to_string())?;
}
// Get and set permissions (Unix only)
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Some(mode) = file.unix_mode() {
fs::set_permissions(&outpath, fs::Permissions::from_mode(mode))
.map_err(|e| e.to_string())?;
}
}
}
Ok(())
// Open the zip file
let file = File::open(zip_path).map_err(|e| e.to_string())?;
// Create output directory if it doesn't exist
fs::create_dir_all(output_folder).map_err(|e| e.to_string())?;
// Create ZIP archive reader
let mut archive = ZipArchive::new(file).map_err(|e| e.to_string())?;
// Extract everything
for i in 0..archive.len() {
let mut file = archive.by_index(i).map_err(|e| e.to_string())?;
let outpath = Path::new(output_folder).join(file.name());
if file.name().ends_with('/') {
fs::create_dir_all(&outpath).map_err(|e| e.to_string())?;
} else {
if let Some(p) = outpath.parent() {
fs::create_dir_all(p).map_err(|e| e.to_string())?;
}
let mut outfile = File::create(&outpath).map_err(|e| e.to_string())?;
std::io::copy(&mut file, &mut outfile).map_err(|e| e.to_string())?;
}
// Get and set permissions (Unix only)
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Some(mode) = file.unix_mode() {
fs::set_permissions(&outpath, fs::Permissions::from_mode(mode))
.map_err(|e| e.to_string())?;
}
}
}
Ok(())
}
#[tauri::command]
fn get_arch() -> String {
let arch = std::env::consts::ARCH;
match arch {
"aarch64" => "arm64".to_string(),
"x86_64" => "amd64".to_string(),
"i386" => "386".to_string(),
"i386" => "386".to_string(),
_ => "unknown".to_string(),
}
}
#[tauri::command]
fn get_uid() -> String {
return machine_uid::get().unwrap();
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let mut app = tauri::Builder::default()
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_process::init())
// .plugin(tauri_plugin_autostart::init(tauri_plugin_autostart::MacosLauncher::LaunchAgent, Some(vec![])))
// .plugin(tauri_plugin_single_instance::init(|app, argv, cwd| {
// println!("{}, {argv:?}, {cwd}", app.package_info().name);
// // app.emit("single-instance", Payload { args: argv, cwd }).unwrap();
@@ -88,7 +93,7 @@ pub fn run() {
.plugin(tauri_plugin_log::Builder::new().build())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![unzip_file, get_arch])
.invoke_handler(tauri::generate_handler![unzip_file, get_arch, get_uid])
.setup(|_app| Ok(()))
// .setup(|app| {
// if cfg!(debug_assertions) {
+14 -1
View File
@@ -32,7 +32,7 @@
},
"bundle": {
"active": true,
"targets": "all",
"targets": ["nsis", "dmg", "app", "appimage", "deb", "rpm"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
@@ -79,6 +79,19 @@
},
"hardenedRuntime": true,
"minimumSystemVersion": "10.13"
},
"windows": {
"nsis": {
"installMode": "both"
},
"signCommand": "trusted-signing-cli %1 -e https://eus.codesigning.azure.net -a sign-1 -c Sign1"
},
"createUpdaterArtifacts": true
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDIyNDFENEZGNjFDNTBGOEYKUldTUEQ4VmgvOVJCSWhVZmw0enhmcW1kWFk3TS9mMzBDRjVEZWdxKzQ5ZmRhTlYvT2gvdFNMbE8K",
"endpoints": ["https://github.com/FTCHD/rclone-ui/releases/latest/download/latest.json"]
}
}
}
+7
View File
@@ -20,3 +20,10 @@ html {
body {
/* background-color: red; */
}
/* * {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
} */
+670 -29
View File
@@ -1,23 +1,607 @@
import { Button, Card, CardBody } from '@nextui-org/react'
import { confirm } from '@tauri-apps/plugin-dialog'
import { CableIcon, PencilIcon, Plus, Trash2Icon } from 'lucide-react'
import { useState } from 'react'
import { Button, Card, CardBody, Checkbox, Chip, Input, Tab, Tabs } from '@nextui-org/react'
import { ask, message } from '@tauri-apps/plugin-dialog'
import { openUrl } from '@tauri-apps/plugin-opener'
import { relaunch } from '@tauri-apps/plugin-process'
import { type Update, check } from '@tauri-apps/plugin-updater'
import {
CheckIcon,
CogIcon,
EyeIcon,
MedalIcon,
PlusIcon,
ServerIcon,
Trash2Icon,
} from 'lucide-react'
import { useEffect, useState } from 'react'
import { revokeLicense, validateLicense } from '../../lib/license'
import { deleteRemote } from '../../lib/rclone/api'
import { useStore } from '../../lib/store'
import { usePersistedStore, useStore } from '../../lib/store'
import { triggerTrayRebuild } from '../../lib/tray'
import RemoteCreateDrawer from '../components/RemoteCreateDrawer'
import RemoteDefaultsDrawer from '../components/RemoteDefaultsDrawer'
import RemoteEditDrawer from '../components/RemoteEditDrawer'
function Settings() {
const settingsPass = usePersistedStore((state) => state.settingsPass)
const [passwordCheckInput, setPasswordCheckInput] = useState('')
const [passwordCheckPassed, setPasswordCheckPassed] = useState(false)
const [passwordVisible, setPasswordVisible] = useState(false)
if (settingsPass && !passwordCheckPassed) {
return (
<div className="flex flex-col items-center justify-center w-screen h-screen gap-4 overflow-hidden animate-fade-in">
<Input
placeholder="Enter pin or password"
value={passwordCheckInput}
onChange={(e) => setPasswordCheckInput(e.target.value)}
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
spellCheck={false}
type={passwordVisible ? 'text' : 'password'}
fullWidth={false}
size="lg"
endContent={
<Button
onPress={() => setPasswordVisible(!passwordVisible)}
isIconOnly={true}
variant="light"
data-focus-visible="false"
>
<EyeIcon className="w-5 h-5" />
</Button>
}
/>
<Button
onPress={async () => {
if (passwordCheckInput === settingsPass) {
setPasswordCheckPassed(true)
return
}
await message('The password you entered is incorrect.', {
title: 'Login failed',
kind: 'error',
})
}}
data-focus-visible="false"
color="primary"
>
Open
</Button>
</div>
)
}
return (
<div className="flex flex-col">
<RemotesSection />
<div className="flex flex-col w-screen h-screen gap-0 overflow-hidden animate-fade-in">
<Tabs
aria-label="Options"
isVertical={true}
variant="light"
destroyInactiveTabPanel={false}
disableAnimation={true}
className="flex-shrink-0 w-3/12 h-screen px-2 py-4 border-r border-neutral-700"
classNames={{
tabList: 'w-full gap-3',
}}
size="lg"
defaultSelectedKey="general"
color="secondary"
>
<Tab
key="general"
title={
<div className="flex items-center space-x-2">
<CogIcon className="w-5 h-5" />
<span>General</span>
</div>
}
data-focus-visible="false"
className="w-full max-h-screen p-0 overflow-scroll overscroll-none"
>
<GeneralSection />
</Tab>
<Tab
key="remotes"
title={
<div className="flex items-center space-x-2">
<ServerIcon className="w-5 h-5" />
<span>Remotes</span>
</div>
}
data-focus-visible="false"
className="w-full max-h-screen p-0 overflow-scroll overscroll-none"
>
<RemotesSection />
</Tab>
<Tab
key="license"
title={
<div className="flex items-center space-x-2">
<MedalIcon className="w-5 h-5" />
<span>License</span>
</div>
}
data-focus-visible="false"
className="w-full max-h-screen p-0 overflow-scroll overscroll-none"
>
<LicenseSection />
</Tab>
{/* <Tab
key="hosts"
title={
<div className="flex items-center space-x-2">
<SwatchBookIcon className="w-5 h-5" />
<span>Hosts</span>
</div>
}
data-focus-visible="false"
className="w-full max-h-screen p-0 overflow-scroll overscroll-none"
>
<div className="flex flex-col gap-4">
<BaseHeader title="Hosts" />
</div>
</Tab> */}
</Tabs>
</div>
)
}
const RemotesSection = () => {
function GeneralSection() {
const settingsPass = usePersistedStore((state) => state.settingsPass)
const setSettingsPass = usePersistedStore((state) => state.setSettingsPass)
const [passwordInput, setPasswordInput] = useState('')
const [passwordVisible, setPasswordVisible] = useState(false)
const disabledActions = usePersistedStore((state) => state.disabledActions)
const setDisabledActions = usePersistedStore((state) => state.setDisabledActions)
const [update, setUpdate] = useState<Update | null>(null)
useEffect(() => {
// needed since the first value from the persisted store is undefined
setPasswordInput(settingsPass || '')
}, [settingsPass])
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
useEffect(() => {
triggerTrayRebuild()
}, [disabledActions])
return (
<div className="flex flex-col gap-10">
<BaseHeader title="General" />
<div className="flex flex-row justify-center w-full gap-8 px-8">
<div className="flex flex-col items-end flex-1 gap-2 bg-transparent-500">
<h3 className="font-medium">Password</h3>
<p className="text-xs text-neutral-500 text-end">
Set a password to protect this settings panel
</p>
</div>
<div className="flex flex-col w-3/5 gap-2 bg-transparent-500">
<Input
placeholder="Enter password"
value={passwordInput}
onChange={(e) => setPasswordInput(e.target.value)}
size="lg"
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
spellCheck={false}
type={passwordVisible ? 'text' : 'password'}
endContent={
passwordInput && (
<Button
onPress={() => setPasswordVisible(!passwordVisible)}
isIconOnly={true}
variant="light"
data-focus-visible="false"
>
<EyeIcon className="w-5 h-5" />
</Button>
)
}
data-focus-visible="false"
/>
<div className="flex flex-row gap-2">
<Button
size="sm"
fullWidth={true}
onPress={async () => {
setSettingsPass(passwordInput)
}}
data-focus-visible="false"
>
Change password
</Button>
<Button
size="sm"
color="danger"
fullWidth={true}
onPress={async () => {
setPasswordInput('')
setSettingsPass(undefined)
}}
data-focus-visible="false"
>
Remove password
</Button>
</div>
</div>
</div>
<div className="flex flex-row justify-center w-full gap-8 px-8">
<div className="flex flex-col items-end flex-grow gap-2 bg-transparent-500">
<h3 className="font-medium">Options</h3>
</div>
<div className="flex flex-col w-3/5 gap-3 bg-transparent-500">
<Checkbox isDisabled={true}>
<div className="flex flex-row gap-2">
<p>Start on boot</p>
<Chip size="sm" color="primary">
Coming soon
</Chip>
</div>
</Checkbox>
<Checkbox
isSelected={!disabledActions?.includes('tray-mount')}
onValueChange={(value) => {
if (value) {
setDisabledActions(
disabledActions?.filter((action) => action !== 'tray-mount') ||
[]
)
} else {
setDisabledActions([...(disabledActions || []), 'tray-mount'])
}
}}
>
Show <span className="font-mono text-blue-300">Mount</span> option
</Checkbox>
<Checkbox
isSelected={!disabledActions?.includes('tray-sync')}
onValueChange={(value) => {
if (value) {
setDisabledActions(
disabledActions?.filter((action) => action !== 'tray-sync') ||
[]
)
} else {
setDisabledActions([...(disabledActions || []), 'tray-sync'])
}
}}
>
Show <span className="font-mono text-blue-300">Sync</span> option
</Checkbox>
<Checkbox
isSelected={!disabledActions?.includes('tray-copy')}
onValueChange={(value) => {
if (value) {
setDisabledActions(
disabledActions?.filter((action) => action !== 'tray-copy') ||
[]
)
} else {
setDisabledActions([...(disabledActions || []), 'tray-copy'])
}
}}
>
Show <span className="font-mono text-blue-300">Copy</span> option
</Checkbox>
</div>
{/* <Button
onPress={async () => {
const enabled = await isEnabled()
if (enabled) {
await disable()
} else {
await enable()
}
}}
>
Start on boot
</Button>
<Button
onPress={async () => {
const enabled = await isEnabled()
alert(enabled ? 'Enabled' : 'Disabled')
}}
>
Get status
</Button> */}
</div>
<div className="flex flex-row justify-center w-full gap-8 px-8">
<div className="flex flex-col items-end flex-grow gap-2 bg-transparent-500">
<h3 className="font-medium">Update</h3>
</div>
<div className="flex flex-col w-3/5 gap-3 bg-transparent-500">
{update ? (
<Button
onPress={async () => {
let downloaded = 0
let contentLength = 0
await update.downloadAndInstall((event) => {
// biome-ignore lint/style/useDefaultSwitchClause: <explanation>
switch (event.event) {
case 'Started': {
contentLength = event.data.contentLength || 0
console.log(
`started downloading ${event.data.contentLength} bytes`
)
break
}
case 'Progress': {
downloaded += event.data.chunkLength
console.log(
`downloaded ${downloaded} from ${contentLength}`
)
break
}
case 'Finished':
console.log('download finished')
break
}
})
const answer = await ask('Update installed. Ready to restart?', {
title: 'Update',
kind: 'info',
okLabel: 'Restart',
cancelLabel: '',
})
if (!answer) {
return
}
await relaunch()
}}
>
Check for updates
</Button>
) : (
<Button
onPress={async () => {
const receivedUpdate = await check()
if (!receivedUpdate) {
return
}
console.log(
`found update ${receivedUpdate.version} from ${receivedUpdate.date} with notes ${receivedUpdate.body}`
)
setUpdate(receivedUpdate)
}}
>
Tap to update
</Button>
)}
</div>
</div>
</div>
)
}
function LicenseSection() {
const [isLicenseEditable, setIsLicenseEditable] = useState(false)
const licenseKey = usePersistedStore((state) => state.licenseKey)
const licenseValid = usePersistedStore((state) => state.licenseValid)
const [isRevoking, setIsRevoking] = useState(false)
const [isActivating, setIsActivating] = useState(false)
const [licenseKeyInput, setLicenseKeyInput] = useState('')
useEffect(() => {
setLicenseKeyInput(licenseKey || '')
setIsLicenseEditable(!licenseKey)
}, [licenseKey])
return (
<div className="flex flex-col gap-10">
<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-col w-4/6 gap-2 bg-transparent-500">
<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"
/>
{!licenseValid && (
<Button
fullWidth={true}
isLoading={isActivating}
onPress={async () => {
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.', {
title: 'Error',
kind: 'error',
okLabel: 'Ok',
cancelLabel: '',
})
} finally {
setIsActivating(false)
}
await message('Your license has been activated successfully.', {
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 revokeLicense(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: '',
})
} finally {
setIsRevoking(false)
}
await message('Your license has been deactivated.', {
title: 'License deactivated',
kind: 'info',
})
}}
data-focus-visible="false"
>
Deactivate
</Button>
)}
</div>
</div>
<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">Buy</h3>
<p className="text-xs text-neutral-500 text-end">
Includes access to future features and updates.
</p>
</div>
<div className="flex flex-col w-4/6 gap-3 bg-transparent-500">
<Button
size="lg"
fullWidth={true}
color="primary"
variant="shadow"
onPress={async () => {
await openUrl('https://buy.stripe.com/test_dR67uygoIcYJ6hG4gg')
}}
>
Lifetime License $7
</Button>
</div>
</div>
{/* <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">Features</h3>
<p className="text-xs text-neutral-500 text-end">What you get with a license</p>
</div>
<div className="flex flex-col w-4/6 gap-2 bg-transparent-500">
<Card className="border-none bg-background/60 dark:bg-default-100/60">
<CardBody>
<ul className="flex flex-col gap-3">
<li className="flex items-center gap-2">
<CheckIcon className="w-5 h-5 text-green-500" />
<span>Work with more than 3 remotes</span>
</li>
<li className="flex items-center gap-2">
<CheckIcon className="w-5 h-5 text-green-500" />
<span>Runs on up to 5 devices</span>
</li>
<li className="flex items-center gap-2">
<CheckIcon className="w-5 h-5 text-green-500" />
<span>File Commander</span>
</li>
<li className="flex items-center gap-2">
<CheckIcon className="w-5 h-5 text-green-500" />
<span>Mobile Client</span>
</li>
<li className="flex items-center gap-2">
<CheckIcon className="w-5 h-5 text-green-500" />
<span>Supporting Open Source</span>
</li>
</ul>
</CardBody>
</Card>
</div>
</div> */}
</div>
)
}
function RemotesSection() {
const licenseValid = usePersistedStore((state) => state.licenseValid)
const remotes = useStore((state) => state.remotes)
const removeRemote = useStore((state) => state.removeRemote)
@@ -35,6 +619,7 @@ const RemotesSection = () => {
onPress={() => setCreatingDrawerOpen(true)}
color="primary"
data-focus-visible="false"
variant="shadow"
>
Create Remote
</Button>
@@ -44,54 +629,79 @@ const RemotesSection = () => {
return (
<div className="flex flex-col gap-4">
<div className="sticky top-0 z-50 flex items-center justify-between p-4 bg-neutral-900/50 backdrop-blur-lg">
<h2 className="text-xl font-semibold">Remotes</h2>
<Button
onPress={() => setCreatingDrawerOpen(true)}
isIconOnly={true}
variant="light"
data-focus-visible="false"
>
<Plus className="w-5 h-5" />
</Button>
</div>
<BaseHeader
title="Remotes"
endContent={
<Button
onPress={async () => {
if (!licenseValid) {
await message(
'Community version does not support adding more than 3 remotes.',
{
title: 'Missing license',
kind: 'error',
}
)
return
}
setCreatingDrawerOpen(true)
}}
isIconOnly={true}
variant="faded"
color="primary"
data-focus-visible="false"
size="sm"
>
<PlusIcon className="w-4 h-4" />
</Button>
}
/>
<div className="flex flex-col gap-2 p-4">
{remotes.map((remote) => (
<Card key={remote} shadow="sm">
<Card
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="space-x-2">
<div className="flex flex-row items-center gap-2">
<Button
onPress={() => {
setPickedRemote(remote)
setDefaultsDrawerOpen(true)
}}
isIconOnly={true}
// isIconOnly={true}
color="primary"
variant="light"
variant="flat"
data-focus-visible="false"
>
<CableIcon className="w-4 h-4" />
{/* <CableIcon className="w-4 h-4" /> */}
Edit Defaults
</Button>
<Button
onPress={() => {
setPickedRemote(remote)
setEditingDrawerOpen(true)
}}
isIconOnly={true}
color="primary"
variant="light"
// isIconOnly={true}
// color="primary"
variant="faded"
data-focus-visible="false"
>
<PencilIcon className="w-4 h-4" />
{/* <PencilIcon className="w-4 h-4" /> */}
Config
</Button>
<Button
isIconOnly={true}
color="danger"
variant="light"
size="sm"
onPress={async () => {
const confirmation = await confirm(
const confirmation = await ask(
`Are you sure you want to remove ${remote}? This action cannot be reverted.`,
{ title: `Removing ${remote}`, kind: 'warning' }
)
@@ -153,4 +763,35 @@ const RemotesSection = () => {
)
}
function BaseHeader({ title, endContent }: { title: string; endContent?: React.ReactNode }) {
return (
<div className="sticky top-0 z-50 flex items-center justify-between p-4 h-14 bg-neutral-900/50 backdrop-blur-lg">
<h2 className="text-xl font-semibold">{title}</h2>
{endContent}
</div>
)
}
export default Settings
// {/* <Button
// onPress={async () => {
// const enabled = await isEnabled()
// if (enabled) {
// await disable()
// } else {
// await enable()
// }
// }}
// >
// Start on boot
// </Button>
// <Button
// onPress={async () => {
// const enabled = await isEnabled()
// alert(enabled ? 'Enabled' : 'Disabled')
// }}
// >
// Get status
// </Button> */}