返回 oh-my-ppt
presentation-handlers.ts
根目录 / src / main / session / presentation-handlers.ts
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
179 lines TYPESCRIPT