@pmate/cli 0.35.0 → 0.35.2
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/dist/index.js +242 -229
- package/dist/templates/electron/call-preload.cjs +15 -0
- package/dist/templates/electron/calls.cjs +100 -0
- package/dist/templates/electron/main.cjs +50 -3
- package/dist/templates/electron/media.cjs +8 -14
- package/dist/templates/electron/notifications.cjs +71 -0
- package/dist/templates/electron/preload.cjs +35 -0
- package/dist/templates/electron/reach-renderer.cjs +39 -0
- package/dist/templates/electron/reach.cjs +62 -0
- package/dist/templates/electron/share-picker-preload.cjs +3 -0
- package/dist/templates/electron/share-picker.cjs +22 -0
- package/dist/templates/electron/share-picker.html +3 -0
- package/dist/templates/electron/share-picker.js +20 -0
- package/package.json +17 -10
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const { contextBridge, ipcRenderer } = require('electron')
|
|
2
|
+
contextBridge.exposeInMainWorld('pmateIncomingCall', Object.freeze({
|
|
3
|
+
getSession: () => ipcRenderer.invoke('pmate:call:session'),
|
|
4
|
+
updateState: state => ipcRenderer.invoke('pmate:call:state', state),
|
|
5
|
+
onCloseRequested: handler => {
|
|
6
|
+
const listener = () => handler()
|
|
7
|
+
ipcRenderer.on('pmate:call:closeRequested', listener)
|
|
8
|
+
return () => ipcRenderer.removeListener('pmate:call:closeRequested', listener)
|
|
9
|
+
},
|
|
10
|
+
onCaller: handler => {
|
|
11
|
+
const listener = (_event, caller) => handler(caller)
|
|
12
|
+
ipcRenderer.on('pmate:call:caller', listener)
|
|
13
|
+
return () => ipcRenderer.removeListener('pmate:call:caller', listener)
|
|
14
|
+
},
|
|
15
|
+
}))
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
const path = require('node:path')
|
|
2
|
+
function createDesktopCalls({ appId, instanceId, origin, BrowserWindow, ipcMain, dialog, createClient = async options => {
|
|
3
|
+
const { CallClient } = await import('@pmate/chat-sdk/runtime')
|
|
4
|
+
return new CallClient(options)
|
|
5
|
+
}, onError = () => {} }) {
|
|
6
|
+
let client, identity, generation = 0, current, unsubscribe
|
|
7
|
+
const people = new Map()
|
|
8
|
+
const trusted = event => {
|
|
9
|
+
try { return current && event.sender === current.window.webContents && event.senderFrame === event.sender.mainFrame
|
|
10
|
+
&& new URL(event.senderFrame.url).origin === origin && new URL(event.senderFrame.url).pathname === '/_desktop/call' } catch { return false }
|
|
11
|
+
}
|
|
12
|
+
function destroy() {
|
|
13
|
+
const previous = current
|
|
14
|
+
current = undefined
|
|
15
|
+
if (previous) { clearTimeout(previous.timer); previous.resolve('suppressed'); previous.window.destroy() }
|
|
16
|
+
}
|
|
17
|
+
function present(callId) {
|
|
18
|
+
if (!client || !identity || client.snapshot().callId !== callId || client.snapshot().phase !== 'incoming') return Promise.resolve('suppressed')
|
|
19
|
+
if (current?.callId === callId) return current.ready
|
|
20
|
+
destroy()
|
|
21
|
+
const window = new BrowserWindow({ width: 420, height: 680, minWidth: 380, minHeight: 540, title: '来电', show: false,
|
|
22
|
+
backgroundColor: '#ffffff', webPreferences: { preload: path.join(__dirname, 'call-preload.cjs'), partition: 'persist:desktop',
|
|
23
|
+
nodeIntegration: false, contextIsolation: true, sandbox: true, webSecurity: true, autoplayPolicy: 'no-user-gesture-required' } })
|
|
24
|
+
let resolve, reject
|
|
25
|
+
const ready = new Promise((yes, no) => { resolve = yes; reject = no })
|
|
26
|
+
const entry = current = { callId, window, ready, resolve, reject, phase: 'incoming', confirming: false }
|
|
27
|
+
entry.timer = setTimeout(() => { if (current === entry) { reject(new Error('Incoming call window timeout')); destroy() } }, 1500)
|
|
28
|
+
window.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
|
|
29
|
+
for (const event of ['will-navigate', 'will-redirect', 'will-attach-webview']) window.webContents.on(event, e => e.preventDefault())
|
|
30
|
+
window.webContents.on('did-fail-load', (_event, code) => { if (code !== -3 && current === entry) { reject(new Error('Incoming call window failed')); destroy() } })
|
|
31
|
+
window.on('close', event => {
|
|
32
|
+
if (current !== entry) return
|
|
33
|
+
event.preventDefault()
|
|
34
|
+
if (entry.phase === 'incoming' || entry.phase === 'ended' || entry.phase === 'idle') {
|
|
35
|
+
if (entry.phase === 'incoming') client?.hangup()
|
|
36
|
+
destroy(); return
|
|
37
|
+
}
|
|
38
|
+
if (entry.confirming) return
|
|
39
|
+
entry.confirming = true
|
|
40
|
+
void dialog.showMessageBox(window, { type: 'question', message: '结束通话并关闭窗口?', buttons: ['继续通话', '结束通话'], defaultId: 0, cancelId: 0 })
|
|
41
|
+
.then(result => { if (current === entry && result.response === 1) { window.webContents.send('pmate:call:closeRequested'); setTimeout(() => { if (current === entry) destroy() }, 2000) } })
|
|
42
|
+
.catch(onError).finally(() => { entry.confirming = false })
|
|
43
|
+
})
|
|
44
|
+
void window.loadURL(`${origin}/_desktop/call`).catch(error => { if (current === entry) { reject(error); destroy() } })
|
|
45
|
+
return ready
|
|
46
|
+
}
|
|
47
|
+
ipcMain.handle('pmate:call:session', event => {
|
|
48
|
+
if (!trusted(event) || !identity || !client) throw new Error('Untrusted call session request')
|
|
49
|
+
const callerId = client.snapshot().peerId, caller = people.get(callerId) ?? { id: callerId, name: '联系人' }
|
|
50
|
+
return { callId: current.callId, profileId: identity.profileId, token: identity.token, clientInstanceId: instanceId, caller, url: 'wss://calls.pmate.chat/call' }
|
|
51
|
+
})
|
|
52
|
+
ipcMain.handle('pmate:call:state', (event, state) => {
|
|
53
|
+
if (!trusted(event) || !state || state.callId !== current.callId || !['idle', 'requesting', 'ringing', 'incoming', 'connecting', 'connected', 'ended'].includes(state.phase)) throw new Error('Invalid call window state')
|
|
54
|
+
const entry = current
|
|
55
|
+
entry.phase = state.phase
|
|
56
|
+
if (['incoming', 'connecting', 'connected'].includes(state.phase)) {
|
|
57
|
+
clearTimeout(entry.timer); entry.window.show(); entry.window.focus(); entry.resolve('handled')
|
|
58
|
+
} else if (state.phase === 'ended') destroy()
|
|
59
|
+
})
|
|
60
|
+
return {
|
|
61
|
+
getWindow: () => current?.window,
|
|
62
|
+
setPeople(values) {
|
|
63
|
+
if (!Array.isArray(values) || values.length > 2000 || values.some(value => !value || typeof value.id !== 'string' || value.id.length > 128
|
|
64
|
+
|| typeof value.name !== 'string' || value.name.length > 256 || (value.avatar !== undefined && (typeof value.avatar !== 'string' || value.avatar.length > 2048)))) throw new Error('Invalid call directory')
|
|
65
|
+
people.clear(); for (const value of values) people.set(value.id, { id: value.id, name: value.name, avatar: value.avatar })
|
|
66
|
+
if (current && client) { const caller = people.get(client.snapshot().peerId); if (caller) current.window.webContents.send('pmate:call:caller', caller) }
|
|
67
|
+
},
|
|
68
|
+
async configure(value) {
|
|
69
|
+
if (identity?.profileId === value.profileId && identity?.token === value.token && client) return
|
|
70
|
+
generation++; const ownGeneration = generation
|
|
71
|
+
unsubscribe?.(); client?.close(); client = undefined; identity = undefined; destroy(); people.clear()
|
|
72
|
+
const next = await createClient({ profileId: value.profileId, token: value.token, clientInstanceId: instanceId })
|
|
73
|
+
if (generation !== ownGeneration) { next.close(); return }
|
|
74
|
+
identity = { profileId: value.profileId, token: value.token }; client = next
|
|
75
|
+
unsubscribe = next.subscribe(state => {
|
|
76
|
+
if (generation !== ownGeneration) return
|
|
77
|
+
if (state.phase === 'incoming') void present(state.callId).catch(onError)
|
|
78
|
+
else if (state.phase === 'ended' && state.reason !== 'answered_elsewhere' && current?.callId === state.callId) destroy()
|
|
79
|
+
})
|
|
80
|
+
next.connect()
|
|
81
|
+
},
|
|
82
|
+
async handleReach(event) {
|
|
83
|
+
if (event.appId !== appId || !client) throw new Error('Call handler unavailable')
|
|
84
|
+
const ownGeneration = generation, result = await client.handleReach(event)
|
|
85
|
+
if (generation !== ownGeneration) throw new Error('Call identity changed')
|
|
86
|
+
if (event.eventType === 'call.incoming' && result === 'handled') return present(event.payload.callId)
|
|
87
|
+
if (event.eventType === 'call.ended' && result === 'handled' && current?.callId === event.payload.callId) destroy()
|
|
88
|
+
return result
|
|
89
|
+
},
|
|
90
|
+
async incoming(callId) {
|
|
91
|
+
if (!client || typeof callId !== 'string' || !callId || callId.length > 128) throw new Error('Invalid incoming call')
|
|
92
|
+
if (current?.callId === callId && ['incoming', 'connecting', 'connected'].includes(current.phase)) { current.window.show(); current.window.focus(); return current.ready }
|
|
93
|
+
const ownGeneration = generation, result = await client.attachAndWait(callId)
|
|
94
|
+
if (generation !== ownGeneration) throw new Error('Call identity changed')
|
|
95
|
+
return result === 'handled' ? present(callId) : result
|
|
96
|
+
},
|
|
97
|
+
stop() { generation++; unsubscribe?.(); unsubscribe = undefined; client?.close(); client = undefined; identity = undefined; destroy(); people.clear() },
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
module.exports = { createDesktopCalls }
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
const { app, BrowserWindow, session, shell, Menu, desktopCapturer } = require('electron')
|
|
1
|
+
const { app, BrowserWindow, session, shell, Menu, desktopCapturer, ipcMain, Notification, dialog } = require('electron')
|
|
2
2
|
const path = require('node:path')
|
|
3
3
|
const { createResourceHandler } = require('./resources.cjs')
|
|
4
4
|
const { installMediaPermissions } = require('./media.cjs')
|
|
5
|
+
const { createDesktopNotifications } = require('./notifications.cjs')
|
|
6
|
+
const { desktopInstanceId, createDesktopReach } = require('./reach.cjs')
|
|
7
|
+
const { createRendererReach } = require('./reach-renderer.cjs')
|
|
8
|
+
const { createDesktopCalls } = require('./calls.cjs')
|
|
5
9
|
const config = require('./desktop-config.json')
|
|
6
10
|
const manifest = require('./resource-manifest.json')
|
|
7
11
|
app.setName(config.productName)
|
|
@@ -9,6 +13,7 @@ app.setName(config.productName)
|
|
|
9
13
|
app.setPath('userData', path.join(app.getPath('appData'), config.bundleId))
|
|
10
14
|
const allowedNavigation = new Set([config.origin, ...config.authOrigins])
|
|
11
15
|
let mainWindow
|
|
16
|
+
let desktopNotifications, desktopReach, desktopCalls
|
|
12
17
|
function isAllowed(raw) { try { return allowedNavigation.has(new URL(raw).origin) } catch { return false } }
|
|
13
18
|
function external(raw) {
|
|
14
19
|
try { if (['https:', 'http:', 'mailto:'].includes(new URL(raw).protocol)) void shell.openExternal(raw) } catch { /* Invalid links are not executable targets. */ }
|
|
@@ -24,8 +29,9 @@ function restrict(window) {
|
|
|
24
29
|
window.webContents.on('did-create-window', child => restrict(child))
|
|
25
30
|
}
|
|
26
31
|
function createWindow() {
|
|
27
|
-
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 } })
|
|
32
|
+
mainWindow = new BrowserWindow({ width: 1180, height: 820, minWidth: 380, minHeight: 540, title: config.productName, backgroundColor: '#f6f8fb', webPreferences: { preload: path.join(__dirname, 'preload.cjs'), partition: 'persist:desktop', nodeIntegration: false, contextIsolation: true, sandbox: true, webSecurity: true } })
|
|
28
33
|
restrict(mainWindow)
|
|
34
|
+
mainWindow.webContents.on('did-start-navigation', (_event, _url, _inPlace, mainFrame) => { if (mainFrame) desktopNotifications?.clearContext() })
|
|
29
35
|
mainWindow.webContents.on('did-fail-load', (_event, code, _description, url, mainFrame) => {
|
|
30
36
|
if (mainFrame && code !== -3 && url !== `${config.origin}/_desktop/offline`) void mainWindow.loadURL(`${config.origin}/_desktop/offline`)
|
|
31
37
|
})
|
|
@@ -45,11 +51,52 @@ else {
|
|
|
45
51
|
}
|
|
46
52
|
return resources(request) ?? desktopSession.fetch(request, { bypassCustomProtocolHandlers: true })
|
|
47
53
|
})
|
|
48
|
-
installMediaPermissions(desktopSession, { origin: config.origin, getWindow: () => mainWindow, desktopCapturer,
|
|
54
|
+
installMediaPermissions(desktopSession, { origin: config.origin, getWindow: () => mainWindow, getWindows: () => [mainWindow, desktopCalls?.getWindow()], desktopCapturer, BrowserWindow, ipcMain })
|
|
55
|
+
ipcMain.handle('pmate:screen-settings', event => {
|
|
56
|
+
if (!mainWindow || event.sender !== mainWindow.webContents || event.senderFrame !== mainWindow.webContents.mainFrame || new URL(mainWindow.webContents.getURL()).origin !== config.origin) return false
|
|
57
|
+
if (process.platform === 'darwin') { void shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture'); return true }
|
|
58
|
+
return false
|
|
59
|
+
})
|
|
49
60
|
Menu.setApplicationMenu(Menu.buildFromTemplate([
|
|
50
61
|
{ role: 'appMenu' }, { role: 'editMenu' }, { role: 'viewMenu' }, { role: 'windowMenu' },
|
|
51
62
|
{ label: config.productName, submenu: [{ label: 'Back to app', click: () => { if (!mainWindow) createWindow(); else void mainWindow.loadURL(config.origin) } }] },
|
|
52
63
|
]))
|
|
64
|
+
desktopNotifications = createDesktopNotifications({ ipcMain, Notification, origin: config.origin, appId: config.app, getWindow: () => mainWindow, showWindow: () => {
|
|
65
|
+
if (!mainWindow) createWindow()
|
|
66
|
+
if (mainWindow.isMinimized()) mainWindow.restore()
|
|
67
|
+
mainWindow.show(); mainWindow.focus()
|
|
68
|
+
return mainWindow
|
|
69
|
+
} })
|
|
70
|
+
const instanceId = desktopInstanceId(app.getPath('userData'))
|
|
71
|
+
const rendererReach = createRendererReach({ ipcMain, getWindow: () => mainWindow, trusted: event => desktopNotifications.trusted(event) })
|
|
72
|
+
if (config.app === 'pmate:hello') desktopCalls = createDesktopCalls({ appId: config.app, instanceId, origin: config.origin, BrowserWindow, ipcMain, dialog })
|
|
73
|
+
desktopReach = createDesktopReach({ appId: config.app, instanceId, present: input => desktopNotifications.display(input),
|
|
74
|
+
configureCalls: identity => { rendererReach.reset(); return desktopCalls?.configure(identity) }, stopCalls: () => { rendererReach.reset(); desktopCalls?.stop() },
|
|
75
|
+
incomingCall: callId => { if (!desktopCalls) throw new Error('Call handler unavailable'); return desktopCalls.incoming(callId) },
|
|
76
|
+
isViewingThread: threadId => desktopNotifications.isViewingThread(threadId),
|
|
77
|
+
onReach: event => ['call.incoming', 'call.ended'].includes(event.eventType) && desktopCalls ? desktopCalls.handleReach(event) : rendererReach.deliver(event),
|
|
78
|
+
})
|
|
79
|
+
ipcMain.handle('pmate:call:incoming', (event, callId) => {
|
|
80
|
+
if (!desktopNotifications.trusted(event) || !desktopCalls) throw new Error('Untrusted call sender')
|
|
81
|
+
return desktopCalls.incoming(callId)
|
|
82
|
+
})
|
|
83
|
+
ipcMain.handle('pmate:call:reach', (event, value) => {
|
|
84
|
+
if (!desktopNotifications.trusted(event) || !desktopCalls) throw new Error('Untrusted call sender')
|
|
85
|
+
return desktopCalls.handleReach(value)
|
|
86
|
+
})
|
|
87
|
+
ipcMain.handle('pmate:call:people', (event, people) => {
|
|
88
|
+
if (!desktopNotifications.trusted(event) || !desktopCalls) throw new Error('Untrusted call sender')
|
|
89
|
+
desktopCalls.setPeople(people)
|
|
90
|
+
})
|
|
91
|
+
ipcMain.handle('pmate:reach:configure', (event, identity) => {
|
|
92
|
+
if (!desktopNotifications.trusted(event)) throw new Error('Untrusted reach sender')
|
|
93
|
+
return desktopReach.configure(identity)
|
|
94
|
+
})
|
|
95
|
+
ipcMain.handle('pmate:reach:stop', event => {
|
|
96
|
+
if (!desktopNotifications.trusted(event)) throw new Error('Untrusted reach sender')
|
|
97
|
+
desktopReach.stop(); desktopNotifications.reset()
|
|
98
|
+
})
|
|
99
|
+
app.on('before-quit', () => { desktopReach.stop(); desktopNotifications.reset() })
|
|
53
100
|
createWindow()
|
|
54
101
|
app.on('activate', () => { if (!mainWindow) createWindow() })
|
|
55
102
|
}).catch(error => { console.error('Desktop startup failed:', error.message); app.exit(1) })
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
const { chooseScreen } = require('./share-picker.cjs')
|
|
2
|
+
function installMediaPermissions(desktopSession, { origin, getWindow, getWindows = () => [getWindow()], desktopCapturer, BrowserWindow, ipcMain }) {
|
|
2
3
|
const sameOrigin = raw => { try { return new URL(raw).origin === origin } catch { return false } }
|
|
3
4
|
const trusted = contents => {
|
|
4
|
-
|
|
5
|
-
return !!window && !window.isDestroyed() && contents === window.webContents && sameOrigin(contents.getURL())
|
|
5
|
+
return getWindows().some(window => !!window && !window.isDestroyed() && contents === window.webContents && sameOrigin(contents.getURL()))
|
|
6
6
|
}
|
|
7
7
|
desktopSession.setPermissionRequestHandler((contents, permission, callback, details) => {
|
|
8
8
|
const allowed = trusted(contents) && details.isMainFrame === true && sameOrigin(details.requestingUrl)
|
|
@@ -12,7 +12,7 @@ function installMediaPermissions(desktopSession, { origin, getWindow, desktopCap
|
|
|
12
12
|
return Boolean(trusted(contents) && sameOrigin(requestingOrigin) && details.isMainFrame === true && (permission === 'display-capture' || (permission === 'media' && details.mediaType === 'audio')))
|
|
13
13
|
})
|
|
14
14
|
desktopSession.setDisplayMediaRequestHandler(async (request, callback) => {
|
|
15
|
-
const window =
|
|
15
|
+
const window = getWindows().find(window => window && !window.isDestroyed() && request.frame === window.webContents.mainFrame)
|
|
16
16
|
const valid = () => window && !window.isDestroyed() && trusted(window.webContents) && request.frame && request.frame === window.webContents.mainFrame && sameOrigin(request.securityOrigin)
|
|
17
17
|
if (!valid() || !request.userGesture || !request.videoRequested || request.audioRequested) return callback(null)
|
|
18
18
|
let completed = false
|
|
@@ -22,17 +22,11 @@ function installMediaPermissions(desktopSession, { origin, getWindow, desktopCap
|
|
|
22
22
|
try { callback(source && valid() ? { video: source } : null) } catch { /* Request frame was closed or navigated. */ }
|
|
23
23
|
}
|
|
24
24
|
try {
|
|
25
|
-
const sources = await desktopCapturer.getSources({ types: ['screen', 'window'], thumbnailSize: { width:
|
|
25
|
+
const sources = await desktopCapturer.getSources({ types: ['screen', 'window'], thumbnailSize: { width: 320, height: 200 } })
|
|
26
26
|
if (!valid() || !sources.length) return finish(null)
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
{ label: 'Choose a screen or window to share', enabled: false },
|
|
30
|
-
{ type: 'separator' },
|
|
31
|
-
...sources.map(source => ({ label: source.name.replace(/&/g, '&&'), click: () => finish(source) })),
|
|
32
|
-
{ type: 'separator' }, { label: 'Cancel', click: () => finish(null) },
|
|
33
|
-
])
|
|
34
|
-
menu.popup({ window, callback: () => finish(null) })
|
|
27
|
+
const source = await chooseScreen({ BrowserWindow, ipcMain, parent: window, sources })
|
|
28
|
+
finish(source)
|
|
35
29
|
} catch { finish(null) }
|
|
36
|
-
}, { useSystemPicker:
|
|
30
|
+
}, { useSystemPicker: false })
|
|
37
31
|
}
|
|
38
32
|
module.exports = { installMediaPermissions }
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Main-process presentation boundary. Only the configured app's top frame may invoke it.
|
|
2
|
+
function createDesktopNotifications({ ipcMain, Notification, origin, appId, getWindow, showWindow, now = Date.now }) {
|
|
3
|
+
let activeThreadId, pendingOpen, epoch = 0
|
|
4
|
+
const shown = new Map()
|
|
5
|
+
const pending = new Map()
|
|
6
|
+
const trusted = event => {
|
|
7
|
+
try { return event.sender === getWindow()?.webContents && event.senderFrame === event.sender.mainFrame && new URL(event.senderFrame.url).origin === origin } catch { return false }
|
|
8
|
+
}
|
|
9
|
+
const context = (event, value) => {
|
|
10
|
+
if (!trusted(event)) throw new Error('Untrusted notification context')
|
|
11
|
+
if (value !== null && (typeof value !== 'string' || value.length > 512)) throw new Error('Invalid thread context')
|
|
12
|
+
activeThreadId = value ?? undefined
|
|
13
|
+
}
|
|
14
|
+
const present = async (event, input) => {
|
|
15
|
+
if (!trusted(event)) throw new Error('Untrusted notification sender')
|
|
16
|
+
return display(input)
|
|
17
|
+
}
|
|
18
|
+
const display = input => {
|
|
19
|
+
if (!input || input.appId !== appId || typeof input.notificationId !== 'string' || !input.notificationId || input.notificationId.length > 512
|
|
20
|
+
|| typeof input.title !== 'string' || input.title.length > 256 || typeof input.body !== 'string' || input.body.length > 4096
|
|
21
|
+
|| ['threadId', 'callId', 'meetingId'].some(key => input[key] !== undefined && (typeof input[key] !== 'string' || input[key].length > 512))) throw new Error('Invalid notification')
|
|
22
|
+
for (const [id, item] of shown) if (item.until <= now()) { item.notification.close(); shown.delete(id) }
|
|
23
|
+
if (input.expiresAtMs !== undefined && (!Number.isFinite(input.expiresAtMs) || input.expiresAtMs <= now())) return 'suppressed'
|
|
24
|
+
if (shown.has(input.notificationId)) return 'suppressed'
|
|
25
|
+
if (pending.has(input.notificationId)) return pending.get(input.notificationId)
|
|
26
|
+
const window = getWindow()
|
|
27
|
+
if (input.digestThreads !== undefined) {
|
|
28
|
+
if (!Array.isArray(input.digestThreads) || !input.digestThreads.length || input.digestThreads.length > 1000 || input.digestThreads.some(item => !item || typeof item.threadId !== 'string' || !item.threadId || item.threadId.length > 512 || typeof item.title !== 'string' || item.title.length > 512 || !Number.isSafeInteger(item.count) || item.count < 1)) throw new Error('Invalid notification digest')
|
|
29
|
+
const visible = input.digestThreads.filter(item => !(item.threadId === activeThreadId && window && window.isVisible() && window.isFocused() && !window.isMinimized()))
|
|
30
|
+
if (!visible.length) return 'suppressed'
|
|
31
|
+
input = { ...input, threadId: visible.length === 1 ? visible[0].threadId : undefined,
|
|
32
|
+
body: visible.length === input.digestThreads.length ? input.body : `${visible.length} 个会话中有 ${visible.reduce((sum, item) => sum + item.count, 0)} 条新消息` }
|
|
33
|
+
}
|
|
34
|
+
if (input.threadId && input.threadId === activeThreadId && window && window.isVisible() && window.isFocused() && !window.isMinimized()) return 'suppressed'
|
|
35
|
+
if (!Notification.isSupported()) throw new Error('Desktop notifications unavailable')
|
|
36
|
+
const notification = new Notification({ title: input.title, body: input.body, silent: false })
|
|
37
|
+
const until = Math.min(input.expiresAtMs ?? now() + 86400000, now() + 86400000)
|
|
38
|
+
const presentationEpoch = epoch
|
|
39
|
+
const operation = new Promise((resolve, reject) => {
|
|
40
|
+
let settled = false
|
|
41
|
+
const timeout = setTimeout(() => { if (!settled) { settled = true; notification.close(); reject(new Error('Notification display timeout')) } }, 1500)
|
|
42
|
+
notification.once('show', () => {
|
|
43
|
+
if (settled) return
|
|
44
|
+
settled = true; clearTimeout(timeout)
|
|
45
|
+
if (presentationEpoch !== epoch) { notification.close(); reject(new Error('Notification identity changed')); return }
|
|
46
|
+
if (shown.size >= 10000) { const first = shown.keys().next().value; shown.get(first).notification.close(); shown.delete(first) }
|
|
47
|
+
shown.set(input.notificationId, { notification, until }); resolve('displayed')
|
|
48
|
+
})
|
|
49
|
+
notification.once('failed', () => { if (!settled) { settled = true; clearTimeout(timeout); reject(new Error('Notification display failed')) } })
|
|
50
|
+
notification.on('click', () => {
|
|
51
|
+
const main = showWindow()
|
|
52
|
+
pendingOpen = { notificationId: input.notificationId, threadId: input.threadId, callId: input.callId, meetingId: input.meetingId }
|
|
53
|
+
main.webContents.send('pmate:notification:open', pendingOpen)
|
|
54
|
+
notification.close()
|
|
55
|
+
})
|
|
56
|
+
notification.show()
|
|
57
|
+
})
|
|
58
|
+
pending.set(input.notificationId, operation)
|
|
59
|
+
const cleanup = () => { if (pending.get(input.notificationId) === operation) pending.delete(input.notificationId) }
|
|
60
|
+
void operation.then(cleanup, cleanup)
|
|
61
|
+
return operation
|
|
62
|
+
}
|
|
63
|
+
ipcMain.handle('pmate:notification:consumeOpen', event => {
|
|
64
|
+
if (!trusted(event)) throw new Error('Untrusted notification click consumer')
|
|
65
|
+
const intent = pendingOpen; pendingOpen = undefined; return intent
|
|
66
|
+
})
|
|
67
|
+
ipcMain.handle('pmate:notification:context', context)
|
|
68
|
+
ipcMain.handle('pmate:notification:present', present)
|
|
69
|
+
return { display, trusted, isViewingThread(threadId) { const window = getWindow(); return !!threadId && threadId === activeThreadId && !!window && window.isVisible() && window.isFocused() && !window.isMinimized() }, clearContext() { activeThreadId = undefined }, reset() { epoch++; pendingOpen = undefined; activeThreadId = undefined; for (const item of shown.values()) item.notification.close(); shown.clear() } }
|
|
70
|
+
}
|
|
71
|
+
module.exports = { createDesktopNotifications }
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const { contextBridge, ipcRenderer } = require('electron')
|
|
2
|
+
contextBridge.exposeInMainWorld('pmateDesktop', Object.freeze({
|
|
3
|
+
openScreenSettings: () => ipcRenderer.invoke('pmate:screen-settings'),
|
|
4
|
+
reach: Object.freeze({
|
|
5
|
+
onEvent: handler => {
|
|
6
|
+
const listener = (_event, value) => {
|
|
7
|
+
Promise.resolve().then(() => handler(value.event)).then(status =>
|
|
8
|
+
ipcRenderer.invoke('pmate:reach:ack', { requestId: value.requestId, status }), () =>
|
|
9
|
+
ipcRenderer.invoke('pmate:reach:ack', { requestId: value.requestId, status: 'failed' })).catch(() => {})
|
|
10
|
+
}
|
|
11
|
+
ipcRenderer.on('pmate:reach:event', listener)
|
|
12
|
+
return () => ipcRenderer.removeListener('pmate:reach:event', listener)
|
|
13
|
+
},
|
|
14
|
+
configure: identity => ipcRenderer.invoke('pmate:reach:configure', identity),
|
|
15
|
+
stop: () => ipcRenderer.invoke('pmate:reach:stop'),
|
|
16
|
+
}),
|
|
17
|
+
calls: Object.freeze({
|
|
18
|
+
incoming: callId => ipcRenderer.invoke('pmate:call:incoming', callId),
|
|
19
|
+
handleReach: event => ipcRenderer.invoke('pmate:call:reach', event),
|
|
20
|
+
setPeople: people => ipcRenderer.invoke('pmate:call:people', people),
|
|
21
|
+
}),
|
|
22
|
+
notifications: Object.freeze({
|
|
23
|
+
present: input => ipcRenderer.invoke('pmate:notification:present', input),
|
|
24
|
+
setActiveThread: threadId => ipcRenderer.invoke('pmate:notification:context', threadId),
|
|
25
|
+
onOpen: handler => {
|
|
26
|
+
let active = true
|
|
27
|
+
const seen = new Set()
|
|
28
|
+
const deliver = value => { if (active && value && !seen.has(value.notificationId)) { seen.add(value.notificationId); handler(value) } }
|
|
29
|
+
const listener = () => { void ipcRenderer.invoke('pmate:notification:consumeOpen').then(deliver).catch(() => {}) }
|
|
30
|
+
ipcRenderer.on('pmate:notification:open', listener)
|
|
31
|
+
listener()
|
|
32
|
+
return () => { active = false; ipcRenderer.removeListener('pmate:notification:open', listener) }
|
|
33
|
+
},
|
|
34
|
+
}),
|
|
35
|
+
}))
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
const { randomUUID } = require('node:crypto')
|
|
2
|
+
/** A socket receipt is not a renderer business acknowledgement. */
|
|
3
|
+
function createRendererReach({ ipcMain, getWindow, trusted, timeoutMs = 1800 }) {
|
|
4
|
+
const pending = new Map()
|
|
5
|
+
ipcMain.handle('pmate:reach:ack', (event, reply) => {
|
|
6
|
+
if (!trusted(event)) throw new Error('Untrusted reach acknowledgement')
|
|
7
|
+
const item = pending.get(reply?.requestId)
|
|
8
|
+
if (!item || item.sender !== event.sender) throw new Error('Unknown reach request')
|
|
9
|
+
if (!['handled', 'suppressed', 'failed'].includes(reply.status)) throw new Error('Invalid reach acknowledgement')
|
|
10
|
+
item.finish(reply.status)
|
|
11
|
+
})
|
|
12
|
+
return {
|
|
13
|
+
deliver(event) {
|
|
14
|
+
const window = getWindow()
|
|
15
|
+
if (!window || window.isDestroyed() || window.webContents.isDestroyed()) return Promise.reject(new Error('Reach renderer unavailable'))
|
|
16
|
+
const sender = window.webContents, requestId = randomUUID()
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
let timer
|
|
19
|
+
const finish = status => {
|
|
20
|
+
if (!pending.delete(requestId)) return
|
|
21
|
+
clearTimeout(timer)
|
|
22
|
+
sender.removeListener('destroyed', destroyed)
|
|
23
|
+
sender.removeListener('did-start-navigation', navigated)
|
|
24
|
+
if (status === 'handled' || status === 'suppressed') resolve(status)
|
|
25
|
+
else reject(new Error('Reach renderer did not handle event'))
|
|
26
|
+
}
|
|
27
|
+
const destroyed = () => finish('failed')
|
|
28
|
+
const navigated = (_event, _url, inPlace, mainFrame) => { if (mainFrame && !inPlace) finish('failed') }
|
|
29
|
+
pending.set(requestId, { sender, finish })
|
|
30
|
+
sender.once('destroyed', destroyed)
|
|
31
|
+
sender.on('did-start-navigation', navigated)
|
|
32
|
+
timer = setTimeout(() => finish('failed'), timeoutMs)
|
|
33
|
+
try { sender.send('pmate:reach:event', { requestId, event }) } catch { finish('failed') }
|
|
34
|
+
})
|
|
35
|
+
},
|
|
36
|
+
reset() { for (const item of [...pending.values()]) item.finish('failed') },
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
module.exports = { createRendererReach }
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
const { randomUUID } = require('node:crypto')
|
|
2
|
+
const { readFileSync, writeFileSync, mkdirSync } = require('node:fs')
|
|
3
|
+
const path = require('node:path')
|
|
4
|
+
|
|
5
|
+
function desktopInstanceId(userData) {
|
|
6
|
+
const file = path.join(userData, 'reach-device.json')
|
|
7
|
+
try { const value = JSON.parse(readFileSync(file, 'utf8')); if (typeof value.id === 'string' && /^desktop-[a-f0-9-]{36}$/.test(value.id)) return value.id } catch {}
|
|
8
|
+
mkdirSync(userData, { recursive: true })
|
|
9
|
+
const id = `desktop-${randomUUID()}`
|
|
10
|
+
writeFileSync(file, JSON.stringify({ id }), { mode: 0o600 })
|
|
11
|
+
return id
|
|
12
|
+
}
|
|
13
|
+
/** Credentials remain in memory. This connection survives renderer reload/closure. */
|
|
14
|
+
function createDesktopReach({ appId, instanceId, hubUrl = 'wss://hub.pmate.chat', present, onReach, configureCalls, stopCalls, incomingCall, isViewingThread, createClient = async options => {
|
|
15
|
+
const { ChatClient } = await import('@pmate/chat-sdk/runtime')
|
|
16
|
+
return new ChatClient(options)
|
|
17
|
+
} }) {
|
|
18
|
+
let client, identity, generation = 0
|
|
19
|
+
return {
|
|
20
|
+
async configure(value) {
|
|
21
|
+
if (!value || value.appId !== appId || ['accountId', 'profileId', 'token'].some(key => typeof value[key] !== 'string' || !value[key] || value[key].length > 16384)) throw new Error('Invalid reach identity')
|
|
22
|
+
if (client && identity && identity.accountId === value.accountId && identity.profileId === value.profileId && identity.token === value.token) return { instanceId }
|
|
23
|
+
const ownGeneration = ++generation
|
|
24
|
+
client?.close(); client = undefined; identity = { accountId: value.accountId, profileId: value.profileId, token: value.token }
|
|
25
|
+
await configureCalls?.(value)
|
|
26
|
+
if (generation !== ownGeneration) return { instanceId }
|
|
27
|
+
const next = await createClient({ appId, profileId: value.profileId, clientInstanceId: instanceId, hubUrl,
|
|
28
|
+
session: { token: value.token, session: { identity: { accountId: value.accountId, app: appId, profileId: value.profileId, kind: 'user' } } },
|
|
29
|
+
onNotification: async notification => {
|
|
30
|
+
if (generation !== ownGeneration) throw new Error('Reach account changed')
|
|
31
|
+
const data = notification.data
|
|
32
|
+
let threadId = data.threadId
|
|
33
|
+
if (data.type === 'call.incoming') {
|
|
34
|
+
if (!incomingCall || typeof data.callId !== 'string' || typeof data.callerId !== 'string') throw new Error('Call handler unavailable')
|
|
35
|
+
const { dmThreadId } = await import('@pmate/chat-sdk/runtime')
|
|
36
|
+
threadId = dmThreadId(appId, value.profileId, data.callerId)
|
|
37
|
+
const viewed = isViewingThread?.(threadId)
|
|
38
|
+
const result = await incomingCall(data.callId)
|
|
39
|
+
if (generation !== ownGeneration) throw new Error('Reach account changed')
|
|
40
|
+
if (result === 'suppressed' || viewed) return 'suppressed'
|
|
41
|
+
}
|
|
42
|
+
const digestThreads = data.type === 'social.messageDigest' ? JSON.parse(data.threads) : undefined
|
|
43
|
+
return present({ appId, notificationId: notification.notificationId, title: notification.title ?? '', body: notification.body ?? '', digestThreads,
|
|
44
|
+
threadId, callId: data.callId, meetingId: data.meetingId, expiresAtMs: notification.expiresAtMs })
|
|
45
|
+
},
|
|
46
|
+
onReach: async event => {
|
|
47
|
+
if (generation !== ownGeneration || !onReach) throw new Error('Reach handler unavailable')
|
|
48
|
+
const result = await onReach(event)
|
|
49
|
+
if (generation !== ownGeneration) throw new Error('Reach account changed')
|
|
50
|
+
return result
|
|
51
|
+
},
|
|
52
|
+
})
|
|
53
|
+
if (generation !== ownGeneration) { next.close(); return { instanceId } }
|
|
54
|
+
client = next
|
|
55
|
+
// Authentication and reconnect belong to the host; IPC does not wait for a network roundtrip.
|
|
56
|
+
void next.connect().catch(() => {})
|
|
57
|
+
return { instanceId }
|
|
58
|
+
},
|
|
59
|
+
stop() { generation++; client?.close(); client = undefined; identity = undefined; stopCalls?.() },
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
module.exports = { desktopInstanceId, createDesktopReach }
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
const { contextBridge, ipcRenderer } = require('electron')
|
|
2
|
+
const nonce = process.argv.find(arg => arg.startsWith('--picker-id='))?.slice('--picker-id='.length)
|
|
3
|
+
contextBridge.exposeInMainWorld('screenPicker', { sources: () => ipcRenderer.invoke(`screen-picker:data:${nonce}`), select: id => ipcRenderer.invoke(`screen-picker:select:${nonce}`, typeof id === 'string' ? id : null) })
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const { randomUUID } = require('node:crypto')
|
|
2
|
+
const path = require('node:path')
|
|
3
|
+
/** A separate sandboxed window owns the picker; only that exact frame can select. */
|
|
4
|
+
function chooseScreen({ BrowserWindow, ipcMain, parent, sources }) {
|
|
5
|
+
return new Promise(resolve => {
|
|
6
|
+
const nonce = randomUUID()
|
|
7
|
+
const picker = new BrowserWindow({ parent, modal: true, width: 780, height: 550, minWidth: 540, minHeight: 420, title: '选择共享内容', backgroundColor: '#f6faf8', autoHideMenuBar: true,
|
|
8
|
+
webPreferences: { preload: path.join(__dirname, 'share-picker-preload.cjs'), additionalArguments: [`--picker-id=${nonce}`], partition: `screen-picker-${nonce}`, nodeIntegration: false, contextIsolation: true, sandbox: true, webSecurity: true } })
|
|
9
|
+
let complete = false
|
|
10
|
+
const dataChannel = `screen-picker:data:${nonce}`, selectChannel = `screen-picker:select:${nonce}`
|
|
11
|
+
const trusted = event => event.sender === picker.webContents && event.senderFrame === picker.webContents.mainFrame
|
|
12
|
+
const finish = source => { if (complete) return; complete = true; ipcMain.removeHandler(dataChannel); ipcMain.removeHandler(selectChannel); resolve(source); if (!picker.isDestroyed()) picker.close() }
|
|
13
|
+
ipcMain.handle(dataChannel, event => trusted(event) ? sources.map(s => ({ id: s.id, name: s.name, thumbnail: s.thumbnail?.toDataURL() ?? '', kind: s.id.startsWith('screen:') ? 'screen' : 'window' })) : [])
|
|
14
|
+
ipcMain.handle(selectChannel, (event, id) => { if (!trusted(event)) return; finish(sources.find(s => s.id === id)) })
|
|
15
|
+
picker.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
|
|
16
|
+
picker.webContents.on('will-navigate', event => event.preventDefault())
|
|
17
|
+
picker.once('closed', () => finish())
|
|
18
|
+
picker.webContents.on('render-process-gone', () => finish())
|
|
19
|
+
picker.loadFile(path.join(__dirname, 'share-picker.html')).catch(() => finish())
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
module.exports = { chooseScreen }
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; img-src data:"><title>选择共享内容</title><style>
|
|
2
|
+
*{box-sizing:border-box}body{margin:0;font:14px -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f6faf8;color:#172c27}main{height:100vh;display:flex;flex-direction:column;padding:24px}h1{font-size:19px;margin:0 0 20px}nav{display:flex;gap:8px;margin-bottom:20px}button{font:inherit;cursor:pointer;border:1px solid #dce6e1;background:white;color:#19372d;border-radius:9px;padding:10px 20px}button:focus-visible{outline:3px solid #2ec88a;outline-offset:3px}button[aria-selected=true]{background:#e1f6ed;color:#087d52;border-color:#9ddbbf}#sources{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;overflow:auto;flex:1;align-content:start;padding:4px}.source{padding:5px;min-width:0;border:2px solid transparent;background:transparent}.source[aria-checked=true]{border-color:#149964;background:#e6f7ef}.source img{width:100%;height:105px;object-fit:contain;background:#e5ece8;border-radius:5px}.source span{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding:8px 2px;font-size:12px}footer{padding-top:20px;display:flex;align-items:center;gap:10px}footer p{flex:1;color:#6a7c74;font-size:12px}#start{background:#11875c;color:#fff;border-color:#11875c}button:disabled{opacity:.45;cursor:default}#error{color:#687d73;line-height:1.7} @media(prefers-color-scheme:dark){body{background:#14231e;color:#edf6f1}button{background:#20372e;color:#edf6f1;border-color:#456353}.source img{background:#23362e}.source[aria-checked=true]{background:#234c3c}}
|
|
3
|
+
</style></head><body><main><h1>选择共享内容</h1><nav role="tablist"><button role="tab" data-kind="screen" aria-selected="true">整个屏幕</button><button role="tab" data-kind="window" aria-selected="false">应用窗口</button></nav><div id="sources" role="radiogroup" aria-label="共享来源"></div><p id="error" hidden></p><footer><p>只有点击开始共享后,对方才能看到内容</p><button id="cancel">取消</button><button id="start" disabled>开始共享</button></footer></main><script src="share-picker.js"></script></body></html>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
let sources = [], kind = 'screen', selected
|
|
2
|
+
const list = document.getElementById('sources'), start = document.getElementById('start')
|
|
3
|
+
function render() {
|
|
4
|
+
list.replaceChildren()
|
|
5
|
+
const available = sources.filter(source => source.kind === kind)
|
|
6
|
+
for (const source of available) {
|
|
7
|
+
const button = document.createElement('button'); button.className = 'source'; button.setAttribute('role', 'radio'); button.setAttribute('aria-checked', String(selected === source.id))
|
|
8
|
+
const image = document.createElement('img'); image.src = source.thumbnail; image.alt = ''
|
|
9
|
+
const label = document.createElement('span'); label.textContent = source.name; button.title = source.name
|
|
10
|
+
button.append(image, label); button.onclick = () => { selected = source.id; render() }; list.append(button)
|
|
11
|
+
}
|
|
12
|
+
start.disabled = !available.some(source => source.id === selected)
|
|
13
|
+
document.getElementById('error').hidden = available.length > 0
|
|
14
|
+
document.getElementById('error').textContent = '没有可共享的内容。请在系统设置中允许屏幕录制,再重新打开应用。'
|
|
15
|
+
}
|
|
16
|
+
for (const tab of document.querySelectorAll('[data-kind]')) tab.onclick = () => { kind = tab.dataset.kind; selected = undefined; for (const other of document.querySelectorAll('[data-kind]')) other.setAttribute('aria-selected', String(other === tab)); render() }
|
|
17
|
+
document.getElementById('cancel').onclick = () => window.screenPicker.select(null)
|
|
18
|
+
start.onclick = () => { if (selected) { start.disabled = true; window.screenPicker.select(selected) } }
|
|
19
|
+
document.addEventListener('keydown', e => { if (e.key === 'Escape') window.screenPicker.select(null) })
|
|
20
|
+
window.screenPicker.sources().then(data => { sources = data; render() }).catch(() => render())
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pmate/cli",
|
|
3
|
-
"version": "0.35.
|
|
3
|
+
"version": "0.35.2",
|
|
4
4
|
"description": "CLI utilities for the pMate project",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
|
@@ -11,6 +11,16 @@
|
|
|
11
11
|
"files": [
|
|
12
12
|
"dist"
|
|
13
13
|
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "npm run clean && tsup --config tsup.config.ts && node scripts/copy-templates.js",
|
|
16
|
+
"clean": "rimraf dist",
|
|
17
|
+
"dev": "ts-node src/index.ts",
|
|
18
|
+
"release": "pnpm publish",
|
|
19
|
+
"start": "node dist/index.js",
|
|
20
|
+
"test": "vitest run src/tests",
|
|
21
|
+
"typecheck": "tsc --noEmit",
|
|
22
|
+
"prepare": "npm run build"
|
|
23
|
+
},
|
|
14
24
|
"keywords": [
|
|
15
25
|
"cli",
|
|
16
26
|
"typescript",
|
|
@@ -57,13 +67,10 @@
|
|
|
57
67
|
"typescript": "^5.9.3",
|
|
58
68
|
"vitest": "^4.0.17"
|
|
59
69
|
},
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
"start": "node dist/index.js",
|
|
66
|
-
"test": "vitest run src/tests",
|
|
67
|
-
"typecheck": "tsc --noEmit"
|
|
70
|
+
"pnpm": {
|
|
71
|
+
"overrides": {
|
|
72
|
+
"@pmate/meta": "^1.3.1",
|
|
73
|
+
"@pmate/utils": "^1.1.4"
|
|
74
|
+
}
|
|
68
75
|
}
|
|
69
|
-
}
|
|
76
|
+
}
|