Skip to content
Open
38 changes: 37 additions & 1 deletion electron/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion electron/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,13 @@
"electron-unhandled": "~4.0.1",
"electron-updater": "^6.6.2",
"electron-window-state": "^5.0.3",
"extract-zip": "^2.0.1"
"extract-zip": "^2.0.1",
"write-file-atomic": "^7.0.1"
},
"devDependencies": {
"@electron/notarize": "^2.5.0",
"@electron/rebuild": "^3.7.2",
"@types/write-file-atomic": "^4.0.3",
"electron": "^32.3.1",
"electron-builder": "^25.1.8",
"shelljs": "^0.8.5",
Expand Down
2 changes: 2 additions & 0 deletions electron/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
import { log as loggerLog, error as loggerError } from './logger';
import {
ElectronCapacitorApp,
flushPersistentStore,
setupContentSecurityPolicy,
setupReloadWatcher,
} from './setup';
Expand Down Expand Up @@ -193,6 +194,7 @@ async function setupMultiInstanceUserData(basePort = 55000, maxInstances = 10) {
// Set isQuitting flag before the app quits
app.on('before-quit', () => {
setIsQuitting(true);
flushPersistentStore();
});

// Handle when all of our windows are close (platforms have their own expectations).
Expand Down
23 changes: 20 additions & 3 deletions electron/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,18 @@ try {
windowMinimize: () => ipcRenderer.invoke('window:minimize'),
windowMaximize: () => ipcRenderer.invoke('window:maximize'),
windowClose: () => ipcRenderer.invoke('window:close'),
focusWindow: () => ipcRenderer.invoke('window:focus'),
getWindowState: () =>
ipcRenderer.invoke('window:isMaximized').then((isMaximized: boolean) => ({ isMaximized })),
ipcRenderer
.invoke('window:isMaximized')
.then((isMaximized: boolean) => ({ isMaximized })),
getPlatform: () => ipcRenderer.invoke('window:getPlatform'),
showAppMenu: (x?: number, y?: number) =>
ipcRenderer.invoke('window:showAppMenu', { x, y }),
getAppSettings: () => ipcRenderer.invoke('appSettings:get'),
setAppSettings: (settings: { closeAction?: 'ask' | 'minimizeToTray' | 'quit' }) =>
ipcRenderer.invoke('appSettings:set', settings),
setAppSettings: (settings: {
closeAction?: 'ask' | 'minimizeToTray' | 'quit';
}) => ipcRenderer.invoke('appSettings:set', settings),
});

// Expose other utility functions
Expand Down Expand Up @@ -86,6 +90,19 @@ try {
},
});

// Generic persistent store (persistent-store.json, in-memory cache + debounced writes in main)
contextBridge.exposeInMainWorld('appStorage', {
get: async (key) => {
return ipcRenderer.invoke('persistentStore:get', key);
},
set: async (key, value) => {
return ipcRenderer.invoke('persistentStore:set', key, value);
},
delete: async (key) => {
return ipcRenderer.invoke('persistentStore:delete', key);
},
});

