@pmate/cli 0.35.1 → 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 +44 -2
- package/dist/templates/electron/media.cjs +3 -4
- package/dist/templates/electron/notifications.cjs +71 -0
- package/dist/templates/electron/preload.cjs +34 -1
- package/dist/templates/electron/reach-renderer.cjs +39 -0
- package/dist/templates/electron/reach.cjs +62 -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, ipcMain } = 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. */ }
|
|
@@ -26,6 +31,7 @@ function restrict(window) {
|
|
|
26
31
|
function createWindow() {
|
|
27
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,7 +51,7 @@ else {
|
|
|
45
51
|
}
|
|
46
52
|
return resources(request) ?? desktopSession.fetch(request, { bypassCustomProtocolHandlers: true })
|
|
47
53
|
})
|
|
48
|
-
installMediaPermissions(desktopSession, { origin: config.origin, getWindow: () => mainWindow, desktopCapturer, BrowserWindow, ipcMain })
|
|
54
|
+
installMediaPermissions(desktopSession, { origin: config.origin, getWindow: () => mainWindow, getWindows: () => [mainWindow, desktopCalls?.getWindow()], desktopCapturer, BrowserWindow, ipcMain })
|
|
49
55
|
ipcMain.handle('pmate:screen-settings', event => {
|
|
50
56
|
if (!mainWindow || event.sender !== mainWindow.webContents || event.senderFrame !== mainWindow.webContents.mainFrame || new URL(mainWindow.webContents.getURL()).origin !== config.origin) return false
|
|
51
57
|
if (process.platform === 'darwin') { void shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture'); return true }
|
|
@@ -55,6 +61,42 @@ else {
|
|
|
55
61
|
{ role: 'appMenu' }, { role: 'editMenu' }, { role: 'viewMenu' }, { role: 'windowMenu' },
|
|
56
62
|
{ label: config.productName, submenu: [{ label: 'Back to app', click: () => { if (!mainWindow) createWindow(); else void mainWindow.loadURL(config.origin) } }] },
|
|
57
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() })
|
|
58
100
|
createWindow()
|
|
59
101
|
app.on('activate', () => { if (!mainWindow) createWindow() })
|
|
60
102
|
}).catch(error => { console.error('Desktop startup failed:', error.message); app.exit(1) })
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
const { chooseScreen } = require('./share-picker.cjs')
|
|
2
|
-
function installMediaPermissions(desktopSession, { origin, getWindow, desktopCapturer, BrowserWindow, ipcMain }) {
|
|
2
|
+
function installMediaPermissions(desktopSession, { origin, getWindow, getWindows = () => [getWindow()], desktopCapturer, BrowserWindow, ipcMain }) {
|
|
3
3
|
const sameOrigin = raw => { try { return new URL(raw).origin === origin } catch { return false } }
|
|
4
4
|
const trusted = contents => {
|
|
5
|
-
|
|
6
|
-
return !!window && !window.isDestroyed() && contents === window.webContents && sameOrigin(contents.getURL())
|
|
5
|
+
return getWindows().some(window => !!window && !window.isDestroyed() && contents === window.webContents && sameOrigin(contents.getURL()))
|
|
7
6
|
}
|
|
8
7
|
desktopSession.setPermissionRequestHandler((contents, permission, callback, details) => {
|
|
9
8
|
const allowed = trusted(contents) && details.isMainFrame === true && sameOrigin(details.requestingUrl)
|
|
@@ -13,7 +12,7 @@ function installMediaPermissions(desktopSession, { origin, getWindow, desktopCap
|
|
|
13
12
|
return Boolean(trusted(contents) && sameOrigin(requestingOrigin) && details.isMainFrame === true && (permission === 'display-capture' || (permission === 'media' && details.mediaType === 'audio')))
|
|
14
13
|
})
|
|
15
14
|
desktopSession.setDisplayMediaRequestHandler(async (request, callback) => {
|
|
16
|
-
const window =
|
|
15
|
+
const window = getWindows().find(window => window && !window.isDestroyed() && request.frame === window.webContents.mainFrame)
|
|
17
16
|
const valid = () => window && !window.isDestroyed() && trusted(window.webContents) && request.frame && request.frame === window.webContents.mainFrame && sameOrigin(request.securityOrigin)
|
|
18
17
|
if (!valid() || !request.userGesture || !request.videoRequested || request.audioRequested) return callback(null)
|
|
19
18
|
let completed = false
|
|
@@ -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 }
|
|
@@ -1,2 +1,35 @@
|
|
|
1
1
|
const { contextBridge, ipcRenderer } = require('electron')
|
|
2
|
-
contextBridge.exposeInMainWorld('pmateDesktop',
|
|
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 }
|
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
|
+
}
|