Created
March 19, 2026 01:11
-
-
Save 3p3r/6c523f1783f84e034d6c832ab4216427 to your computer and use it in GitHub Desktop.
Electron shim for web builds
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { directoryOpen, fileOpen } from 'browser-fs-access'; | |
| import debug from 'debug'; | |
| import { EventEmitter } from 'events'; | |
| import * as fs from 'fs'; | |
| import { memoize } from 'lodash'; | |
| import { bus } from './bus'; | |
| import { MemoryInitializer } from './memory'; | |
| const log = debug('pleadable:web_ipc'); | |
| export default {}; | |
| /** Options for showOpenDialog (web: uses browser-fs-access) */ | |
| interface OpenDialogOptions { | |
| properties?: Array<'openFile' | 'openDirectory' | 'multiSelections' | 'showHiddenFiles'>; | |
| } | |
| export const ipcRenderer = new (class extends EventEmitter { | |
| send(channel: string, ...args: unknown[]): void { | |
| log('ipcRenderer.send channel: %s %o', channel, args); | |
| ipcMain.emit(channel, {}, ...args); | |
| } | |
| async invoke(channel: string, ...args: unknown[]): Promise<unknown> { | |
| log('ipcRenderer.invoke channel: %s %o', channel, args); | |
| const output = await ipcMain.emitAsync(channel, ...args); | |
| if (channel === 'auth:getToken') { | |
| log('ipcRenderer.invoke auth:getToken result (token): %s', output ?? '(empty)'); | |
| } | |
| MemoryInitializer.persist(); // persist in background | |
| return output; | |
| } | |
| })(); | |
| export const contextBridge = { | |
| exposeInMainWorld: (key: string, api: unknown): void => { | |
| if (typeof window !== 'undefined') { | |
| (window as unknown as Record<string, unknown>)[key] = api; | |
| } | |
| if (typeof globalThis !== 'undefined') { | |
| (globalThis as unknown as Record<string, unknown>)[key] = api; | |
| } | |
| }, | |
| }; | |
| const BrowserWindow = class extends EventEmitter { | |
| private static instance: InstanceType<typeof BrowserWindow>; | |
| constructor() { | |
| super(); | |
| BrowserWindow.instance = this; | |
| } | |
| static getAllWindows(): InstanceType<typeof BrowserWindow>[] { | |
| return BrowserWindow.instance ? [BrowserWindow.instance] : []; | |
| } | |
| static fromWebContents(_webContents: unknown): InstanceType<typeof BrowserWindow> | null { | |
| return BrowserWindow.instance || null; | |
| } | |
| webContents = new (class { | |
| setWindowOpenHandler( | |
| handler: (details: { url: string }) => { action: 'allow' | 'deny' }, | |
| ): void { | |
| log('setWindowOpenHandler: %o', handler); | |
| } | |
| send(channel: string, ...args: unknown[]): void { | |
| log('webContents.send channel: %s %o', channel, args); | |
| // Emit with null as first argument for IPC event shape | |
| ipcRenderer.emit(channel, null, ...args); | |
| } | |
| })(); | |
| loadFile(filePath: string): void { | |
| log('BrowserWindow.loadFile: %s', filePath); | |
| } | |
| }; | |
| export { BrowserWindow }; | |
| export const shell = { | |
| openExternal: async (url: string): Promise<void> => { | |
| log('shell.openExternal: %s', url); | |
| }, | |
| }; | |
| export const nativeImage = {}; | |
| export const ipcMain = new (class extends EventEmitter { | |
| private handlers = new Map<string, (event: null, ...args: unknown[]) => Promise<unknown>>(); | |
| handle(channel: string, listener: (event: null, ...args: unknown[]) => Promise<unknown>): void { | |
| log('ipcMain.handle channel: %s', channel); | |
| this.handlers.set(channel, listener); | |
| } | |
| async emitAsync(channel: string, ...args: unknown[]): Promise<unknown> { | |
| const handler = this.handlers.get(channel); | |
| if (handler) { | |
| const output = await handler(null, ...args); | |
| MemoryInitializer.persist(); // persist in background | |
| return output; | |
| } | |
| return undefined; | |
| } | |
| })(); | |
| export const dialog = { | |
| showOpenDialog: async ( | |
| options?: OpenDialogOptions, | |
| ): Promise<{ canceled: boolean; filePaths: string[] }> => { | |
| log('dialog.showOpenDialog options: %o', options); | |
| try { | |
| const properties = options?.properties || []; | |
| if (properties.includes('openDirectory')) { | |
| const files = await directoryOpen({ recursive: true }); | |
| const fileList = Array.isArray(files) ? files : [files]; | |
| if (fileList.length === 0) { | |
| return { canceled: false, filePaths: ['/'] }; | |
| } | |
| const basePath = `/${fileList[0].webkitRelativePath.split(/[/\\]/)[0]}`; | |
| for (const file of fileList) { | |
| const targetPath = `/${file.webkitRelativePath.replace(/\\/g, '/')}`; | |
| await writeFileToFs(file, targetPath); | |
| } | |
| return { canceled: false, filePaths: [basePath] }; | |
| } else { | |
| const multiple = properties.includes('multiSelections'); | |
| const files = await fileOpen({ multiple }); | |
| const fileList = files == null ? [] : Array.isArray(files) ? files : [files]; | |
| const filePaths: string[] = []; | |
| for (const file of fileList) { | |
| const filePath = `/${file.name}`; | |
| await writeFileToFs(file, filePath); | |
| filePaths.push(filePath); | |
| } | |
| return { canceled: false, filePaths }; | |
| } | |
| } catch (error) { | |
| log('Dialog canceled or error: %o', error); | |
| return { canceled: true, filePaths: [] }; | |
| } | |
| }, | |
| }; | |
| /** | |
| * Write a File from browser-fs-access to the virtual filesystem | |
| */ | |
| async function writeFileToFs(file: File, targetPath: string): Promise<void> { | |
| const arrayBuffer = await file.arrayBuffer(); | |
| const uint8Array = new Uint8Array(arrayBuffer); | |
| const parentDir = targetPath.substring(0, targetPath.lastIndexOf('/')); | |
| if (parentDir) { | |
| fs.mkdirSync(parentDir, { recursive: true }); | |
| } | |
| fs.writeFileSync(targetPath, uint8Array); | |
| log('Synced file to workspace: %s', targetPath); | |
| } | |
| export const app = new (class extends EventEmitter { | |
| private _whenReady: () => Promise<void>; | |
| constructor() { | |
| super(); | |
| this._whenReady = memoize(async () => { | |
| await new Promise((resolve) => { | |
| bus.once('appReady', resolve); | |
| }); | |
| }); | |
| } | |
| whenReady = async () => { | |
| await this._whenReady(); | |
| }; | |
| getPath = (name: string) => { | |
| // Return paths for web app (posix-style) | |
| const paths: Record<string, string> = { | |
| userData: '/tmp/openwork-userdata', | |
| appData: '/tmp/openwork-appdata', | |
| temp: '/tmp', | |
| home: '/home', | |
| }; | |
| const path = paths[name] || '/tmp'; | |
| // Ensure the directory exists in the workspace | |
| if (!fs.existsSync(path)) { | |
| try { | |
| fs.mkdirSync(path, { recursive: true }); | |
| } catch (error) { | |
| log('Failed to create directory %s: %o', path, error); | |
| } | |
| } | |
| return path; | |
| }; | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment