feat(core): modernize backend with rusqlite and tokio scheduler

This commit migrates Firelink to use native Rust implementations for state persistence (SQLite) and background scheduling (tokio::spawn + chrono). React's interval pollers are completely removed.
This commit is contained in:
NimBold
2026-06-13 22:40:19 +03:30
parent a2b64aa6d1
commit d6fa8b6865
8 changed files with 815 additions and 214 deletions
+15 -30
View File
@@ -55,6 +55,10 @@ function App() {
const previousSpeedLimit = useRef(globalSpeedLimit);
const maxConcurrentDownloads = useSettingsStore(state => state.maxConcurrentDownloads);
useEffect(() => {
useDownloadStore.getState().initDB();
}, []);
useEffect(() => {
window.document.documentElement.setAttribute('data-font-size', appFontSize);
}, [appFontSize]);
@@ -114,39 +118,20 @@ function App() {
}, [globalSpeedLimit]);
useEffect(() => {
const checkSchedule = async () => {
const unlisten = listen('schedule-trigger', async (event) => {
const state = useSettingsStore.getState();
const scheduler = state.scheduler;
if (!scheduler.enabled) return;
const now = new Date();
const currentTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
const allowedToday = scheduler.everyday || scheduler.selectedDays.includes(now.getDay());
if (!allowedToday) return;
const dateKey = localDateKey(now);
if (scheduler.startTime === currentTime) {
const triggerKey = `${dateKey}-${currentTime}`;
if (state.schedulerLastStartKey !== triggerKey) {
state.setSchedulerLastStartKey(triggerKey);
const started = await useDownloadStore.getState().startQueue(MAIN_QUEUE_ID);
state.setSchedulerRunning(started > 0);
}
}
if (scheduler.stopTimeEnabled && scheduler.stopTime === currentTime) {
const triggerKey = `${dateKey}-${currentTime}`;
if (state.schedulerLastStopKey !== triggerKey) {
state.setSchedulerLastStopKey(triggerKey);
await useDownloadStore.getState().pauseQueue(MAIN_QUEUE_ID);
state.setSchedulerRunning(false);
}
if (event.payload === 'start') {
const started = await useDownloadStore.getState().startQueue(MAIN_QUEUE_ID);
state.setSchedulerRunning(started > 0);
} else if (event.payload === 'stop') {
await useDownloadStore.getState().pauseQueue(MAIN_QUEUE_ID);
state.setSchedulerRunning(false);
}
});
return () => {
unlisten.then(f => f()).catch(console.error);
};
void checkSchedule();
const interval = window.setInterval(() => void checkSchedule(), 10_000);
return () => window.clearInterval(interval);
}, []);
useEffect(() => {
+108 -10
View File
@@ -116,6 +116,7 @@ interface DownloadState {
addQueue: (name: string) => void;
renameQueue: (id: string, name: string) => void;
removeQueue: (id: string) => void;
initDB: () => Promise<void>;
}
export const useDownloadStore = create<DownloadState>((set, get) => ({
@@ -174,14 +175,23 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
});
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) {
@@ -189,16 +199,24 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
if (newFraction === 0 && d.fraction && d.fraction > 0) {
newFraction = d.fraction;
}
return {
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();
@@ -219,22 +237,37 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
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 =>
d.id === id
? { ...d, status: 'queued', _dispatched: false, fraction: 0, speed: '-', eta: '-' }
: d
)
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) => {
@@ -273,18 +306,83 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
return activeIds.length;
},
addQueue: (name) => {
set((state) => ({ queues: [...state.queues, { id: crypto.randomUUID(), name, isMain: false }] }));
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 => q.id === id ? { ...q, name } : q)
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 || q.isMain),
downloads: state.downloads.map(d => d.queueId === id ? { ...d, queueId: MAIN_QUEUE_ID } : d)
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<string[]>('db_get_all_queues');
const queues = queuesStr.map(q => JSON.parse(q));
const downloadsStr = await invoke<string[]>('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,
filename: item.fileName, format_selector: item.mediaFormatSelector,
proxy: null
}).catch(console.error);
} else {
invoke('start_download', {
id: item.id, url: item.url, destination: item.destination, filename: item.fileName,
connections: item.connections, speed_limit: item.speedLimit, username: item.username,
password: item.password, headers: item.headers, checksum: item.checksum, cookies: item.cookies,
mirrors: item.mirrors, user_agent: null, max_tries: null, proxy: null
}).catch(console.error);
}
});
void get().processQueue();
} catch (e) {
console.error("Failed to init DB", e);
}
},
processQueue: async () => {
const { downloads, updateDownload } = get();
+29 -1
View File
@@ -1,7 +1,34 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { persist, createJSONStorage, StateStorage } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core';
const tauriStorage: StateStorage = {
getItem: async (name: string): Promise<string | null> => {
if (name === 'firelink-settings') {
try {
const data = await invoke<string | null>('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 interface SiteLogin {
id: string;
urlPattern: string;
@@ -243,6 +270,7 @@ export const useSettingsStore = create<SettingsState>()(
}),
{
name: 'firelink-settings',
storage: createJSONStorage(() => tauriStorage),
partialize: (state) => ({
theme: state.theme,
defaultDownloadPath: state.defaultDownloadPath,