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:
NimBold
2026-06-23 21:26:51 +03:30
parent 0e3118ec2c
commit f603b74a99
41 changed files with 1889 additions and 382 deletions
+101 -22
View File
@@ -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 -1
View File
@@ -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, };
+1 -1
View File
@@ -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, };
+3
View File
@@ -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, };
+31 -10
View File
@@ -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;
+2 -2
View File
@@ -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>
+14 -2
View File
@@ -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>
+27 -19
View File
@@ -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.')}
+3
View File
@@ -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 };
+23 -1
View File
@@ -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,
+6
View File
@@ -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();
});
+17
View File
@@ -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
View File
@@ -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;
+45
View File
@@ -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;
};