| 1 | import { BrowserWindow, ipcMain } from 'electron' |
| 2 | import fs from 'fs' |
| 3 | import http from 'http' |
| 4 | import path from 'path' |
| 5 | import type { AddressInfo } from 'net' |
| 6 | import type { IpcContext } from '../ipc/context' |
| 7 | import { ensureSessionRuntimeCompatible } from './runtime-assets' |
| 8 | import { ensureIndexPresentBackgroundStyle } from './index-transition' |
| 9 | |
| 10 | const CONTENT_TYPES: Record<string, string> = { |
| 11 | '.html': 'text/html; charset=utf-8', |
| 12 | '.css': 'text/css; charset=utf-8', |
| 13 | '.js': 'text/javascript; charset=utf-8', |
| 14 | '.json': 'application/json; charset=utf-8', |
| 15 | '.png': 'image/png', |
| 16 | '.jpg': 'image/jpeg', |
| 17 | '.jpeg': 'image/jpeg', |
| 18 | '.webp': 'image/webp', |
| 19 | '.gif': 'image/gif', |
| 20 | '.svg': 'image/svg+xml', |
| 21 | '.mp4': 'video/mp4', |
| 22 | '.webm': 'video/webm', |
| 23 | '.ogg': 'video/ogg', |
| 24 | '.woff2': 'font/woff2' |
| 25 | } |
| 26 | const PREFERRED_PRESENTATION_PORT_START = 9090 |
| 27 | const PREFERRED_PRESENTATION_PORT_COUNT = 10 |
| 28 | |
| 29 | const parseStartIndex = (value: unknown): number => { |
| 30 | const raw = typeof value === 'number' ? value : Number(value) |
| 31 | return Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) : 0 |
| 32 | } |
| 33 | |
| 34 | const resolveRequestPath = (projectDir: string, requestUrl: string | undefined): string | null => { |
| 35 | const pathname = new URL(requestUrl || '/', 'http://127.0.0.1').pathname |
| 36 | const decodedPath = decodeURIComponent(pathname) |
| 37 | const relativePath = decodedPath === '/' ? 'index.html' : decodedPath.replace(/^\/+/, '') |
| 38 | const resolvedPath = path.resolve(projectDir, relativePath) |
| 39 | const projectRoot = path.resolve(projectDir) |
| 40 | const relativeToRoot = path.relative(projectRoot, resolvedPath) |
| 41 | if (relativeToRoot.startsWith('..') || path.isAbsolute(relativeToRoot)) return null |
| 42 | return resolvedPath |
| 43 | } |
| 44 | |
| 45 | const createPresentationServer = async (projectDir: string): Promise<http.Server> => { |
| 46 | const server = http.createServer((request, response) => { |
| 47 | const filePath = resolveRequestPath(projectDir, request.url) |
| 48 | if (!filePath) { |
| 49 | response.writeHead(403) |
| 50 | response.end('Forbidden') |
| 51 | return |
| 52 | } |
| 53 | |
| 54 | fs.promises |
| 55 | .stat(filePath) |
| 56 | .then((stat) => { |
| 57 | if (!stat.isFile()) { |
| 58 | response.writeHead(404) |
| 59 | response.end('Not found') |
| 60 | return |
| 61 | } |
| 62 | response.writeHead(200, { |
| 63 | 'Content-Type': CONTENT_TYPES[path.extname(filePath).toLowerCase()] || 'application/octet-stream', |
| 64 | 'Content-Length': stat.size, |
| 65 | 'Cache-Control': 'no-store' |
| 66 | }) |
| 67 | if (request.method === 'HEAD') { |
| 68 | response.end() |
| 69 | return |
| 70 | } |
| 71 | fs.createReadStream(filePath).pipe(response) |
| 72 | }) |
| 73 | .catch(() => { |
| 74 | response.writeHead(404) |
| 75 | response.end('Not found') |
| 76 | }) |
| 77 | }) |
| 78 | |
| 79 | const listenOnPort = (port: number): Promise<void> => |
| 80 | new Promise((resolve, reject) => { |
| 81 | const onError = (error: NodeJS.ErrnoException): void => { |
| 82 | server.off('listening', onListening) |
| 83 | reject(error) |
| 84 | } |
| 85 | const onListening = (): void => { |
| 86 | server.off('error', onError) |
| 87 | resolve() |
| 88 | } |
| 89 | server.once('error', onError) |
| 90 | server.once('listening', onListening) |
| 91 | server.listen(port, '127.0.0.1') |
| 92 | }) |
| 93 | |
| 94 | for (let offset = 0; offset < PREFERRED_PRESENTATION_PORT_COUNT; offset += 1) { |
| 95 | try { |
| 96 | await listenOnPort(PREFERRED_PRESENTATION_PORT_START + offset) |
| 97 | return server |
| 98 | } catch (error) { |
| 99 | const code = (error as NodeJS.ErrnoException).code |
| 100 | if (code !== 'EADDRINUSE' && code !== 'EACCES') throw error |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | await listenOnPort(0) |
| 105 | |
| 106 | return server |
| 107 | } |
| 108 | |
| 109 | export function registerPresentationHandlers(ctx: IpcContext): void { |
| 110 | ipcMain.handle('presentation:open', async (_event, payload: unknown) => { |
| 111 | if (!payload || typeof payload !== 'object') return { success: false } |
| 112 | const record = payload as { sessionId?: unknown; startIndex?: unknown } |
| 113 | const sessionId = typeof record.sessionId === 'string' ? record.sessionId : '' |
| 114 | const startIndex = parseStartIndex(record.startIndex) |
| 115 | if (!sessionId) return { success: false } |
| 116 | |
| 117 | const { pages, projectDir } = await ctx.resolveSessionPageFiles(sessionId) |
| 118 | await ensureSessionRuntimeCompatible(ctx, projectDir) |
| 119 | |
| 120 | const indexPath = path.join(projectDir, 'index.html') |
| 121 | await fs.promises.access(indexPath, fs.constants.R_OK) |
| 122 | const indexHtml = await fs.promises.readFile(indexPath, 'utf-8') |
| 123 | const patchedIndexHtml = ensureIndexPresentBackgroundStyle(indexHtml) |
| 124 | if (patchedIndexHtml !== indexHtml) { |
| 125 | await fs.promises.writeFile(indexPath, patchedIndexHtml, 'utf-8') |
| 126 | } |
| 127 | |
| 128 | const server = await createPresentationServer(projectDir) |
| 129 | const address = server.address() as AddressInfo |
| 130 | const startPage = pages[Math.min(startIndex, pages.length - 1)] ?? pages[0] |
| 131 | const url = new URL(`http://127.0.0.1:${address.port}/index.html`) |
| 132 | url.searchParams.set('present', '1') |
| 133 | if (startPage?.pageId) { |
| 134 | url.hash = encodeURIComponent(startPage.pageId) |
| 135 | } |
| 136 | |
| 137 | const win = new BrowserWindow({ |
| 138 | fullscreen: true, |
| 139 | backgroundColor: '#000000', |
| 140 | autoHideMenuBar: true, |
| 141 | show: false, |
| 142 | webPreferences: { |
| 143 | sandbox: true, |
| 144 | contextIsolation: true, |
| 145 | nodeIntegration: false, |
| 146 | webSecurity: true |
| 147 | } |
| 148 | }) |
| 149 | |
| 150 | win.webContents.on('before-input-event', (event, input) => { |
| 151 | if (input.type === 'keyDown' && (input.key === 'Escape' || input.code === 'Escape')) { |
| 152 | event.preventDefault() |
| 153 | if (!win.isDestroyed()) win.close() |
| 154 | } |
| 155 | }) |
| 156 | |
| 157 | win.on('closed', () => { |
| 158 | server.close() |
| 159 | }) |
| 160 | win.on('ready-to-show', () => { |
| 161 | win.show() |
| 162 | }) |
| 163 | try { |
| 164 | await win.loadURL(url.toString()) |
| 165 | } catch (error) { |
| 166 | server.close() |
| 167 | if (!win.isDestroyed()) win.close() |
| 168 | throw error |
| 169 | } |
| 170 | |
| 171 | return { success: true } |
| 172 | }) |
| 173 | |
| 174 | ipcMain.on('presentation:close', (event) => { |
| 175 | const win = BrowserWindow.fromWebContents(event.sender) |
| 176 | if (win) win.close() |
| 177 | }) |
| 178 | } |
| 179 |