mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-06 01:17:48 +00:00
feat(release): add cross-platform packaging
Add target-aware engine provisioning, platform package configs, and CI/release verification for macOS arm64, Windows x64, and Linux AppImage.
This commit is contained in:
+101
-22
@@ -17,6 +17,7 @@ import SpeedLimiterView from "./components/SpeedLimiterView";
|
||||
import LogsView from "./components/LogsView";
|
||||
import { useToast } from "./contexts/ToastContext";
|
||||
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||
import { usePlatformInfo } from './utils/platform';
|
||||
|
||||
let automaticUpdateCheckStarted = false;
|
||||
const processingScheduleKeys = new Set<string>();
|
||||
@@ -40,6 +41,8 @@ const getScheduledQueueIds = () => {
|
||||
};
|
||||
|
||||
function App() {
|
||||
const platform = usePlatformInfo();
|
||||
const platformOsRef = useRef(platform.os);
|
||||
const [filter, setFilter] = useState<SidebarFilter>('all');
|
||||
const [coreReady, setCoreReady] = useState(false);
|
||||
|
||||
@@ -110,6 +113,10 @@ function App() {
|
||||
|
||||
const { addToast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
platformOsRef.current = platform.os;
|
||||
}, [platform.os]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const initialize = async () => {
|
||||
@@ -177,6 +184,11 @@ function App() {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to hydrate extension pairing token:', error);
|
||||
addToast({
|
||||
message: `Secure credential persistence is unavailable. Browser pairing works for this session only: ${String(error)}`,
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
}
|
||||
};
|
||||
void initialize();
|
||||
@@ -238,8 +250,10 @@ function App() {
|
||||
}, [maxConcurrentDownloads]);
|
||||
|
||||
useEffect(() => {
|
||||
invoke('update_dock_badge', { count: showDockBadge ? activeDownloadCount : 0 }).catch(() => {});
|
||||
}, [showDockBadge, activeDownloadCount]);
|
||||
if (platform.os === 'macos') {
|
||||
invoke('update_dock_badge', { count: showDockBadge ? activeDownloadCount : 0 }).catch(() => {});
|
||||
}
|
||||
}, [platform.os, showDockBadge, activeDownloadCount]);
|
||||
|
||||
useEffect(() => {
|
||||
invoke('set_prevent_sleep', {
|
||||
@@ -360,23 +374,72 @@ function App() {
|
||||
isActionable: true
|
||||
});
|
||||
} else if (settings.scheduler.postQueueAction !== 'none') {
|
||||
invoke('perform_system_action', { action: settings.scheduler.postQueueAction }).catch(error => {
|
||||
console.error('Scheduled post action failed:', error);
|
||||
addToast({
|
||||
message: `Scheduled system action failed: ${String(error)}`,
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
const action = settings.scheduler.postQueueAction;
|
||||
let cancelled = false;
|
||||
addToast({
|
||||
variant: 'warning',
|
||||
isActionable: true,
|
||||
message: (
|
||||
<div className="flex items-center gap-3">
|
||||
<span>{action === 'shutdown' ? 'Shut down' : action === 'restart' ? 'Restart' : 'Sleep'} in 10 seconds.</span>
|
||||
<button
|
||||
type="button"
|
||||
className="app-button px-2 py-1"
|
||||
onClick={() => {
|
||||
cancelled = true;
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
});
|
||||
window.setTimeout(() => {
|
||||
if (cancelled) return;
|
||||
const activeTransfers = useDownloadStore.getState().downloads.some(download =>
|
||||
isActiveDownloadStatus(download.status)
|
||||
);
|
||||
if (activeTransfers) {
|
||||
addToast({
|
||||
message: 'System action cancelled because another download is active.',
|
||||
variant: 'warning',
|
||||
isActionable: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
invoke('perform_system_action', { action }).catch(error => {
|
||||
console.error('Scheduled post action failed:', error);
|
||||
addToast({
|
||||
message: `Scheduled system action failed: ${String(error)}`,
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
});
|
||||
}, 10_000);
|
||||
}
|
||||
}, [addToast, downloads, schedulerRunning, schedulerActiveDownloadIds]);
|
||||
|
||||
useEffect(() => {
|
||||
const initNotifications = async () => {
|
||||
if (!useSettingsStore.getState().showNotifications) return;
|
||||
let permissionGranted = await isPermissionGranted();
|
||||
if (!permissionGranted) {
|
||||
await requestPermission();
|
||||
try {
|
||||
const permissionGranted = await isPermissionGranted();
|
||||
if (!permissionGranted) {
|
||||
const permission = await requestPermission();
|
||||
if (permission !== 'granted') {
|
||||
addToast({
|
||||
message: 'System notifications are disabled for Firelink.',
|
||||
variant: 'warning',
|
||||
isActionable: true
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
addToast({
|
||||
message: `Could not configure notifications: ${String(error)}`,
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -387,7 +450,7 @@ function App() {
|
||||
return useSettingsStore.persist.onFinishHydration(() => {
|
||||
void initNotifications();
|
||||
});
|
||||
}, [showNotifications]);
|
||||
}, [addToast, showNotifications]);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePaste = (e: ClipboardEvent) => {
|
||||
@@ -450,17 +513,33 @@ function App() {
|
||||
|
||||
const item = useDownloadStore.getState().downloads.find(d => d.id === event.payload.id);
|
||||
const fileName = item?.fileName || 'A file';
|
||||
const platformOs = platformOsRef.current;
|
||||
const sound = settings.playCompletionSound
|
||||
? platformOs === 'macos'
|
||||
? 'Ping'
|
||||
: platformOs === 'linux'
|
||||
? 'message-new-instant'
|
||||
: undefined
|
||||
: undefined;
|
||||
if (event.payload.status === 'completed') {
|
||||
sendNotification({
|
||||
title: 'Download Complete',
|
||||
body: `${fileName} has finished downloading.`,
|
||||
sound: settings.playCompletionSound ? 'default' : undefined
|
||||
});
|
||||
try {
|
||||
sendNotification({
|
||||
title: 'Download Complete',
|
||||
body: `${fileName} has finished downloading.`,
|
||||
sound
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Completion notification failed:', error);
|
||||
}
|
||||
} else {
|
||||
sendNotification({
|
||||
title: 'Download Failed',
|
||||
body: `${fileName} failed to download.`,
|
||||
});
|
||||
try {
|
||||
sendNotification({
|
||||
title: 'Download Failed',
|
||||
body: `${fileName} failed to download.`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failure notification failed:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type PairingTokenHydration = { token: string, tokenChanged: boolean, };
|
||||
export type PairingTokenHydration = { token: string, tokenChanged: boolean, persistent: boolean, error: string | null, };
|
||||
|
||||
@@ -8,4 +8,4 @@ import type { SettingsTab } from "./SettingsTab";
|
||||
import type { SiteLogin } from "./SiteLogin";
|
||||
import type { Theme } from "./Theme";
|
||||
|
||||
export type PersistedSettings = { theme: Theme, baseDownloadFolder: string, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, maxConcurrentDownloads: number, globalSpeedLimit: string, isSidebarVisible: boolean, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, preventsSleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array<SiteLogin>, autoCheckUpdates: boolean, };
|
||||
export type PersistedSettings = { theme: Theme, baseDownloadFolder: string, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, isSidebarVisible: boolean, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, preventsSleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array<SiteLogin>, autoCheckUpdates: boolean, };
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type PlatformInfo = { os: string, arch: string, targetTriple: string, };
|
||||
@@ -13,8 +13,10 @@ import { canonicalizeDownloadFileName, categoryForFileName } from '../utils/down
|
||||
import { fetchMediaMetadataDeduped } from '../utils/mediaMetadata';
|
||||
import {
|
||||
resolveCategoryDestination,
|
||||
resolveDownloadFilePath
|
||||
resolveDownloadFilePath,
|
||||
downloadLocationEquals
|
||||
} from '../utils/downloadLocations';
|
||||
import { getPlatformInfo } from '../utils/platform';
|
||||
import { isTransferLocked } from '../utils/downloadActions';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import {
|
||||
@@ -319,9 +321,10 @@ export const AddDownloadsModal = () => {
|
||||
directory: true,
|
||||
multiple: false,
|
||||
defaultPath: saveLocation.startsWith('~') ? undefined : saveLocation
|
||||
});
|
||||
});
|
||||
if (selected && typeof selected === 'string') {
|
||||
setSaveLocation(selected);
|
||||
const approvedPath = await useSettingsStore.getState().approveDownloadRoot(selected);
|
||||
setSaveLocation(approvedPath);
|
||||
setIsSaveLocationManual(true);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -347,6 +350,7 @@ export const AddDownloadsModal = () => {
|
||||
let useSharedDestination = isSaveLocationManual;
|
||||
const destinationOverrides: Record<number, string> = {};
|
||||
const settings = useSettingsStore.getState();
|
||||
const platform = await getPlatformInfo().catch(() => ({ os: 'unknown' }));
|
||||
if (settings.askWhereToSaveEachFile && parsedItems.length > 0) {
|
||||
for (const [index, item] of parsedItems.entries()) {
|
||||
try {
|
||||
@@ -360,7 +364,8 @@ export const AddDownloadsModal = () => {
|
||||
defaultPath: suggestedLocation.startsWith('~') ? undefined : suggestedLocation
|
||||
});
|
||||
if (selected && typeof selected === 'string') {
|
||||
destinationOverrides[index] = selected;
|
||||
const approvedPath = await useSettingsStore.getState().approveDownloadRoot(selected);
|
||||
destinationOverrides[index] = approvedPath;
|
||||
} else {
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
@@ -398,8 +403,13 @@ export const AddDownloadsModal = () => {
|
||||
const destination = download.destination ||
|
||||
await resolveCategoryDestination(settings, download.category);
|
||||
if (
|
||||
destination === itemLocation &&
|
||||
download.fileName === finalFile &&
|
||||
downloadLocationEquals(
|
||||
destination,
|
||||
download.fileName,
|
||||
itemLocation,
|
||||
finalFile,
|
||||
platform.os
|
||||
) &&
|
||||
download.status !== 'failed'
|
||||
) {
|
||||
fileExistsInStore = true;
|
||||
@@ -456,6 +466,7 @@ export const AddDownloadsModal = () => {
|
||||
destinationOverrides: Record<number, string> = {}
|
||||
) => {
|
||||
let itemsToAdd: Array<AddDownloadDraftRow | null> = [...parsedItems];
|
||||
const platform = await getPlatformInfo().catch(() => ({ os: 'unknown' }));
|
||||
|
||||
if (resolutions) {
|
||||
for (const res of resolutions) {
|
||||
@@ -491,8 +502,13 @@ export const AddDownloadsModal = () => {
|
||||
const destination = download.destination ||
|
||||
await resolveCategoryDestination(currentSettings, download.category);
|
||||
if (
|
||||
destination === itemLocation &&
|
||||
download.fileName === newName &&
|
||||
downloadLocationEquals(
|
||||
destination,
|
||||
download.fileName,
|
||||
itemLocation,
|
||||
newName,
|
||||
platform.os
|
||||
) &&
|
||||
download.status !== 'failed'
|
||||
) {
|
||||
storeHas = true;
|
||||
@@ -534,8 +550,13 @@ export const AddDownloadsModal = () => {
|
||||
const destination = download.destination ||
|
||||
await resolveCategoryDestination(currentSettings, download.category);
|
||||
if (
|
||||
destination === itemLocation &&
|
||||
download.fileName === finalFile &&
|
||||
downloadLocationEquals(
|
||||
destination,
|
||||
download.fileName,
|
||||
itemLocation,
|
||||
finalFile,
|
||||
platform.os
|
||||
) &&
|
||||
download.status !== 'failed'
|
||||
) {
|
||||
existingItem = download;
|
||||
|
||||
@@ -211,8 +211,8 @@ export default function LogsView() {
|
||||
<div className="bg-black/10 border-y border-border-modal px-4 py-2 shrink-0 flex items-center gap-2 text-text-muted text-[10px] select-none">
|
||||
<Info size={12} className="text-text-muted opacity-80 shrink-0" />
|
||||
<span className="opacity-90 leading-tight">
|
||||
<strong className="font-medium text-text-primary mr-1">Privacy Note:</strong>
|
||||
Telemetry securely captures basic hardware capabilities (OS, CPU, RAM) exclusively for troubleshooting. No unique identifiers or sensitive paths are collected. Logs remain entirely offline on your device until manually exported.
|
||||
<strong className="font-medium text-text-primary mr-1">Local diagnostics:</strong>
|
||||
Firelink keeps bounded rotating logs on this device for troubleshooting. Common secrets, URL queries, and home-directory paths are redacted during display and export. Nothing is uploaded automatically.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { PostQueueAction, SchedulerSettings, useSettingsStore } from '../store/u
|
||||
import { MAIN_QUEUE_ID, useDownloadStore } from '../store/useDownloadStore';
|
||||
import { WindowDragRegion } from './WindowDragRegion';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { usePlatformInfo } from '../utils/platform';
|
||||
|
||||
const days = [
|
||||
{ value: 0, label: 'Su' },
|
||||
@@ -65,7 +66,8 @@ export default function SchedulerView() {
|
||||
const { addToast } = useToast();
|
||||
const [permissionMessage, setPermissionMessage] = useState('');
|
||||
const [automationPermissionGranted, setAutomationPermissionGranted] = useState<boolean | null>(null);
|
||||
const isMac = navigator.userAgent.includes('Mac');
|
||||
const platform = usePlatformInfo();
|
||||
const isMac = platform.os === 'macos';
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(savedSettings);
|
||||
@@ -365,7 +367,7 @@ export default function SchedulerView() {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{isMac && (
|
||||
{isMac ? (
|
||||
<section className="app-card mt-4 max-w-[760px] p-5">
|
||||
<div className="mb-2 flex items-center gap-2 font-semibold text-text-primary">
|
||||
<LockKeyhole size={17} className="text-accent" /> System Permissions
|
||||
@@ -393,6 +395,16 @@ export default function SchedulerView() {
|
||||
</div>
|
||||
{permissionMessage && <p className="mt-3 text-[11px] text-text-muted">{permissionMessage}</p>}
|
||||
</section>
|
||||
) : (
|
||||
<section className="app-card mt-4 max-w-[760px] p-5">
|
||||
<div className="mb-2 flex items-center gap-2 font-semibold text-text-primary">
|
||||
<LockKeyhole size={17} className="text-accent" /> System Actions
|
||||
</div>
|
||||
<p className="text-[12px] text-text-muted">
|
||||
Sleep, restart, and shut down use {platform.os === 'windows' ? 'Windows system privileges' : 'your Linux desktop and system policy'}.
|
||||
Firelink reports any rejected action when it runs; no permanent permission is claimed in advance.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
DOWNLOAD_CATEGORIES,
|
||||
normalizeCategorySubfolder
|
||||
} from '../utils/downloadLocations';
|
||||
import { usePlatformInfo } from '../utils/platform';
|
||||
|
||||
const settingsTabs: { type: SettingsTab; label: string; icon: typeof Download }[] = [
|
||||
{ type: 'downloads', label: 'Downloads', icon: Download },
|
||||
@@ -176,6 +177,7 @@ const CategoryFolderInput = ({
|
||||
export default function SettingsView() {
|
||||
const settings = useSettingsStore();
|
||||
const activeTab = settings.activeSettingsTab;
|
||||
const platform = usePlatformInfo();
|
||||
|
||||
// Local state for engine status
|
||||
const [engineStatus, setEngineStatus] = useState<EngineStatusItem[] | null>(null);
|
||||
@@ -362,7 +364,8 @@ runEngineChecks(false);
|
||||
defaultPath: currentPath.startsWith('~') ? undefined : currentPath
|
||||
});
|
||||
if (selected && typeof selected === 'string') {
|
||||
settings.setCategoryDirectoryOverride(category, selected);
|
||||
const approvedPath = await settings.approveDownloadRoot(selected);
|
||||
settings.setCategoryDirectoryOverride(category, approvedPath);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Failed to select folder for ${category}:`, e);
|
||||
@@ -379,7 +382,8 @@ runEngineChecks(false);
|
||||
: settings.baseDownloadFolder
|
||||
});
|
||||
if (base && typeof base === 'string') {
|
||||
settings.setBaseDownloadFolder(base);
|
||||
const approvedBase = await settings.approveDownloadRoot(base);
|
||||
settings.setBaseDownloadFolder(approvedBase);
|
||||
try {
|
||||
const safeSubfolders = Object.fromEntries(
|
||||
DOWNLOAD_CATEGORIES.map(category => [
|
||||
@@ -391,7 +395,7 @@ runEngineChecks(false);
|
||||
])
|
||||
);
|
||||
await invoke('create_category_directories', {
|
||||
baseFolder: base,
|
||||
baseFolder: approvedBase,
|
||||
subfolders: safeSubfolders
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -647,24 +651,28 @@ runEngineChecks(false);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="settings-section-title">macOS Integration</h2>
|
||||
<h2 className="settings-section-title">
|
||||
{platform.os === 'macos' ? 'macOS Integration' : 'Desktop Integration'}
|
||||
</h2>
|
||||
<div className="mac-settings-group">
|
||||
{platform.os === 'macos' && (
|
||||
<label className="mac-settings-row cursor-default">
|
||||
<div className="settings-row-label">
|
||||
<span>Show badge on Dock icon</span>
|
||||
<small>Displays active download count on Firelink Dock icon.</small>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.showDockBadge}
|
||||
onChange={(e) => settings.setShowDockBadge(e.target.checked)}
|
||||
className="mac-switch"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<label className="mac-settings-row cursor-default">
|
||||
<div className="settings-row-label">
|
||||
<span>Show badge on Dock icon</span>
|
||||
<small>Displays the number of active downloads on the Firelink Dock icon.</small>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.showDockBadge}
|
||||
onChange={(e) => settings.setShowDockBadge(e.target.checked)}
|
||||
className="mac-switch"
|
||||
/>
|
||||
</label>
|
||||
<label className="mac-settings-row cursor-default">
|
||||
<div className="settings-row-label">
|
||||
<span>Show menu bar icon</span>
|
||||
<small>Provides quick access to downloads and queues from the macOS menu bar.</small>
|
||||
<span>{platform.os === 'macos' ? 'Show menu bar icon' : 'Show system tray icon'}</span>
|
||||
<small>Provides quick access to downloads and queues.</small>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -732,7 +740,7 @@ runEngineChecks(false);
|
||||
)}
|
||||
<p className="settings-group-footer">
|
||||
{settings.proxyMode === 'none' && 'Downloads ignore configured proxies.'}
|
||||
{settings.proxyMode === 'system' && 'Downloads use the matching macOS system proxy when one is configured.'}
|
||||
{settings.proxyMode === 'system' && `Downloads use the detected ${platform.os === 'macos' ? 'macOS' : platform.os === 'windows' ? 'Windows' : 'desktop'} system proxy when available.`}
|
||||
{settings.proxyMode === 'custom' && (settings.proxyHost
|
||||
? `Downloads use http://${settings.proxyHost}:${settings.proxyPort}.`
|
||||
: 'Enter a proxy host and port to enable the custom proxy.')}
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { ReleaseCheckOutcome } from './bindings/ReleaseCheckOutcome';
|
||||
import type { PairingTokenHydration } from './bindings/PairingTokenHydration';
|
||||
import type { EnqueueItem } from './bindings/EnqueueItem';
|
||||
import type { EnqueueAccepted } from './bindings/EnqueueAccepted';
|
||||
import type { PlatformInfo } from './bindings/PlatformInfo';
|
||||
|
||||
type CommandMap = {
|
||||
fetch_metadata: {
|
||||
@@ -34,6 +35,8 @@ type CommandMap = {
|
||||
remove_download: { args: { id: string; deleteAssets: boolean }; result: void };
|
||||
detach_download_for_reconfigure: { args: { id: string }; result: void };
|
||||
update_dock_badge: { args: { count: number }; result: void };
|
||||
get_platform_info: { args: undefined; result: PlatformInfo };
|
||||
approve_download_root: { args: { path: string }; result: string };
|
||||
set_prevent_sleep: { args: { prevent: boolean }; result: void };
|
||||
perform_system_action: { args: { action: PostQueueAction }; result: void };
|
||||
ack_schedule_trigger: { args: { action: 'start' | 'stop'; key: string }; result: void };
|
||||
|
||||
@@ -74,6 +74,7 @@ export interface SettingsState {
|
||||
baseDownloadFolder: string;
|
||||
categorySubfolders: Record<string, string>;
|
||||
categoryDirectoryOverrides: Record<string, string>;
|
||||
approvedDownloadRoots: string[];
|
||||
maxConcurrentDownloads: number;
|
||||
globalSpeedLimit: string;
|
||||
isSidebarVisible: boolean;
|
||||
@@ -108,6 +109,7 @@ export interface SettingsState {
|
||||
|
||||
setTheme: (theme: Theme) => void;
|
||||
setBaseDownloadFolder: (path: string) => void;
|
||||
approveDownloadRoot: (path: string) => Promise<string>;
|
||||
setMaxConcurrentDownloads: (count: number) => void;
|
||||
setGlobalSpeedLimit: (limit: string) => void;
|
||||
setActiveView: (view: ActiveView) => void;
|
||||
@@ -178,6 +180,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
baseDownloadFolder: '~/Downloads',
|
||||
categorySubfolders: { ...DEFAULT_CATEGORY_SUBFOLDERS },
|
||||
categoryDirectoryOverrides: {},
|
||||
approvedDownloadRoots: [],
|
||||
maxConcurrentDownloads: 3,
|
||||
globalSpeedLimit: '',
|
||||
activeView: 'downloads',
|
||||
@@ -224,6 +227,15 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
info('Settings updated: baseDownloadFolder');
|
||||
set({ baseDownloadFolder: path });
|
||||
},
|
||||
approveDownloadRoot: async (path) => {
|
||||
const approvedPath = await invoke('approve_download_root', { path });
|
||||
set(state => ({
|
||||
approvedDownloadRoots: state.approvedDownloadRoots.includes(approvedPath)
|
||||
? state.approvedDownloadRoots
|
||||
: [...state.approvedDownloadRoots, approvedPath]
|
||||
}));
|
||||
return approvedPath;
|
||||
},
|
||||
setMaxConcurrentDownloads: (max) => {
|
||||
info('Settings updated: maxConcurrentDownloads');
|
||||
set({ maxConcurrentDownloads: max });
|
||||
@@ -299,6 +311,9 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
hydratePairingToken: async () => {
|
||||
const result = await invoke('hydrate_extension_pairing_token');
|
||||
set({ extensionPairingToken: result.token });
|
||||
if (!result.persistent && result.error) {
|
||||
throw new Error(`Session-only browser pairing token: ${result.error}`);
|
||||
}
|
||||
return result.tokenChanged;
|
||||
},
|
||||
setAutoCheckUpdates: (autoCheckUpdates) => set({ autoCheckUpdates }),
|
||||
@@ -330,7 +345,10 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
: [DEFAULT_SCHEDULER_QUEUE_ID]
|
||||
}
|
||||
: persisted.scheduler,
|
||||
siteLogins: Array.isArray(persisted.siteLogins) ? persisted.siteLogins : []
|
||||
siteLogins: Array.isArray(persisted.siteLogins) ? persisted.siteLogins : [],
|
||||
approvedDownloadRoots: Array.isArray(persisted.approvedDownloadRoots)
|
||||
? persisted.approvedDownloadRoots
|
||||
: []
|
||||
} as SettingsState;
|
||||
},
|
||||
partialize: (state): PersistedSettings => ({
|
||||
@@ -338,6 +356,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
baseDownloadFolder: state.baseDownloadFolder,
|
||||
categorySubfolders: state.categorySubfolders,
|
||||
categoryDirectoryOverrides: state.categoryDirectoryOverrides,
|
||||
approvedDownloadRoots: state.approvedDownloadRoots,
|
||||
maxConcurrentDownloads: state.maxConcurrentDownloads,
|
||||
globalSpeedLimit: state.globalSpeedLimit,
|
||||
isSidebarVisible: state.isSidebarVisible,
|
||||
@@ -376,6 +395,9 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
...currentState,
|
||||
...persisted,
|
||||
...locations,
|
||||
approvedDownloadRoots: Array.isArray(persisted.approvedDownloadRoots)
|
||||
? persisted.approvedDownloadRoots
|
||||
: currentState.approvedDownloadRoots,
|
||||
scheduler: {
|
||||
...currentState.scheduler,
|
||||
...persisted.scheduler,
|
||||
|
||||
@@ -9,6 +9,7 @@ vi.mock('@tauri-apps/api/path', () => ({
|
||||
}));
|
||||
|
||||
import {
|
||||
downloadLocationEquals,
|
||||
DEFAULT_CATEGORY_SUBFOLDERS,
|
||||
normalizeCategorySubfolder,
|
||||
normalizeDownloadLocationSettings,
|
||||
@@ -16,6 +17,11 @@ import {
|
||||
} from './downloadLocations';
|
||||
|
||||
describe('download locations', () => {
|
||||
it('compares Windows and macOS locations case-insensitively', () => {
|
||||
expect(downloadLocationEquals('D:\\Downloads', 'Movie.MP4', 'd:/downloads', 'movie.mp4', 'windows')).toBe(true);
|
||||
expect(downloadLocationEquals('/Users/Test', 'Movie.MP4', '/users/test', 'movie.mp4', 'macos')).toBe(true);
|
||||
expect(downloadLocationEquals('/home/Test', 'Movie.MP4', '/home/test', 'movie.mp4', 'linux')).toBe(false);
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -143,3 +143,20 @@ export const resolveDownloadFilePath = async (
|
||||
const expandedDest = await expandTilde(destination);
|
||||
return join(expandedDest, fileName);
|
||||
};
|
||||
|
||||
export const downloadLocationEquals = (
|
||||
leftDirectory: string,
|
||||
leftFileName: string,
|
||||
rightDirectory: string,
|
||||
rightFileName: string,
|
||||
os: string
|
||||
): boolean => {
|
||||
const normalize = (value: string) => {
|
||||
const normalized = value.replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
return os === 'windows' || os === 'macos'
|
||||
? normalized.toLocaleLowerCase()
|
||||
: normalized;
|
||||
};
|
||||
return normalize(`${leftDirectory}/${leftFileName}`)
|
||||
=== normalize(`${rightDirectory}/${rightFileName}`);
|
||||
};
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { info as tauriInfo, warn as tauriWarn, error as tauriError, debug as tau
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
// Default to true to match backend default
|
||||
let isPaused = true;
|
||||
let isPaused = false;
|
||||
|
||||
let initPromise: Promise<void> | null = null;
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { PlatformInfo } from '../bindings/PlatformInfo';
|
||||
import { invokeCommand as invoke } from '../ipc';
|
||||
|
||||
const fallback: PlatformInfo = {
|
||||
os: 'unknown',
|
||||
arch: 'unknown',
|
||||
targetTriple: 'unknown'
|
||||
};
|
||||
|
||||
let cached: PlatformInfo | null = null;
|
||||
let pending: Promise<PlatformInfo> | null = null;
|
||||
|
||||
export const getPlatformInfo = (): Promise<PlatformInfo> => {
|
||||
if (cached) return Promise.resolve(cached);
|
||||
if (!pending) {
|
||||
pending = invoke('get_platform_info')
|
||||
.then(info => {
|
||||
cached = info;
|
||||
return info;
|
||||
})
|
||||
.finally(() => {
|
||||
pending = null;
|
||||
});
|
||||
}
|
||||
return pending;
|
||||
};
|
||||
|
||||
export const usePlatformInfo = () => {
|
||||
const [platform, setPlatform] = useState<PlatformInfo>(cached ?? fallback);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
void getPlatformInfo()
|
||||
.then(info => {
|
||||
if (active) setPlatform(info);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return platform;
|
||||
};
|
||||
Reference in New Issue
Block a user