mirror of
https://github.com/Qortal/Qortal-Hub.git
synced 2025-04-21 18:37:51 +00:00
433 lines
14 KiB
TypeScript
433 lines
14 KiB
TypeScript
import type { CapacitorElectronConfig } from '@capacitor-community/electron';
|
|
import {
|
|
CapElectronEventEmitter,
|
|
CapacitorSplashScreen,
|
|
setupCapacitorElectronPlugins,
|
|
} from '@capacitor-community/electron';
|
|
import chokidar from 'chokidar';
|
|
import type { MenuItemConstructorOptions } from 'electron';
|
|
import { app, BrowserWindow, Menu, MenuItem, nativeImage, Tray, session, ipcMain, dialog } from 'electron';
|
|
import electronIsDev from 'electron-is-dev';
|
|
import electronServe from 'electron-serve';
|
|
import windowStateKeeper from 'electron-window-state';
|
|
const AdmZip = require('adm-zip');
|
|
import { join } from 'path';
|
|
import { myCapacitorApp } from '.';
|
|
const fs = require('fs');
|
|
const path = require('path')
|
|
|
|
const defaultDomains = [
|
|
'capacitor-electron://-',
|
|
'http://127.0.0.1:12391',
|
|
'ws://127.0.0.1:12391',
|
|
'https://ext-node.qortal.link',
|
|
'wss://ext-node.qortal.link',
|
|
'https://appnode.qortal.org',
|
|
'wss://appnode.qortal.org',
|
|
"https://api.qortal.org",
|
|
"https://api2.qortal.org",
|
|
"https://apinode.qortalnodes.live",
|
|
"https://apinode1.qortalnodes.live",
|
|
"https://apinode2.qortalnodes.live",
|
|
"https://apinode3.qortalnodes.live",
|
|
"https://apinode4.qortalnodes.live",
|
|
"https://www.qort.trade"
|
|
];
|
|
|
|
// let allowedDomains: string[] = [...defaultDomains]
|
|
const domainHolder = {
|
|
allowedDomains: [...defaultDomains],
|
|
};
|
|
// Define components for a watcher to detect when the webapp is changed so we can reload in Dev mode.
|
|
const reloadWatcher = {
|
|
debouncer: null,
|
|
ready: false,
|
|
watcher: null,
|
|
};
|
|
export function setupReloadWatcher(electronCapacitorApp: ElectronCapacitorApp): void {
|
|
reloadWatcher.watcher = chokidar
|
|
.watch(join(app.getAppPath(), 'app'), {
|
|
ignored: /[/\\]\./,
|
|
persistent: true,
|
|
})
|
|
.on('ready', () => {
|
|
reloadWatcher.ready = true;
|
|
})
|
|
.on('all', (_event, _path) => {
|
|
if (reloadWatcher.ready) {
|
|
clearTimeout(reloadWatcher.debouncer);
|
|
reloadWatcher.debouncer = setTimeout(async () => {
|
|
electronCapacitorApp.getMainWindow().webContents.reload();
|
|
reloadWatcher.ready = false;
|
|
clearTimeout(reloadWatcher.debouncer);
|
|
reloadWatcher.debouncer = null;
|
|
reloadWatcher.watcher = null;
|
|
setupReloadWatcher(electronCapacitorApp);
|
|
}, 1500);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Define our class to manage our app.
|
|
export class ElectronCapacitorApp {
|
|
private MainWindow: BrowserWindow | null = null;
|
|
private SplashScreen: CapacitorSplashScreen | null = null;
|
|
private TrayIcon: Tray | null = null;
|
|
private CapacitorFileConfig: CapacitorElectronConfig;
|
|
private TrayMenuTemplate: (MenuItem | MenuItemConstructorOptions)[] = [
|
|
new MenuItem({ label: 'Quit App', role: 'quit' }),
|
|
];
|
|
private AppMenuBarMenuTemplate: (MenuItem | MenuItemConstructorOptions)[] = [
|
|
{ role: process.platform === 'darwin' ? 'appMenu' : 'fileMenu' },
|
|
{ role: 'viewMenu' },
|
|
];
|
|
private mainWindowState;
|
|
private loadWebApp;
|
|
private customScheme: string;
|
|
|
|
constructor(
|
|
capacitorFileConfig: CapacitorElectronConfig,
|
|
trayMenuTemplate?: (MenuItemConstructorOptions | MenuItem)[],
|
|
appMenuBarMenuTemplate?: (MenuItemConstructorOptions | MenuItem)[]
|
|
) {
|
|
this.CapacitorFileConfig = capacitorFileConfig;
|
|
|
|
this.customScheme = this.CapacitorFileConfig.electron?.customUrlScheme ?? 'capacitor-electron';
|
|
|
|
if (trayMenuTemplate) {
|
|
this.TrayMenuTemplate = trayMenuTemplate;
|
|
}
|
|
|
|
if (appMenuBarMenuTemplate) {
|
|
this.AppMenuBarMenuTemplate = appMenuBarMenuTemplate;
|
|
}
|
|
|
|
// Setup our web app loader, this lets us load apps like react, vue, and angular without changing their build chains.
|
|
this.loadWebApp = electronServe({
|
|
directory: join(app.getAppPath(), 'app'),
|
|
scheme: this.customScheme,
|
|
});
|
|
}
|
|
|
|
// Helper function to load in the app.
|
|
private async loadMainWindow(thisRef: any) {
|
|
await thisRef.loadWebApp(thisRef.MainWindow);
|
|
}
|
|
|
|
// Expose the mainWindow ref for use outside of the class.
|
|
getMainWindow(): BrowserWindow {
|
|
return this.MainWindow;
|
|
}
|
|
|
|
getCustomURLScheme(): string {
|
|
return this.customScheme;
|
|
}
|
|
|
|
async init(): Promise<void> {
|
|
const icon = nativeImage.createFromPath(
|
|
join(app.getAppPath(), 'assets', process.platform === 'win32' ? 'appIcon.ico' : 'appIcon.png')
|
|
);
|
|
this.mainWindowState = windowStateKeeper({
|
|
defaultWidth: 1000,
|
|
defaultHeight: 800,
|
|
});
|
|
// Setup preload script path and construct our main window.
|
|
const preloadPath = join(app.getAppPath(), 'build', 'src', 'preload.js');
|
|
this.MainWindow = new BrowserWindow({
|
|
icon,
|
|
show: false,
|
|
x: this.mainWindowState.x,
|
|
y: this.mainWindowState.y,
|
|
width: this.mainWindowState.width,
|
|
height: this.mainWindowState.height,
|
|
backgroundColor: '#27282c',
|
|
webPreferences: {
|
|
nodeIntegration: true,
|
|
contextIsolation: true,
|
|
// Use preload to inject the electron varriant overrides for capacitor plugins.
|
|
// preload: join(app.getAppPath(), "node_modules", "@capacitor-community", "electron", "dist", "runtime", "electron-rt.js"),
|
|
preload: preloadPath },
|
|
});
|
|
this.mainWindowState.manage(this.MainWindow);
|
|
|
|
if (this.CapacitorFileConfig.backgroundColor) {
|
|
this.MainWindow.setBackgroundColor(this.CapacitorFileConfig.electron.backgroundColor);
|
|
}
|
|
|
|
// If we close the main window with the splashscreen enabled we need to destory the ref.
|
|
this.MainWindow.on('closed', () => {
|
|
if (this.SplashScreen?.getSplashWindow() && !this.SplashScreen.getSplashWindow().isDestroyed()) {
|
|
this.SplashScreen.getSplashWindow().close();
|
|
}
|
|
});
|
|
|
|
// When the tray icon is enabled, setup the options.
|
|
if (this.CapacitorFileConfig.electron?.trayIconAndMenuEnabled) {
|
|
this.TrayIcon = new Tray(icon);
|
|
this.TrayIcon.on('double-click', () => {
|
|
if (this.MainWindow) {
|
|
if (this.MainWindow.isVisible()) {
|
|
this.MainWindow.hide();
|
|
} else {
|
|
this.MainWindow.show();
|
|
this.MainWindow.focus();
|
|
}
|
|
}
|
|
});
|
|
this.TrayIcon.on('click', () => {
|
|
if (this.MainWindow) {
|
|
if (this.MainWindow.isVisible()) {
|
|
this.MainWindow.hide();
|
|
} else {
|
|
this.MainWindow.show();
|
|
this.MainWindow.focus();
|
|
}
|
|
}
|
|
});
|
|
this.TrayIcon.setToolTip(app.getName());
|
|
this.TrayIcon.setContextMenu(Menu.buildFromTemplate(this.TrayMenuTemplate));
|
|
}
|
|
|
|
// Setup the main manu bar at the top of our window.
|
|
Menu.setApplicationMenu(Menu.buildFromTemplate(this.AppMenuBarMenuTemplate));
|
|
|
|
// If the splashscreen is enabled, show it first while the main window loads then switch it out for the main window, or just load the main window from the start.
|
|
if (this.CapacitorFileConfig.electron?.splashScreenEnabled) {
|
|
this.SplashScreen = new CapacitorSplashScreen({
|
|
imageFilePath: join(
|
|
app.getAppPath(),
|
|
'assets',
|
|
this.CapacitorFileConfig.electron?.splashScreenImageName ?? 'splash.png'
|
|
),
|
|
windowWidth: 400,
|
|
windowHeight: 400,
|
|
});
|
|
this.SplashScreen.init(this.loadMainWindow, this);
|
|
} else {
|
|
this.loadMainWindow(this);
|
|
}
|
|
|
|
// Security
|
|
this.MainWindow.webContents.setWindowOpenHandler((details) => {
|
|
if (!details.url.includes(this.customScheme)) {
|
|
return { action: 'deny' };
|
|
} else {
|
|
return { action: 'allow' };
|
|
}
|
|
});
|
|
this.MainWindow.webContents.on('will-navigate', (event, _newURL) => {
|
|
if (!this.MainWindow.webContents.getURL().includes(this.customScheme)) {
|
|
event.preventDefault();
|
|
}
|
|
});
|
|
|
|
// Link electron plugins into the system.
|
|
setupCapacitorElectronPlugins();
|
|
|
|
// When the web app is loaded we hide the splashscreen if needed and show the mainwindow.
|
|
this.MainWindow.webContents.on('dom-ready', () => {
|
|
if (this.CapacitorFileConfig.electron?.splashScreenEnabled) {
|
|
this.SplashScreen.getSplashWindow().hide();
|
|
}
|
|
if (!this.CapacitorFileConfig.electron?.hideMainWindowOnLaunch) {
|
|
this.MainWindow.show();
|
|
}
|
|
setTimeout(() => {
|
|
if (electronIsDev) {
|
|
this.MainWindow.webContents.openDevTools();
|
|
}
|
|
CapElectronEventEmitter.emit('CAPELECTRON_DeeplinkListenerInitialized', '');
|
|
}, 400);
|
|
});
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
export function setupContentSecurityPolicy(customScheme: string): void {
|
|
session.defaultSession.webRequest.onHeadersReceived((details: any, callback) => {
|
|
const allowedSources = ["'self'", customScheme, ...domainHolder.allowedDomains];
|
|
const connectSources = [...allowedSources];
|
|
const frameSources = [
|
|
"'self'",
|
|
'http://localhost:*',
|
|
'https://localhost:*',
|
|
'http://127.0.0.1:*',
|
|
'https://127.0.0.1:*',
|
|
...allowedSources,
|
|
];
|
|
|
|
// Create the Content Security Policy (CSP) string
|
|
const csp = `
|
|
default-src 'self' ${allowedSources.join(' ')};
|
|
frame-src ${frameSources.join(' ')};
|
|
script-src 'self' 'wasm-unsafe-eval' 'unsafe-inline' 'unsafe-eval' ${allowedSources.join(' ')};
|
|
object-src 'self';
|
|
connect-src 'self' blob: ${connectSources.join(' ')};
|
|
img-src 'self' data: blob: ${allowedSources.join(' ')};
|
|
media-src 'self' blob: ${allowedSources.join(' ')};
|
|
style-src 'self' 'unsafe-inline';
|
|
font-src 'self' data:;
|
|
`.replace(/\s+/g, ' ').trim();
|
|
|
|
|
|
// Get the request URL and origin
|
|
const requestUrl = details.url;
|
|
const requestOrigin = details.origin || details.referrer || 'capacitor-electron://-';
|
|
|
|
// Parse the request URL to get its origin
|
|
let requestUrlOrigin: string;
|
|
try {
|
|
const parsedUrl = new URL(requestUrl);
|
|
requestUrlOrigin = parsedUrl.origin;
|
|
} catch (e) {
|
|
// Handle invalid URLs gracefully
|
|
requestUrlOrigin = '';
|
|
}
|
|
|
|
// Determine if the request is cross-origin
|
|
const isCrossOrigin = requestOrigin !== requestUrlOrigin;
|
|
|
|
// Check if the response already includes Access-Control-Allow-Origin
|
|
const hasAccessControlAllowOrigin = Object.keys(details.responseHeaders).some(
|
|
(header) => header.toLowerCase() === 'access-control-allow-origin'
|
|
);
|
|
|
|
// Prepare response headers
|
|
const responseHeaders: Record<string, string | string[]> = {
|
|
...details.responseHeaders,
|
|
'Content-Security-Policy': [csp],
|
|
};
|
|
|
|
if (isCrossOrigin && !hasAccessControlAllowOrigin) {
|
|
// Handle CORS for cross-origin requests lacking CORS headers
|
|
// Optionally, check if the requestOrigin is allowed
|
|
responseHeaders['Access-Control-Allow-Origin'] = requestOrigin;
|
|
responseHeaders['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS, DELETE';
|
|
responseHeaders['Access-Control-Allow-Headers'] = 'Content-Type, Authorization, x-api-key';
|
|
}
|
|
|
|
// Callback with modified headers
|
|
callback({ responseHeaders });
|
|
});
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// IPC listener for updating allowed domains
|
|
ipcMain.on('set-allowed-domains', (event, domains: string[]) => {
|
|
|
|
// Validate and transform user-provided domains
|
|
const validatedUserDomains = domains
|
|
.flatMap((domain) => {
|
|
try {
|
|
const url = new URL(domain);
|
|
const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const socketUrl = `${protocol}//${url.hostname}${url.port ? ':' + url.port : ''}`;
|
|
return [url.origin, socketUrl];
|
|
} catch {
|
|
return [];
|
|
}
|
|
})
|
|
.filter(Boolean) as string[];
|
|
|
|
// Combine default and validated user domains
|
|
const newAllowedDomains = [...new Set([...defaultDomains, ...validatedUserDomains])];
|
|
|
|
// Sort both current allowed domains and new domains for comparison
|
|
const sortedCurrentDomains = [...domainHolder.allowedDomains].sort();
|
|
const sortedNewDomains = [...newAllowedDomains].sort();
|
|
|
|
// Check if the lists are different
|
|
const hasChanged =
|
|
sortedCurrentDomains.length !== sortedNewDomains.length ||
|
|
sortedCurrentDomains.some((domain, index) => domain !== sortedNewDomains[index]);
|
|
|
|
// If there's a change, update allowedDomains and reload the window
|
|
if (hasChanged) {
|
|
domainHolder.allowedDomains = newAllowedDomains;
|
|
|
|
const mainWindow = myCapacitorApp.getMainWindow();
|
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
mainWindow.webContents.reload();
|
|
}
|
|
}
|
|
});
|
|
|
|
|
|
ipcMain.handle('dialog:openFile', async () => {
|
|
const result = await dialog.showOpenDialog({
|
|
properties: ['openFile'],
|
|
filters: [
|
|
{ name: 'ZIP Files', extensions: ['zip'] } // Restrict to ZIP files
|
|
],
|
|
});
|
|
return result.filePaths[0];
|
|
});
|
|
|
|
ipcMain.handle('fs:readFile', async (_, filePath) => {
|
|
try {
|
|
// Ensure the file exists
|
|
if (!fs.existsSync(filePath)) {
|
|
throw new Error('File does not exist.');
|
|
}
|
|
|
|
// Ensure the filePath is an absolute path (optional but recommended for safety)
|
|
const absolutePath = path.resolve(filePath);
|
|
|
|
// Read the file as a Buffer
|
|
const fileBuffer = fs.readFileSync(absolutePath);
|
|
|
|
return fileBuffer
|
|
|
|
} catch (error) {
|
|
console.error('Error reading file:', error.message);
|
|
return null; // Return null on error
|
|
}
|
|
});
|
|
|
|
ipcMain.handle('fs:selectAndZip', async (_, path) => {
|
|
let directoryPath = path
|
|
if(!directoryPath){
|
|
const { canceled, filePaths } = await dialog.showOpenDialog({
|
|
properties: ['openDirectory'],
|
|
});
|
|
if (canceled || filePaths.length === 0) {
|
|
console.log('No directory selected');
|
|
return null;
|
|
}
|
|
|
|
directoryPath = filePaths[0];
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
// Add the entire directory to the zip
|
|
const zip = new AdmZip();
|
|
|
|
// Add the entire directory to the zip
|
|
zip.addLocalFolder(directoryPath);
|
|
|
|
// Generate the zip file as a buffer
|
|
const zipBuffer = zip.toBuffer();
|
|
|
|
return {buffer: zipBuffer, directoryPath}
|
|
} catch (error) {
|
|
return null
|
|
}
|
|
});
|
|
|