feat: implement native system tray, background notifications, and download resumability via tauri-plugin-store

This commit is contained in:
NimBold
2026-06-16 11:38:02 +03:30
parent b6678493d3
commit 275eb6e5c4
25 changed files with 336 additions and 322 deletions
+14
View File
@@ -21,6 +21,7 @@ export const useDownloadProgressStore = create<DownloadProgressState>((set) => (
}));
let unlistenProgress: UnlistenFn | null = null;
let unlistenTray: UnlistenFn | null = null;
export async function initDownloadListener() {
if (unlistenProgress) return;
@@ -38,4 +39,17 @@ export async function initDownloadListener() {
mainStore.updateDownload(payload.id, { size: payload.size });
}
});
if (!unlistenTray) {
unlistenTray = await listen<string>('tray-action', (event) => {
const mainStore = useDownloadStore.getState();
if (event.payload === 'pause-all') {
const uniqueQueues = Array.from(new Set(mainStore.downloads.map(d => d.queueId)));
uniqueQueues.forEach(qid => mainStore.pauseQueue(qid));
} else if (event.payload === 'resume-all') {
const uniqueQueues = Array.from(new Set(mainStore.downloads.map(d => d.queueId)));
uniqueQueues.forEach(qid => mainStore.startQueue(qid));
}
});
}
}
+33 -54
View File
@@ -1,5 +1,8 @@
import { create } from 'zustand';
import { LazyStore } from '@tauri-apps/plugin-store';
import { invokeCommand as invoke } from '../ipc';
export const tauriStore = new LazyStore('store.bin');
import type { DownloadItem } from '../bindings/DownloadItem';
import type { DownloadStatus } from '../bindings/DownloadStatus';
import type { ExtensionDownload } from '../bindings/ExtensionDownload';
@@ -138,13 +141,10 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
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);
set((state) => ({ downloads: [...state.downloads, item] }));
get().processQueue();
},
updateDownload: (id, updates) => {
let updatedItem: DownloadItem | null = null;
set((state) => ({
downloads: state.downloads.map(d => {
if (d.id === id) {
@@ -153,19 +153,12 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
...updates,
fraction: 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();
@@ -194,12 +187,10 @@ 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();
},
redownload: (id) => {
let updatedItem: DownloadItem | null = null;
let wasDownloading = false;
set((state) => ({
downloads: state.downloads.map(d => {
@@ -208,7 +199,6 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
wasDownloading = true;
}
const updated: DownloadItem = { ...d, status: 'queued', _dispatched: false, fraction: 0, speed: '-', eta: '-' };
updatedItem = updated;
return updated;
}
return d;
@@ -217,11 +207,6 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
if (wasDownloading) {
invoke('pause_download', { id }).catch(console.error);
}
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) => {
@@ -238,13 +223,6 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
: item
)
}));
const downloadsToSave = get().downloads.filter(item => runnableIds.includes(item.id));
downloadsToSave.forEach(d => {
const toSave = { ...d };
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);
});
await get().processQueue();
return runnableIds.length;
@@ -263,13 +241,6 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
: item
)
}));
const downloadsToSave = get().downloads.filter(item => activeIds.includes(item.id));
downloadsToSave.forEach(d => {
const toSave = { ...d };
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);
});
await Promise.all(activeIds.map(id => invoke('pause_download', { id }).catch(() => {})));
syncSystemIntegrations();
@@ -281,51 +252,32 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
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;
const affectedDownloads = get().downloads.filter(d => d.queueId === id);
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
affectedDownloads.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));
const queues = await tauriStore.get<Queue[]>('queues') || [];
const downloads = await tauriStore.get<DownloadItem[]>('download_queue') || [];
set(state => ({
queues: queues.length > 0 ? queues : state.queues,
@@ -460,3 +412,30 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
}
}
}));
let lastSavedDownloads = '';
useDownloadStore.subscribe(async (state, prevState) => {
if (state.queues !== prevState.queues) {
await tauriStore.set('queues', state.queues);
await tauriStore.save();
}
if (state.downloads !== prevState.downloads) {
const staticDownloads = state.downloads.map(d => {
const copy = { ...d };
delete copy.fraction;
delete copy.speed;
delete copy.eta;
delete copy._dispatched;
return copy;
});
const currentSerialized = JSON.stringify(staticDownloads);
if (currentSerialized !== lastSavedDownloads) {
lastSavedDownloads = currentSerialized;
await tauriStore.set('download_queue', staticDownloads);
await tauriStore.save();
}
}
});
+6 -3
View File
@@ -13,12 +13,14 @@ import type { SettingsTab } from '../bindings/SettingsTab';
import type { SiteLogin } from '../bindings/SiteLogin';
import type { Theme } from '../bindings/Theme';
import { tauriStore } from './useDownloadStore';
const tauriStorage: StateStorage = {
getItem: async (name: string): Promise<string | null> => {
if (name === 'firelink-settings') {
try {
const data = await invoke('db_load_settings');
return data;
const data = await tauriStore.get<string>('settings');
return data || null;
} catch (e) {
console.error("Failed to load settings from DB", e);
return null;
@@ -29,7 +31,8 @@ const tauriStorage: StateStorage = {
setItem: async (name: string, value: string): Promise<void> => {
if (name === 'firelink-settings') {
try {
await invoke('db_save_settings', { data: value });
await tauriStore.set('settings', value);
await tauriStore.save();
} catch (e) {
console.error("Failed to save settings to DB", e);
}