@pmate/cli 0.30.1 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +655 -497
- package/dist/templates/electron/main.cjs +61 -0
- package/dist/templates/electron/resources.cjs +32 -0
- package/package.json +1 -1
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
const { app, BrowserWindow, session, shell, Menu } = require('electron')
|
|
2
|
+
const path = require('node:path')
|
|
3
|
+
const { createResourceHandler } = require('./resources.cjs')
|
|
4
|
+
const config = require('./desktop-config.json')
|
|
5
|
+
const manifest = require('./resource-manifest.json')
|
|
6
|
+
app.setName(config.productName)
|
|
7
|
+
// Stable across installer versions; separate from every other packaged PMate app.
|
|
8
|
+
app.setPath('userData', path.join(app.getPath('appData'), config.bundleId))
|
|
9
|
+
const allowedNavigation = new Set([config.origin, ...config.authOrigins])
|
|
10
|
+
let mainWindow
|
|
11
|
+
function isAllowed(raw) { try { return allowedNavigation.has(new URL(raw).origin) } catch { return false } }
|
|
12
|
+
function external(raw) {
|
|
13
|
+
try { if (['https:', 'http:', 'mailto:'].includes(new URL(raw).protocol)) void shell.openExternal(raw) } catch { /* Invalid links are not executable targets. */ }
|
|
14
|
+
}
|
|
15
|
+
function restrict(window) {
|
|
16
|
+
window.webContents.on('will-navigate', (event, url) => { if (!isAllowed(url)) { event.preventDefault(); external(url) } })
|
|
17
|
+
window.webContents.on('will-redirect', (event, url) => { if (!isAllowed(url)) event.preventDefault() })
|
|
18
|
+
window.webContents.on('will-attach-webview', event => event.preventDefault())
|
|
19
|
+
window.webContents.setWindowOpenHandler(({ url }) => {
|
|
20
|
+
if (!isAllowed(url)) { external(url); return { action: 'deny' } }
|
|
21
|
+
return { action: 'allow', overrideBrowserWindowOptions: { autoHideMenuBar: true, webPreferences: { session: window.webContents.session, nodeIntegration: false, contextIsolation: true, sandbox: true, webSecurity: true } } }
|
|
22
|
+
})
|
|
23
|
+
window.webContents.on('did-create-window', child => restrict(child))
|
|
24
|
+
}
|
|
25
|
+
function createWindow() {
|
|
26
|
+
mainWindow = new BrowserWindow({ width: 1180, height: 820, minWidth: 380, minHeight: 540, title: config.productName, backgroundColor: '#f6f8fb', webPreferences: { partition: 'persist:desktop', nodeIntegration: false, contextIsolation: true, sandbox: true, webSecurity: true } })
|
|
27
|
+
restrict(mainWindow)
|
|
28
|
+
mainWindow.webContents.on('did-fail-load', (_event, code, _description, url, mainFrame) => {
|
|
29
|
+
if (mainFrame && code !== -3 && url !== `${config.origin}/_desktop/offline`) void mainWindow.loadURL(`${config.origin}/_desktop/offline`)
|
|
30
|
+
})
|
|
31
|
+
void mainWindow.loadURL(config.origin)
|
|
32
|
+
mainWindow.on('closed', () => { mainWindow = null })
|
|
33
|
+
}
|
|
34
|
+
if (!app.requestSingleInstanceLock()) app.quit()
|
|
35
|
+
else {
|
|
36
|
+
app.on('second-instance', () => { if (mainWindow) { if (mainWindow.isMinimized()) mainWindow.restore(); mainWindow.focus() } })
|
|
37
|
+
app.whenReady().then(() => {
|
|
38
|
+
const desktopSession = session.fromPartition('persist:desktop')
|
|
39
|
+
const resources = createResourceHandler(path.join(__dirname, 'renderer'), config.origin, manifest)
|
|
40
|
+
desktopSession.protocol.handle('https', request => {
|
|
41
|
+
if (request.url === `${config.origin}/_desktop/offline`) {
|
|
42
|
+
const escape = text => text.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c])
|
|
43
|
+
return new Response(`<!doctype html><html><head><meta charset="utf-8"><title>${escape(config.productName)}</title></head><body style="font:18px system-ui;max-width:560px;margin:15vh auto;padding:24px"><h1>${escape(config.productName)}</h1><p>Connection unavailable. Your app is installed locally; sign-in and messages need an internet connection.</p><form action="/"><button style="padding:12px 24px">Try again</button></form></body></html>`, { headers: { 'content-type': 'text/html; charset=utf-8', 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'" } })
|
|
44
|
+
}
|
|
45
|
+
return resources(request) ?? desktopSession.fetch(request, { bypassCustomProtocolHandlers: true })
|
|
46
|
+
})
|
|
47
|
+
desktopSession.setPermissionRequestHandler((contents, permission, callback, details) => {
|
|
48
|
+
let trusted = false
|
|
49
|
+
try { trusted = !!contents && new URL(contents.getURL()).origin === config.origin } catch {}
|
|
50
|
+
callback(Boolean(trusted && permission === 'media' && details.mediaTypes?.length && details.mediaTypes.every(type => type === 'audio')))
|
|
51
|
+
})
|
|
52
|
+
desktopSession.setPermissionCheckHandler((_contents, permission, origin, details) => origin === config.origin && permission === 'media' && details.mediaType === 'audio')
|
|
53
|
+
Menu.setApplicationMenu(Menu.buildFromTemplate([
|
|
54
|
+
{ role: 'appMenu' }, { role: 'editMenu' }, { role: 'viewMenu' }, { role: 'windowMenu' },
|
|
55
|
+
{ label: config.productName, submenu: [{ label: 'Back to app', click: () => { if (!mainWindow) createWindow(); else void mainWindow.loadURL(config.origin) } }] },
|
|
56
|
+
]))
|
|
57
|
+
createWindow()
|
|
58
|
+
app.on('activate', () => { if (!mainWindow) createWindow() })
|
|
59
|
+
}).catch(error => { console.error('Desktop startup failed:', error.message); app.exit(1) })
|
|
60
|
+
app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit() })
|
|
61
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const fs = require('node:fs')
|
|
2
|
+
const path = require('node:path')
|
|
3
|
+
const crypto = require('node:crypto')
|
|
4
|
+
const TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.ico': 'image/x-icon', '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.wasm': 'application/wasm', '.mp3': 'audio/mpeg', '.mp4': 'video/mp4' }
|
|
5
|
+
function createResourceHandler(root, origin, manifest) {
|
|
6
|
+
// Reject a stale or altered renderer before displaying it.
|
|
7
|
+
for (const [file, digest] of Object.entries(manifest)) {
|
|
8
|
+
if (file.split(/[\\/]/).includes('..') || path.isAbsolute(file)) throw new Error('Invalid resource manifest')
|
|
9
|
+
if (crypto.createHash('sha256').update(fs.readFileSync(path.join(root, file))).digest('hex') !== digest) throw new Error(`Resource integrity check failed: ${file}`)
|
|
10
|
+
}
|
|
11
|
+
return (request) => {
|
|
12
|
+
const url = new URL(request.url)
|
|
13
|
+
if (url.origin !== origin) return null
|
|
14
|
+
if (!['GET', 'HEAD'].includes(request.method)) return new Response('Method not allowed', { status: 405 })
|
|
15
|
+
let name
|
|
16
|
+
try { name = decodeURIComponent(url.pathname).slice(1) } catch { return new Response('Invalid path', { status: 400 }) }
|
|
17
|
+
if (name.includes('\\') || name.includes('\0') || name.split('/').some(p => p === '..' || p === '.')) return new Response('Invalid path', { status: 400 })
|
|
18
|
+
// Exact files come from the build manifest. Only extensionless document routes get the SPA shell.
|
|
19
|
+
if (!Object.hasOwn(manifest, name)) {
|
|
20
|
+
if (!path.extname(name) && request.headers.get('sec-fetch-dest') === 'document') name = 'index.html'
|
|
21
|
+
else if (!name) name = 'index.html'
|
|
22
|
+
else return new Response('Resource not found', { status: 404 })
|
|
23
|
+
}
|
|
24
|
+
const bytes = fs.readFileSync(path.join(root, name))
|
|
25
|
+
return new Response(request.method === 'HEAD' ? null : bytes, { headers: {
|
|
26
|
+
'content-type': TYPES[path.extname(name)] || 'application/octet-stream',
|
|
27
|
+
'content-length': String(bytes.length), 'x-content-type-options': 'nosniff',
|
|
28
|
+
'content-security-policy': "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src https: wss:; media-src 'self' blob: https:; worker-src 'self' blob:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'",
|
|
29
|
+
} })
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
module.exports = { createResourceHandler }
|