mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-08 02:13:24 +00:00
refactor(repo): promote tauri app to repository root
This commit is contained in:
@@ -0,0 +1,441 @@
|
||||
import { create } from 'zustand';
|
||||
import { invokeCommand as invoke } from '../ipc';
|
||||
import type { DownloadItem } from '../bindings/DownloadItem';
|
||||
import type { DownloadStatus } from '../bindings/DownloadStatus';
|
||||
import type { ExtensionDownload } from '../bindings/ExtensionDownload';
|
||||
import type { Queue } from '../bindings/Queue';
|
||||
import { useSettingsStore } from './useSettingsStore';
|
||||
import {
|
||||
categoryForFileName,
|
||||
fileNameFromUrl,
|
||||
isMediaUrl
|
||||
} from '../utils/downloads';
|
||||
|
||||
export type { DownloadCategory } from '../utils/downloads';
|
||||
|
||||
const getProxyArgs = (settings: ReturnType<typeof useSettingsStore.getState>) => {
|
||||
if (settings.proxyMode === 'custom' && settings.proxyHost) {
|
||||
return `http://${settings.proxyHost}:${settings.proxyPort}`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getSiteLogin = (url: string, settings: ReturnType<typeof useSettingsStore.getState>) => {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const host = urlObj.hostname.toLowerCase();
|
||||
for (const login of settings.siteLogins) {
|
||||
let pattern = login.urlPattern.toLowerCase().trim();
|
||||
if (pattern.startsWith('*.')) {
|
||||
const suffix = pattern.substring(2);
|
||||
if (host === suffix || host.endsWith('.' + suffix)) return login;
|
||||
} else if (pattern.includes('*')) {
|
||||
const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
|
||||
if (regex.test(host)) return login;
|
||||
} else if (host === pattern) {
|
||||
return login;
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
return null;
|
||||
};
|
||||
|
||||
const syncSystemIntegrations = () => {
|
||||
const settings = useSettingsStore.getState();
|
||||
const activeCount = useDownloadStore.getState().downloads.filter(d => d.status === 'downloading').length;
|
||||
invoke('update_dock_badge', { count: settings.showDockBadge ? activeCount : 0 }).catch(() => {});
|
||||
if (settings.preventsSleepWhileDownloading) {
|
||||
invoke('set_prevent_sleep', { prevent: activeCount > 0 }).catch(() => {});
|
||||
} else {
|
||||
invoke('set_prevent_sleep', { prevent: false }).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
// Legacy manual speed limit math removed
|
||||
|
||||
export type { DownloadStatus };
|
||||
export const MAIN_QUEUE_ID = '00000000-0000-0000-0000-000000000001';
|
||||
|
||||
export type { DownloadItem, Queue };
|
||||
export type ExtensionDownloadRequest = ExtensionDownload;
|
||||
|
||||
interface DownloadState {
|
||||
downloads: DownloadItem[];
|
||||
queues: Queue[];
|
||||
isAddModalOpen: boolean;
|
||||
pendingAddUrls: string;
|
||||
pendingAddReferer: string;
|
||||
pendingAddFilename: string;
|
||||
selectedPropertiesDownloadId: string | null;
|
||||
toggleAddModal: (isOpen: boolean) => void;
|
||||
openAddModalWithUrls: (urls: string, referer?: string | null, filename?: string | null) => void;
|
||||
handleExtensionDownload: (request: ExtensionDownloadRequest) => void;
|
||||
setSelectedPropertiesDownloadId: (id: string | null) => void;
|
||||
addDownload: (item: DownloadItem) => void;
|
||||
updateDownload: (id: string, updates: Partial<DownloadItem>) => void;
|
||||
removeDownload: (id: string) => Promise<void>;
|
||||
clearFinished: () => void;
|
||||
redownload: (id: string) => void;
|
||||
processQueue: () => Promise<void>;
|
||||
startQueue: (queueId: string) => Promise<number>;
|
||||
pauseQueue: (queueId: string) => Promise<number>;
|
||||
addQueue: (name: string) => void;
|
||||
renameQueue: (id: string, name: string) => void;
|
||||
removeQueue: (id: string) => void;
|
||||
initDB: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
downloads: [],
|
||||
queues: [{ id: MAIN_QUEUE_ID, name: 'Main Queue', isMain: true }],
|
||||
isAddModalOpen: false,
|
||||
pendingAddUrls: '',
|
||||
pendingAddReferer: '',
|
||||
pendingAddFilename: '',
|
||||
selectedPropertiesDownloadId: null,
|
||||
toggleAddModal: (isOpen) => set({
|
||||
isAddModalOpen: isOpen,
|
||||
pendingAddUrls: '',
|
||||
pendingAddReferer: '',
|
||||
pendingAddFilename: ''
|
||||
}),
|
||||
openAddModalWithUrls: (urls, referer, filename) => set({
|
||||
isAddModalOpen: true,
|
||||
pendingAddUrls: urls,
|
||||
pendingAddReferer: referer?.trim() || '',
|
||||
pendingAddFilename: filename?.trim() || ''
|
||||
}),
|
||||
handleExtensionDownload: (request) => {
|
||||
const urls = [...new Set(request.urls.map(url => url.trim()).filter(Boolean))];
|
||||
if (urls.length === 0) return;
|
||||
|
||||
const settings = useSettingsStore.getState();
|
||||
if (!request.silent || settings.askWhereToSaveEachFile) {
|
||||
get().openAddModalWithUrls(
|
||||
urls.join('\n'),
|
||||
request.referer,
|
||||
urls.length === 1 ? request.filename : null
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const referer = request.referer?.trim();
|
||||
const headers = referer ? `Referer: ${referer}` : undefined;
|
||||
const dateAdded = new Date().toISOString();
|
||||
const downloads = urls.map((url, index): DownloadItem => {
|
||||
const fileName = index === 0 && urls.length === 1 && request.filename?.trim()
|
||||
? request.filename.trim()
|
||||
: fileNameFromUrl(url);
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
url,
|
||||
fileName,
|
||||
status: 'queued',
|
||||
category: categoryForFileName(fileName),
|
||||
dateAdded,
|
||||
connections: settings.perServerConnections,
|
||||
headers,
|
||||
isMedia: isMediaUrl(url),
|
||||
queueId: MAIN_QUEUE_ID
|
||||
};
|
||||
});
|
||||
|
||||
set(state => ({ downloads: [...state.downloads, ...downloads] }));
|
||||
downloads.forEach(item => {
|
||||
const toSave = { ...item };
|
||||
delete toSave.fraction; delete toSave.speed; delete toSave.eta;
|
||||
invoke('db_save_download', { id: item.id, status: item.status, queueId: item.queueId, data: JSON.stringify(toSave) }).catch(console.error);
|
||||
});
|
||||
void get().processQueue();
|
||||
},
|
||||
setSelectedPropertiesDownloadId: (id) => set({ selectedPropertiesDownloadId: id }),
|
||||
addDownload: (item) => {
|
||||
set((state) => ({ downloads: [...state.downloads, item] }));
|
||||
const toSave = { ...item };
|
||||
delete toSave.fraction; delete toSave.speed; delete toSave.eta;
|
||||
invoke('db_save_download', { id: item.id, status: item.status, queueId: item.queueId, data: JSON.stringify(toSave) }).catch(console.error);
|
||||
get().processQueue();
|
||||
},
|
||||
updateDownload: (id, updates) => {
|
||||
let updatedItem: DownloadItem | null = null;
|
||||
set((state) => ({
|
||||
downloads: state.downloads.map(d => {
|
||||
if (d.id === id) {
|
||||
let newFraction = updates.fraction;
|
||||
if (newFraction === 0 && d.fraction && d.fraction > 0) {
|
||||
newFraction = d.fraction;
|
||||
}
|
||||
const updated = {
|
||||
...d,
|
||||
...updates,
|
||||
fraction: newFraction !== undefined ? newFraction : updates.fraction !== undefined ? updates.fraction : d.fraction
|
||||
};
|
||||
updatedItem = updated;
|
||||
return updated;
|
||||
}
|
||||
return d;
|
||||
})
|
||||
}));
|
||||
|
||||
if (updatedItem && Object.keys(updates).some(k => !['fraction', 'speed', 'eta'].includes(k))) {
|
||||
const toSave = { ...(updatedItem as DownloadItem) };
|
||||
delete toSave.fraction; delete toSave.speed; delete toSave.eta;
|
||||
invoke('db_save_download', { id: toSave.id, status: toSave.status, queueId: toSave.queueId, data: JSON.stringify(toSave) }).catch(console.error);
|
||||
}
|
||||
|
||||
// If status changed to something that frees up a slot, process queue
|
||||
if (updates.status && ['completed', 'failed', 'paused'].includes(updates.status)) {
|
||||
get().processQueue();
|
||||
syncSystemIntegrations();
|
||||
} else if (updates.status === 'downloading') {
|
||||
syncSystemIntegrations();
|
||||
}
|
||||
},
|
||||
removeDownload: async (id) => {
|
||||
const item = get().downloads.find(d => d.id === id);
|
||||
if (item && item.status === 'downloading') {
|
||||
try {
|
||||
await invoke('pause_download', { id });
|
||||
} catch (e) {
|
||||
console.error("Failed to terminate download on deletion:", e);
|
||||
}
|
||||
}
|
||||
set((state) => ({
|
||||
downloads: state.downloads.filter(d => d.id !== id)
|
||||
}));
|
||||
invoke('db_delete_download', { id }).catch(console.error);
|
||||
get().processQueue();
|
||||
syncSystemIntegrations();
|
||||
},
|
||||
clearFinished: () => {
|
||||
const downloads = get().downloads;
|
||||
const toRemove = downloads.filter(d => ['completed', 'failed'].includes(d.status));
|
||||
set((state) => ({
|
||||
downloads: state.downloads.filter(d => !['completed', 'failed'].includes(d.status))
|
||||
}));
|
||||
toRemove.forEach(d => {
|
||||
invoke('db_delete_download', { id: d.id }).catch(console.error);
|
||||
});
|
||||
},
|
||||
redownload: (id) => {
|
||||
let updatedItem: DownloadItem | null = null;
|
||||
set((state) => ({
|
||||
downloads: state.downloads.map(d => {
|
||||
if (d.id === id) {
|
||||
const updated: DownloadItem = { ...d, status: 'queued', _dispatched: false, fraction: 0, speed: '-', eta: '-' };
|
||||
updatedItem = updated;
|
||||
return updated;
|
||||
}
|
||||
return d;
|
||||
})
|
||||
}));
|
||||
if (updatedItem) {
|
||||
const toSave = { ...(updatedItem as DownloadItem) };
|
||||
delete toSave.fraction; delete toSave.speed; delete toSave.eta;
|
||||
invoke('db_save_download', { id: toSave.id, status: toSave.status, queueId: toSave.queueId, data: JSON.stringify(toSave) }).catch(console.error);
|
||||
}
|
||||
get().processQueue();
|
||||
},
|
||||
startQueue: async (queueId) => {
|
||||
const runnableIds = get().downloads
|
||||
.filter(item => item.queueId === queueId && (item.status === 'queued' || item.status === 'paused' || item.status === 'failed'))
|
||||
.map(item => item.id);
|
||||
|
||||
if (runnableIds.length === 0) return 0;
|
||||
|
||||
set((state) => ({
|
||||
downloads: state.downloads.map(item =>
|
||||
runnableIds.includes(item.id)
|
||||
? { ...item, status: 'queued', _dispatched: false, speed: '-', eta: '-' }
|
||||
: item
|
||||
)
|
||||
}));
|
||||
await get().processQueue();
|
||||
return runnableIds.length;
|
||||
},
|
||||
pauseQueue: async (queueId) => {
|
||||
const activeIds = get().downloads
|
||||
.filter(item => item.queueId === queueId && item.status === 'downloading')
|
||||
.map(item => item.id);
|
||||
|
||||
if (activeIds.length === 0) return 0;
|
||||
|
||||
set((state) => ({
|
||||
downloads: state.downloads.map(item =>
|
||||
activeIds.includes(item.id)
|
||||
? { ...item, status: 'paused', speed: '-', eta: '-' }
|
||||
: item
|
||||
)
|
||||
}));
|
||||
await Promise.all(activeIds.map(id => invoke('pause_download', { id }).catch(() => {})));
|
||||
syncSystemIntegrations();
|
||||
return activeIds.length;
|
||||
},
|
||||
addQueue: (name) => {
|
||||
const id = crypto.randomUUID();
|
||||
const q = { id, name, isMain: false };
|
||||
set((state) => ({
|
||||
queues: [...state.queues, q]
|
||||
}));
|
||||
invoke('db_save_queue', { id, data: JSON.stringify(q) }).catch(console.error);
|
||||
},
|
||||
renameQueue: (id, name) => {
|
||||
let updatedQ: Queue | null = null;
|
||||
set((state) => ({
|
||||
queues: state.queues.map(q => {
|
||||
if (q.id === id) {
|
||||
const newQ = { ...q, name };
|
||||
updatedQ = newQ;
|
||||
return newQ;
|
||||
}
|
||||
return q;
|
||||
})
|
||||
}));
|
||||
if (updatedQ) {
|
||||
invoke('db_save_queue', { id, data: JSON.stringify(updatedQ) }).catch(console.error);
|
||||
}
|
||||
},
|
||||
removeQueue: (id) => {
|
||||
if (id === MAIN_QUEUE_ID) return;
|
||||
set((state) => ({
|
||||
queues: state.queues.filter(q => q.id !== id),
|
||||
downloads: state.downloads.map(d =>
|
||||
d.queueId === id ? { ...d, queueId: MAIN_QUEUE_ID } : d
|
||||
)
|
||||
}));
|
||||
invoke('db_delete_queue', { id }).catch(console.error);
|
||||
|
||||
// Also we need to save the updated downloads to DB
|
||||
const downloads = get().downloads.filter(d => d.queueId === id);
|
||||
downloads.forEach(d => {
|
||||
const toSave = { ...d, queueId: MAIN_QUEUE_ID };
|
||||
delete toSave.fraction; delete toSave.speed; delete toSave.eta;
|
||||
invoke('db_save_download', { id: toSave.id, status: toSave.status, queueId: MAIN_QUEUE_ID, data: JSON.stringify(toSave) }).catch(console.error);
|
||||
});
|
||||
},
|
||||
initDB: async () => {
|
||||
try {
|
||||
const queuesStr = await invoke('db_get_all_queues');
|
||||
const queues = queuesStr.map(q => JSON.parse(q));
|
||||
|
||||
const downloadsStr = await invoke('db_get_all_downloads');
|
||||
const downloads = downloadsStr.map(d => JSON.parse(d));
|
||||
|
||||
set(state => ({
|
||||
queues: queues.length > 0 ? queues : state.queues,
|
||||
downloads: downloads.length > 0 ? downloads : state.downloads
|
||||
}));
|
||||
|
||||
// Auto resume downloads that were active
|
||||
const active = get().downloads.filter(d => d.status === 'downloading');
|
||||
active.forEach(item => {
|
||||
if (item.isMedia) {
|
||||
invoke('start_media_download', {
|
||||
id: item.id,
|
||||
url: item.url,
|
||||
destination: item.destination || '~/Downloads',
|
||||
filename: item.fileName,
|
||||
formatSelector: item.mediaFormatSelector || null,
|
||||
cookieSource: null,
|
||||
speedLimit: item.speedLimit || null,
|
||||
username: item.username || null,
|
||||
password: item.password || null,
|
||||
headers: item.headers || null,
|
||||
proxy: null,
|
||||
userAgent: null,
|
||||
maxTries: null
|
||||
}).catch(console.error);
|
||||
} else {
|
||||
invoke('start_download', {
|
||||
id: item.id,
|
||||
url: item.url,
|
||||
destination: item.destination || '~/Downloads',
|
||||
filename: item.fileName,
|
||||
connections: item.connections ?? null,
|
||||
speedLimit: item.speedLimit || null,
|
||||
username: item.username || null,
|
||||
password: item.password || null,
|
||||
headers: item.headers || null,
|
||||
checksum: item.checksum || null,
|
||||
cookies: item.cookies || null,
|
||||
mirrors: item.mirrors || null,
|
||||
userAgent: null,
|
||||
maxTries: null,
|
||||
proxy: null
|
||||
}).catch(console.error);
|
||||
}
|
||||
});
|
||||
|
||||
void get().processQueue();
|
||||
} catch (e) {
|
||||
console.error("Failed to init DB", e);
|
||||
}
|
||||
},
|
||||
processQueue: async () => {
|
||||
const { downloads, updateDownload } = get();
|
||||
|
||||
// Find all queued items that haven't been dispatched to the backend yet
|
||||
const itemsToStart = downloads.filter(d => d.status === 'queued' && !d._dispatched);
|
||||
|
||||
for (const item of itemsToStart) {
|
||||
// Mark as dispatched so we don't send it again on the next pass
|
||||
updateDownload(item.id, { _dispatched: true });
|
||||
try {
|
||||
const settings = useSettingsStore.getState();
|
||||
const login = getSiteLogin(item.url, settings);
|
||||
let keychainPassword = null;
|
||||
if (login) {
|
||||
try {
|
||||
keychainPassword = await invoke('get_keychain_password', { id: login.id });
|
||||
} catch (e) {
|
||||
console.warn("Could not fetch keychain password for login:", e);
|
||||
}
|
||||
}
|
||||
|
||||
const destPath = item.destination ||
|
||||
(settings.downloadDirectories && settings.downloadDirectories[item.category]) ||
|
||||
settings.defaultDownloadPath ||
|
||||
'~/Downloads';
|
||||
|
||||
if (item.isMedia) {
|
||||
await invoke('start_media_download', {
|
||||
id: item.id,
|
||||
url: item.url,
|
||||
destination: destPath,
|
||||
filename: item.fileName,
|
||||
formatSelector: item.mediaFormatSelector || null,
|
||||
cookieSource: settings.mediaCookieSource !== 'none' ? settings.mediaCookieSource : null,
|
||||
speedLimit: item.speedLimit || null,
|
||||
username: item.username || (login ? login.username : null),
|
||||
password: item.password || keychainPassword,
|
||||
headers: item.headers || null,
|
||||
proxy: getProxyArgs(settings),
|
||||
userAgent: settings.customUserAgent || null,
|
||||
maxTries: settings.maxAutomaticRetries
|
||||
});
|
||||
} else {
|
||||
await invoke('start_download', {
|
||||
id: item.id,
|
||||
url: item.url,
|
||||
destination: destPath,
|
||||
filename: item.fileName,
|
||||
connections: item.connections || settings.perServerConnections || null,
|
||||
speedLimit: item.speedLimit || null,
|
||||
username: item.username || (login ? login.username : null),
|
||||
password: item.password || keychainPassword,
|
||||
headers: item.headers || null,
|
||||
checksum: item.checksum || null,
|
||||
cookies: item.cookies || null,
|
||||
mirrors: item.mirrors || null,
|
||||
userAgent: settings.customUserAgent || null,
|
||||
maxTries: settings.maxAutomaticRetries,
|
||||
proxy: getProxyArgs(settings)
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to start queued download:", e);
|
||||
updateDownload(item.id, { status: 'failed' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
@@ -0,0 +1,337 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage, StateStorage } from 'zustand/middleware';
|
||||
import { invokeCommand as invoke } from '../ipc';
|
||||
import type { ActiveView } from '../bindings/ActiveView';
|
||||
import type { AppFontSize } from '../bindings/AppFontSize';
|
||||
import type { ListRowDensity } from '../bindings/ListRowDensity';
|
||||
import type { MediaCookieSource } from '../bindings/MediaCookieSource';
|
||||
import type { PostQueueAction } from '../bindings/PostQueueAction';
|
||||
import type { PersistedSettings } from '../bindings/PersistedSettings';
|
||||
import type { ProxyMode } from '../bindings/ProxyMode';
|
||||
import type { SchedulerSettings } from '../bindings/SchedulerSettings';
|
||||
import type { SettingsTab } from '../bindings/SettingsTab';
|
||||
import type { SiteLogin } from '../bindings/SiteLogin';
|
||||
import type { Theme } from '../bindings/Theme';
|
||||
|
||||
const tauriStorage: StateStorage = {
|
||||
getItem: async (name: string): Promise<string | null> => {
|
||||
if (name === 'firelink-settings') {
|
||||
try {
|
||||
const data = await invoke('db_load_settings');
|
||||
return data;
|
||||
} catch (e) {
|
||||
console.error("Failed to load settings from DB", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
setItem: async (name: string, value: string): Promise<void> => {
|
||||
if (name === 'firelink-settings') {
|
||||
try {
|
||||
await invoke('db_save_settings', { data: value });
|
||||
} catch (e) {
|
||||
console.error("Failed to save settings to DB", e);
|
||||
}
|
||||
}
|
||||
},
|
||||
removeItem: async (_name: string): Promise<void> => {
|
||||
// no-op for now
|
||||
},
|
||||
};
|
||||
|
||||
export type {
|
||||
ActiveView,
|
||||
AppFontSize,
|
||||
ListRowDensity,
|
||||
MediaCookieSource,
|
||||
PostQueueAction,
|
||||
ProxyMode,
|
||||
SchedulerSettings,
|
||||
SettingsTab,
|
||||
SiteLogin,
|
||||
Theme
|
||||
};
|
||||
|
||||
export interface SettingsState {
|
||||
theme: Theme;
|
||||
defaultDownloadPath: string;
|
||||
maxConcurrentDownloads: number;
|
||||
globalSpeedLimit: string;
|
||||
isSidebarVisible: boolean;
|
||||
activeView: ActiveView;
|
||||
activeSettingsTab: SettingsTab;
|
||||
scheduler: SchedulerSettings;
|
||||
schedulerRunning: boolean;
|
||||
schedulerLastStartKey: string;
|
||||
schedulerLastStopKey: string;
|
||||
lastCustomSpeedLimitKiB: number;
|
||||
|
||||
// Replicated SwiftUI App Settings
|
||||
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;
|
||||
downloadDirectories: Record<string, string>;
|
||||
siteLogins: SiteLogin[];
|
||||
extensionPairingToken: string;
|
||||
autoCheckUpdates: boolean;
|
||||
|
||||
setTheme: (theme: Theme) => void;
|
||||
setDefaultDownloadPath: (path: string) => void;
|
||||
setMaxConcurrentDownloads: (count: number) => void;
|
||||
setGlobalSpeedLimit: (limit: string) => void;
|
||||
setActiveView: (view: ActiveView) => void;
|
||||
setActiveSettingsTab: (tab: SettingsTab) => void;
|
||||
setScheduler: (settings: SchedulerSettings) => void;
|
||||
setSchedulerRunning: (running: boolean) => void;
|
||||
setSchedulerLastStartKey: (key: string) => void;
|
||||
setSchedulerLastStopKey: (key: string) => void;
|
||||
setLastCustomSpeedLimitKiB: (limit: number) => void;
|
||||
toggleSidebar: () => void;
|
||||
|
||||
setPerServerConnections: (count: number) => void;
|
||||
setMaxAutomaticRetries: (count: number) => void;
|
||||
setShowNotifications: (show: boolean) => void;
|
||||
setPlayCompletionSound: (play: boolean) => void;
|
||||
setAppFontSize: (size: AppFontSize) => void;
|
||||
setListRowDensity: (density: ListRowDensity) => void;
|
||||
setShowDockBadge: (show: boolean) => void;
|
||||
setShowMenuBarIcon: (show: boolean) => void;
|
||||
setProxyMode: (mode: ProxyMode) => void;
|
||||
setProxyHost: (host: string) => void;
|
||||
setProxyPort: (port: number) => void;
|
||||
setCustomUserAgent: (userAgent: string) => void;
|
||||
setAskWhereToSaveEachFile: (ask: boolean) => void;
|
||||
setPreventsSleepWhileDownloading: (prevent: boolean) => void;
|
||||
setMediaCookieSource: (source: MediaCookieSource) => void;
|
||||
setCategoryDirectory: (category: string, path: string) => void;
|
||||
resetCategoryDirectories: () => void;
|
||||
addSiteLogin: (login: SiteLogin) => void;
|
||||
removeSiteLogin: (id: string) => void;
|
||||
regeneratePairingToken: () => void;
|
||||
setAutoCheckUpdates: (autoCheckUpdates: boolean) => void;
|
||||
}
|
||||
|
||||
const defaultDirectories = {
|
||||
Musics: '~/Downloads/Musics',
|
||||
Movies: '~/Downloads/Movies',
|
||||
Compressed: '~/Downloads/Compressed',
|
||||
Documents: '~/Downloads/Documents',
|
||||
Pictures: '~/Downloads/Pictures',
|
||||
Applications: '~/Downloads/Applications',
|
||||
Other: '~/Downloads/Other'
|
||||
};
|
||||
|
||||
const normalizeDownloadDirectories = (directories: unknown): Record<string, string> => {
|
||||
if (!directories || typeof directories !== 'object') {
|
||||
return { ...defaultDirectories };
|
||||
}
|
||||
|
||||
const values = directories as Record<string, unknown>;
|
||||
const directory = (current: string, legacy?: string) => {
|
||||
const value = values[current] ?? (legacy ? values[legacy] : undefined);
|
||||
return typeof value === 'string' && value.length > 0
|
||||
? value
|
||||
: defaultDirectories[current as keyof typeof defaultDirectories];
|
||||
};
|
||||
|
||||
return {
|
||||
Musics: directory('Musics', 'Audio'),
|
||||
Movies: directory('Movies', 'Video'),
|
||||
Compressed: directory('Compressed', 'Archives'),
|
||||
Documents: directory('Documents'),
|
||||
Pictures: directory('Pictures', 'Images'),
|
||||
Applications: directory('Applications', 'Apps'),
|
||||
Other: directory('Other')
|
||||
};
|
||||
};
|
||||
|
||||
const generateSecureToken = () => {
|
||||
try {
|
||||
const cryptoObj = typeof window !== 'undefined'
|
||||
? (window as Window & { msCrypto?: Crypto }).crypto
|
||||
|| (window as Window & { msCrypto?: Crypto }).msCrypto
|
||||
: null;
|
||||
if (cryptoObj && cryptoObj.getRandomValues) {
|
||||
const arr = new Uint8Array(24);
|
||||
cryptoObj.getRandomValues(arr);
|
||||
let binary = '';
|
||||
for (let i = 0; i < arr.byteLength; i++) {
|
||||
binary += String.fromCharCode(arr[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Secure token generation failed, falling back to random characters", e);
|
||||
}
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||
let token = '';
|
||||
for (let i = 0; i < 32; i++) {
|
||||
token += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return token;
|
||||
};
|
||||
|
||||
export const useSettingsStore = create<SettingsState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
theme: 'system',
|
||||
defaultDownloadPath: '~/Downloads',
|
||||
maxConcurrentDownloads: 3,
|
||||
globalSpeedLimit: '',
|
||||
activeView: 'downloads',
|
||||
activeSettingsTab: 'downloads',
|
||||
isSidebarVisible: true,
|
||||
scheduler: {
|
||||
enabled: false,
|
||||
startTime: '00:00',
|
||||
stopTimeEnabled: false,
|
||||
stopTime: '08:00',
|
||||
everyday: true,
|
||||
selectedDays: [0, 1, 2, 3, 4, 5, 6],
|
||||
postQueueAction: 'none'
|
||||
},
|
||||
schedulerRunning: false,
|
||||
schedulerLastStartKey: '',
|
||||
schedulerLastStopKey: '',
|
||||
lastCustomSpeedLimitKiB: 1024,
|
||||
|
||||
// Replicated SwiftUI defaults
|
||||
perServerConnections: 16,
|
||||
maxAutomaticRetries: 3,
|
||||
showNotifications: true,
|
||||
playCompletionSound: true,
|
||||
appFontSize: 'standard',
|
||||
listRowDensity: 'standard',
|
||||
showDockBadge: true,
|
||||
showMenuBarIcon: true,
|
||||
proxyMode: 'none',
|
||||
proxyHost: '',
|
||||
proxyPort: 8080,
|
||||
customUserAgent: '',
|
||||
askWhereToSaveEachFile: false,
|
||||
preventsSleepWhileDownloading: true,
|
||||
mediaCookieSource: 'none',
|
||||
downloadDirectories: { ...defaultDirectories },
|
||||
siteLogins: [],
|
||||
extensionPairingToken: generateSecureToken(),
|
||||
autoCheckUpdates: true,
|
||||
|
||||
setTheme: (theme) => set({ theme }),
|
||||
setDefaultDownloadPath: (path) => set({ defaultDownloadPath: path }),
|
||||
setMaxConcurrentDownloads: (max) => {
|
||||
set({ maxConcurrentDownloads: max });
|
||||
invoke('set_concurrent_limit', { limit: max }).catch(console.error);
|
||||
},
|
||||
setGlobalSpeedLimit: (limit) => {
|
||||
set({ globalSpeedLimit: limit });
|
||||
invoke('set_global_speed_limit', { limit: limit === '' || limit === '0' ? null : limit }).catch(console.error);
|
||||
},
|
||||
setActiveView: (view) => set({ activeView: view }),
|
||||
setActiveSettingsTab: (activeSettingsTab) => set({ activeSettingsTab }),
|
||||
setScheduler: (scheduler) => set({ scheduler }),
|
||||
setSchedulerRunning: (schedulerRunning) => set({ schedulerRunning }),
|
||||
setSchedulerLastStartKey: (schedulerLastStartKey) => set({ schedulerLastStartKey }),
|
||||
setSchedulerLastStopKey: (schedulerLastStopKey) => set({ schedulerLastStopKey }),
|
||||
setLastCustomSpeedLimitKiB: (lastCustomSpeedLimitKiB) => set({ lastCustomSpeedLimitKiB }),
|
||||
toggleSidebar: () => set((state) => ({ isSidebarVisible: !state.isSidebarVisible })),
|
||||
|
||||
setPerServerConnections: (perServerConnections) => set({ perServerConnections }),
|
||||
setMaxAutomaticRetries: (maxAutomaticRetries) => set({ maxAutomaticRetries }),
|
||||
setShowNotifications: (showNotifications) => set({ showNotifications }),
|
||||
setPlayCompletionSound: (playCompletionSound) => set({ playCompletionSound }),
|
||||
setAppFontSize: (appFontSize) => set({ appFontSize }),
|
||||
setListRowDensity: (listRowDensity) => set({ listRowDensity }),
|
||||
setShowDockBadge: (showDockBadge) => {
|
||||
set({ showDockBadge });
|
||||
if (!showDockBadge) invoke('update_dock_badge', { count: 0 }).catch(console.error);
|
||||
},
|
||||
setShowMenuBarIcon: (showMenuBarIcon) => set({ showMenuBarIcon }),
|
||||
setProxyMode: (proxyMode) => set({ proxyMode }),
|
||||
setProxyHost: (proxyHost) => set({ proxyHost }),
|
||||
setProxyPort: (proxyPort) => set({ proxyPort }),
|
||||
setCustomUserAgent: (customUserAgent) => set({ customUserAgent }),
|
||||
setAskWhereToSaveEachFile: (askWhereToSaveEachFile) => set({ askWhereToSaveEachFile }),
|
||||
setPreventsSleepWhileDownloading: (preventsSleepWhileDownloading) => {
|
||||
set({ preventsSleepWhileDownloading });
|
||||
if (!preventsSleepWhileDownloading) invoke('set_prevent_sleep', { prevent: false }).catch(console.error);
|
||||
},
|
||||
setMediaCookieSource: (mediaCookieSource) => set({ mediaCookieSource }),
|
||||
setCategoryDirectory: (category, path) => set((state) => ({
|
||||
downloadDirectories: { ...state.downloadDirectories, [category]: path }
|
||||
})),
|
||||
resetCategoryDirectories: () => set({ downloadDirectories: { ...defaultDirectories } }),
|
||||
addSiteLogin: (login) => set((state) => ({
|
||||
siteLogins: [...state.siteLogins, login]
|
||||
})),
|
||||
removeSiteLogin: (id) => set((state) => ({
|
||||
siteLogins: state.siteLogins.filter((login) => login.id !== id)
|
||||
})),
|
||||
regeneratePairingToken: () => set({ extensionPairingToken: generateSecureToken() }),
|
||||
setAutoCheckUpdates: (autoCheckUpdates) => set({ autoCheckUpdates }),
|
||||
}),
|
||||
{
|
||||
name: 'firelink-settings',
|
||||
storage: createJSONStorage(() => tauriStorage),
|
||||
partialize: (state): PersistedSettings => ({
|
||||
theme: state.theme,
|
||||
defaultDownloadPath: state.defaultDownloadPath,
|
||||
maxConcurrentDownloads: state.maxConcurrentDownloads,
|
||||
globalSpeedLimit: state.globalSpeedLimit,
|
||||
isSidebarVisible: state.isSidebarVisible,
|
||||
activeSettingsTab: state.activeSettingsTab,
|
||||
scheduler: state.scheduler,
|
||||
schedulerLastStartKey: state.schedulerLastStartKey,
|
||||
schedulerLastStopKey: state.schedulerLastStopKey,
|
||||
lastCustomSpeedLimitKiB: state.lastCustomSpeedLimitKiB,
|
||||
|
||||
perServerConnections: state.perServerConnections,
|
||||
maxAutomaticRetries: state.maxAutomaticRetries,
|
||||
showNotifications: state.showNotifications,
|
||||
playCompletionSound: state.playCompletionSound,
|
||||
appFontSize: state.appFontSize,
|
||||
listRowDensity: state.listRowDensity,
|
||||
showDockBadge: state.showDockBadge,
|
||||
showMenuBarIcon: state.showMenuBarIcon,
|
||||
proxyMode: state.proxyMode,
|
||||
proxyHost: state.proxyHost,
|
||||
proxyPort: state.proxyPort,
|
||||
customUserAgent: state.customUserAgent,
|
||||
askWhereToSaveEachFile: state.askWhereToSaveEachFile,
|
||||
preventsSleepWhileDownloading: state.preventsSleepWhileDownloading,
|
||||
mediaCookieSource: state.mediaCookieSource,
|
||||
downloadDirectories: state.downloadDirectories,
|
||||
siteLogins: state.siteLogins,
|
||||
extensionPairingToken: state.extensionPairingToken,
|
||||
autoCheckUpdates: state.autoCheckUpdates
|
||||
}),
|
||||
merge: (persistedState: unknown, currentState) => {
|
||||
const persisted = persistedState && typeof persistedState === 'object'
|
||||
? persistedState as Partial<SettingsState>
|
||||
: {};
|
||||
return ({
|
||||
...currentState,
|
||||
...persisted,
|
||||
appFontSize: persisted.appFontSize || currentState.appFontSize,
|
||||
listRowDensity: persisted.listRowDensity || currentState.listRowDensity,
|
||||
downloadDirectories: normalizeDownloadDirectories(persisted.downloadDirectories),
|
||||
siteLogins: Array.isArray(persisted.siteLogins)
|
||||
? persisted.siteLogins
|
||||
: currentState.siteLogins
|
||||
});
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
Reference in New Issue
Block a user