// Expose it
contextBridge.exposeInMainWorld('coreSetup', {
isCoreRunning: async () => {
Expand Down
138 changes: 136 additions & 2 deletions electron/src/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import {
const AdmZip = require('adm-zip');
const fs = require('fs');
const path = require('path');
const writeFileAtomic = require('write-file-atomic');

const defaultDomains = [
'capacitor-electron://-',
Expand Down Expand Up @@ -540,6 +541,14 @@ ipcMain.handle('window:close', () => {
if (win && !win.isDestroyed()) win.close();
});

ipcMain.handle('window:focus', () => {
const win = myCapacitorApp.getMainWindow();
if (win && !win.isDestroyed()) {
win.show();
win.focus();
}
});

ipcMain.handle('window:isMaximized', () => {
const win = myCapacitorApp.getMainWindow();
return win != null && !win.isDestroyed() && win.isMaximized();
Expand Down Expand Up @@ -652,7 +661,8 @@ export async function readAppSettings(): Promise<AppSettings> {
...DEFAULT_APP_SETTINGS,
...parsed,
closeAction:
parsed.closeAction && ['ask', 'minimizeToTray', 'quit'].includes(parsed.closeAction)
parsed.closeAction &&
['ask', 'minimizeToTray', 'quit'].includes(parsed.closeAction)
? (parsed.closeAction as CloseAction)
: DEFAULT_APP_SETTINGS.closeAction,
};
Expand All @@ -663,7 +673,11 @@ export async function readAppSettings(): Promise<AppSettings> {

async function writeAppSettings(settings: AppSettings): Promise<void> {
const filePath = await getSharedSettingsFilePath(APP_SETTINGS_FILENAME);
await fs.promises.writeFile(filePath, JSON.stringify(settings, null, 2), 'utf-8');
await fs.promises.writeFile(
filePath,
JSON.stringify(settings, null, 2),
'utf-8'
);
}

// READ handler
Expand Down Expand Up @@ -697,6 +711,126 @@ ipcMain.handle(
}
);

// Persistent store: shared across instances via atomic writes to appData/qortal-hub/
// Uses write-file-atomic to prevent partial writes corrupting the file.
// On set/delete: read-from-disk → merge → atomic write, so concurrent instances
// never overwrite each other's keys (only a simultaneous write of the *same* key
// by two instances at the exact same moment could still race, which is acceptable).
const PERSISTENT_STORE_FILENAME = 'qortal-persistent-store.json';

let persistentStoreCache: Record<string, unknown> | null = null;
let persistentStoreLoadedFromDisk = false;

function getPersistentStoreFilePath(): string {
const dir = path.join(app.getPath('appData'), 'qortal-hub');
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
return path.join(dir, PERSISTENT_STORE_FILENAME);
}

function parsePersistentStoreRaw(raw: string): Record<string, unknown> {
const trimmed = raw?.trim() ?? '';
if (trimmed === '') return {};
try {
return (JSON.parse(trimmed) as Record<string, unknown>) || {};
} catch (_) {
return {};
}
}

async function readPersistentStoreFromDisk(): Promise<Record<string, unknown>> {
try {
const filePath = getPersistentStoreFilePath();
const stats = await fs.promises.stat(filePath).catch(() => null);
if (!stats?.isFile()) return {};
const raw = await fs.promises.readFile(filePath, 'utf-8');
return parsePersistentStoreRaw(raw);
} catch (err) {
loggerError('Error reading persistent store from disk', err);
return {};
}
}

async function loadPersistentStore(): Promise<Record<string, unknown>> {
if (persistentStoreCache !== null) return persistentStoreCache;
const data = await readPersistentStoreFromDisk();
const hadData = Object.keys(data).length > 0;
persistentStoreCache = data;
if (hadData) persistentStoreLoadedFromDisk = true;
return persistentStoreCache;
}


export function flushPersistentStore(): void {
if (persistentStoreCache === null) return;
if (
!persistentStoreLoadedFromDisk &&
Object.keys(persistentStoreCache).length === 0
) {
return;
}
try {
const filePath = getPersistentStoreFilePath();
// Read current on-disk state, merge our cache on top, write atomically (sync).
let onDisk: Record<string, unknown> = {};
if (fs.existsSync(filePath)) {
try {
onDisk = parsePersistentStoreRaw(fs.readFileSync(filePath, 'utf-8'));
} catch (_) {
onDisk = {};
}
}
const merged = { ...onDisk, ...persistentStoreCache };
writeFileAtomic.sync(filePath, JSON.stringify(merged, null, 2), {
encoding: 'utf8',
});
} catch (err) {
loggerError('Error flushing persistent store', err);
}
}

ipcMain.handle('persistentStore:get', async (_event, key: string) => {
const store = await loadPersistentStore();
return store[key];
});

ipcMain.handle(
'persistentStore:set',
async (_event, key: string, value: unknown) => {
// Read-merge-write: fetch fresh disk state, merge the new key, write atomically.
// This ensures concurrent instances don't clobber each other's unrelated keys.
const onDisk = await readPersistentStoreFromDisk();
onDisk[key] = value;
try {
const filePath = getPersistentStoreFilePath();
await writeFileAtomic(filePath, JSON.stringify(onDisk, null, 2), {
encoding: 'utf8',
});
} catch (err) {
loggerError('Error writing persistent store (set)', err);
}
// Keep local cache in sync.
if (persistentStoreCache === null) persistentStoreCache = {};
persistentStoreCache[key] = value;
persistentStoreLoadedFromDisk = true;
}
);

ipcMain.handle('persistentStore:delete', async (_event, key: string) => {
// Read-merge-write: fetch fresh disk state, remove the key, write atomically.
const onDisk = await readPersistentStoreFromDisk();
delete onDisk[key];
try {
const filePath = getPersistentStoreFilePath();
await writeFileAtomic(filePath, JSON.stringify(onDisk, null, 2), {
encoding: 'utf8',
});
} catch (err) {
loggerError('Error writing persistent store (delete)', err);
}
// Keep local cache in sync.
if (persistentStoreCache !== null) delete persistentStoreCache[key];
});

// App settings (stored in SharedSettingsFilePath) - e.g. close/minimize to tray
ipcMain.handle('appSettings:get', async () => {
return readAppSettings();
Expand Down
3 changes: 2 additions & 1 deletion electron/src/video-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as crypto from 'crypto';
import { URL } from 'url';

import { log as loggerLog, error as loggerError } from './logger';
import { isLocalPrivateHost } from './local-https-cert';

interface EncryptionConfig {
key: Buffer;
Expand Down Expand Up @@ -75,7 +76,7 @@ function decryptChunk(
function fetchRange(url: string, start: number, end: number): Promise<Buffer> {
return new Promise((resolve, reject) => {
const urlObj = new URL(url);
const client = http;
const client = urlObj.protocol === 'https:' && !isLocalPrivateHost(urlObj.hostname) ? https : http;

const options: http.RequestOptions = {
hostname: urlObj.hostname,
Expand Down
11 changes: 11 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,10 @@ import {
ConnectionRequestScreen,
CountdownOverlay,
CreateWalletView,
ElectronPersistentStorageHydration,
InfoDialog,
NotAuthenticatedFooter,
NotificationPermissionSlideDown,
PaymentPublishDialog,
PaymentRequestScreen,
QortalRequestExtensionDialog,
Expand Down Expand Up @@ -136,6 +138,13 @@ function App() {
const [requestConnection, setRequestConnection] = useState<any>(null);
const [requestBuyOrder, setRequestBuyOrder] = useState<any>(null);
const [userInfo, setUserInfo] = useAtom(userInfoAtom);
useEffect(() => {
const w = window as Window & { __qortalCurrentAddress?: string | null };
w.__qortalCurrentAddress = userInfo?.address ?? null;
return () => {
delete w.__qortalCurrentAddress;
};
}, [userInfo?.address]);
const [balance, setBalance] = useAtom(balanceAtom);
const [paymentTo, setPaymentTo] = useState<string>('');
const [sendPaymentError, setSendPaymentError] = useState<string>('');
Expand Down Expand Up @@ -994,6 +1003,7 @@ function App() {
<PdfViewer />

<QORTAL_APP_CONTEXT.Provider value={contextValue as AppContextInterface}>
<ElectronPersistentStorageHydration />
<CoreSetup />
<Tutorials />
{extState === 'not-authenticated' && (
Expand Down Expand Up @@ -1212,6 +1222,7 @@ function App() {
onCancel={onCancelUnsavedChanges}
onConfirm={() => onOkUnsavedChanges(undefined)}
/>
{isMainWindow && <NotificationPermissionSlideDown />}
{isShowQortalRequestExtension && isMainWindow && (
<QortalRequestExtensionDialog
open={isShowQortalRequestExtension}
Expand Down
Loading