@huakirax/dsh-plugin-pocket 0.0.0-stage → 0.1.1
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/LICENSE +9 -0
- package/README.md +237 -2
- package/bin/pocket.js +31 -0
- package/cordis.patch.yml +6 -0
- package/index.js +360 -0
- package/lib/client.js +2453 -0
- package/package.json +91 -4
- package/src/advertise-host.js +69 -0
- package/src/approval-hook.js +50 -0
- package/src/client/ConfirmDialog.tsx +80 -0
- package/src/client/HostUiLauncher.tsx +101 -0
- package/src/client/ManagePanel.tsx +218 -0
- package/src/client/PhoneIcon.tsx +48 -0
- package/src/client/PocketSettings.tsx +13 -0
- package/src/client/api.ts +97 -0
- package/src/client/completion-sync.js +289 -0
- package/src/client/host-ui-styles.ts +62 -0
- package/src/client/index.ts +47 -0
- package/src/client/locales.ts +75 -0
- package/src/client/nav-icon.ts +31 -0
- package/src/client/qr.ts +15 -0
- package/src/client/styles.ts +76 -0
- package/src/completion-route.js +87 -0
- package/src/completion-tracker.js +95 -0
- package/src/devices.js +60 -0
- package/src/extra-ops.js +604 -0
- package/src/host-ui.js +466 -0
- package/src/instance-id.js +33 -0
- package/src/iroh.js +260 -0
- package/src/manage.js +122 -0
- package/src/mdns-advertise.js +75 -0
- package/src/office-preview.js +129 -0
- package/src/pairing-uri.js +42 -0
- package/src/pairing.js +34 -0
- package/src/parity-ops.js +250 -0
- package/src/server.js +761 -0
- package/src/settings-ops.js +387 -0
- package/src/task-events.js +77 -0
- package/src/task-fallback.js +177 -0
- package/src/token.js +18 -0
- package/src/transport-pref.js +47 -0
- package/src/trust.js +25 -0
- package/src/user-question-hook.js +93 -0
- package/src/web-route.js +116 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { CSSProperties } from 'react'
|
|
2
|
+
|
|
3
|
+
const text = 'var(--dsw-alias-label-primary)'
|
|
4
|
+
const muted = 'var(--dsw-alias-label-secondary)'
|
|
5
|
+
const border = '1px solid var(--dsw-alias-border-l2)'
|
|
6
|
+
const surface = 'var(--dsw-alias-bg-module-platform, var(--dsw-alias-bg-base))'
|
|
7
|
+
const button: CSSProperties = {
|
|
8
|
+
appearance: 'none', display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
|
9
|
+
gap: 8, border: 'none', borderRadius: 8, background: 'transparent', color: muted,
|
|
10
|
+
cursor: 'pointer', font: 'inherit', fontSize: 13, lineHeight: '20px', padding: '8px 10px',
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const styles = {
|
|
14
|
+
settingsRoot: { width: '100%', padding: '8px 0 24px', color: text, containerType: 'inline-size' },
|
|
15
|
+
header: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, marginBottom: 24 },
|
|
16
|
+
title: { margin: 0, fontSize: 24, fontWeight: 650, lineHeight: '32px' },
|
|
17
|
+
service: { display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12, color: muted },
|
|
18
|
+
pairingCard: { border, borderRadius: 16, background: surface, padding: '24px 24px 0' },
|
|
19
|
+
pairingTop: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 24, minHeight: 168, paddingBottom: 24 },
|
|
20
|
+
pairControls: { display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 18, minWidth: 0 },
|
|
21
|
+
rowTitle: { margin: 0, fontSize: 16, lineHeight: '24px', fontWeight: 600, color: text },
|
|
22
|
+
illustration: { width: 180, maxWidth: '30%', flexShrink: 0, color: 'var(--dsw-alias-label-tertiary)', opacity: .55, marginRight: 20 },
|
|
23
|
+
segments: { display: 'inline-flex', padding: 3, borderRadius: 10, gap: 2, background: 'var(--dsw-alias-bg-overlay)' },
|
|
24
|
+
segment: { ...button, minWidth: 88, padding: '6px 16px' },
|
|
25
|
+
segmentActive: { color: text, background: 'var(--dsw-alias-bg-base)', boxShadow: '0 1px 4px rgba(0,0,0,.08)' },
|
|
26
|
+
footer: { display: 'flex', flexWrap: 'wrap', alignItems: 'center', justifyContent: 'space-between', gap: '8px 16px', borderTop: border, padding: '14px 0' },
|
|
27
|
+
address: { display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8, minWidth: 0, fontSize: 12, color: muted },
|
|
28
|
+
addressValue: { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', fontSize: 12, color: text, overflowWrap: 'anywhere' },
|
|
29
|
+
footerActions: { display: 'flex', alignItems: 'center', gap: 10, marginLeft: 'auto' },
|
|
30
|
+
divider: { width: 1, height: 16, background: 'var(--dsw-alias-border-l2)', flexShrink: 0 },
|
|
31
|
+
iconBtn: { ...button, padding: 8 },
|
|
32
|
+
textBtn: button,
|
|
33
|
+
primaryBtn: { ...button, height: 40, padding: '0 18px', fontSize: 14, fontWeight: 550, borderRadius: 10, background: 'var(--dsw-alias-button-primary-fill)', color: 'var(--dsw-alias-label-primary-foreground, var(--dsw-alias-bg-base))' },
|
|
34
|
+
outlineBtn: { ...button, border, color: text },
|
|
35
|
+
outlineBtnSm: { ...button, padding: '5px 8px', color: text },
|
|
36
|
+
destructiveBtn: { ...button, background: 'var(--dsw-alias-state-error-primary)', color: '#fff', padding: '8px 14px' },
|
|
37
|
+
ghostBtn: { ...button, border, color: text },
|
|
38
|
+
hint: { margin: 0, fontSize: 12, lineHeight: '20px', color: muted },
|
|
39
|
+
errorText: { margin: '0 0 16px', fontSize: 12, lineHeight: '20px', color: 'var(--dsw-alias-state-error-primary)' },
|
|
40
|
+
stateDotSlot: (state: 'done' | 'error' | 'idle'): CSSProperties => ({
|
|
41
|
+
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 10, height: 10, flexShrink: 0,
|
|
42
|
+
color: state === 'done' ? 'var(--dsw-alias-state-success-primary)' : state === 'error' ? 'var(--dsw-alias-state-error-primary)' : 'var(--dsw-alias-label-tertiary)',
|
|
43
|
+
}),
|
|
44
|
+
stateDotCore: { width: 6, height: 6, borderRadius: '50%', background: 'currentColor' },
|
|
45
|
+
pairingContent: { display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 24, padding: '0 0 24px' },
|
|
46
|
+
code: { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', fontSize: 28, letterSpacing: '.16em', fontWeight: 600, margin: '4px 0 8px' },
|
|
47
|
+
qrBox: { padding: 10, borderRadius: 10, background: '#fff', lineHeight: 0 },
|
|
48
|
+
actionRow: { display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 16 },
|
|
49
|
+
devicesSection: { marginTop: 30 },
|
|
50
|
+
devicesHeader: { display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 },
|
|
51
|
+
count: { background: surface, color: muted, borderRadius: 8, minWidth: 24, textAlign: 'center', fontSize: 12, padding: '2px 4px' },
|
|
52
|
+
deviceList: { display: 'flex', flexDirection: 'column', gap: 10 },
|
|
53
|
+
deviceRow: { display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 14, padding: '18px 20px', border, borderRadius: 14 },
|
|
54
|
+
deviceIcon: { display: 'flex', alignItems: 'center', justifyContent: 'center', width: 44, height: 48, borderRadius: 11, background: surface, color: muted },
|
|
55
|
+
deviceInfo: { flex: '1 1 120px', minWidth: 0 },
|
|
56
|
+
deviceHeading: { display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 },
|
|
57
|
+
deviceName: { fontSize: 14, lineHeight: '22px', fontWeight: 550, color: text, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
|
|
58
|
+
deviceMeta: { marginTop: 4, fontSize: 12, lineHeight: '18px', color: muted },
|
|
59
|
+
deviceActions: { display: 'flex', alignItems: 'center', gap: 6, marginLeft: 'auto' },
|
|
60
|
+
input: { boxSizing: 'border-box', width: '100%', minWidth: 0, height: 34, border, borderRadius: 8, padding: '0 10px', background: 'var(--dsw-alias-bg-base)', color: text, font: 'inherit', fontSize: 13 },
|
|
61
|
+
dialogMask: { position: 'fixed', inset: 0, zIndex: 1100, display: 'flex', alignItems: 'center', justifyContent: 'center', pointerEvents: 'auto', background: 'rgba(0,0,0,.45)', padding: 24 },
|
|
62
|
+
dialogCard: { width: 'min(560px, calc(100vw - 48px))', maxHeight: '80vh', overflow: 'auto', borderRadius: 14, border, background: 'var(--dsw-alias-bg-base)', color: text, boxShadow: '0 12px 40px rgba(0,0,0,.2)', padding: 24, display: 'flex', flexDirection: 'column', gap: 12 },
|
|
63
|
+
dialogTitle: { margin: 0, fontSize: 16, fontWeight: 600, lineHeight: '24px' },
|
|
64
|
+
dialogBody: { margin: 0, fontSize: 14, lineHeight: '22px', color: muted },
|
|
65
|
+
dialogActions: { display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 8 },
|
|
66
|
+
} satisfies Record<string, CSSProperties | ((state: 'done' | 'error' | 'idle') => CSSProperties)>
|
|
67
|
+
|
|
68
|
+
export const panelCss = `
|
|
69
|
+
[data-pocket-panel] button:disabled { opacity: .45; cursor: not-allowed !important; }
|
|
70
|
+
[data-pocket-panel] button:not(:disabled):hover { filter: brightness(.94); }
|
|
71
|
+
[data-pocket-panel] button:focus-visible { outline: 2px solid var(--dsw-alias-label-secondary); outline-offset: 3px; }
|
|
72
|
+
@container (max-width: 440px) {
|
|
73
|
+
[data-pocket-panel] .pocket-illustration { display: none; }
|
|
74
|
+
[data-pocket-panel] .pocket-device-actions { width: 100%; justify-content: flex-end; }
|
|
75
|
+
}
|
|
76
|
+
`
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { isTrustedRequest } from './trust.js'
|
|
3
|
+
|
|
4
|
+
const json = (res, status, body) => {
|
|
5
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
6
|
+
res.end(JSON.stringify(body))
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Same local-operator trust fence as the existing Pocket management routes. */
|
|
10
|
+
export function registerCompletionRoutes(ctx, tracker) {
|
|
11
|
+
ctx.effect(() => {
|
|
12
|
+
const clients = new Map()
|
|
13
|
+
const gate = (req, res) => {
|
|
14
|
+
if (isTrustedRequest(req)) return true
|
|
15
|
+
json(res, 403, { error: 'forbidden' })
|
|
16
|
+
return false
|
|
17
|
+
}
|
|
18
|
+
const unregisterEvents = ctx.webServer.register({
|
|
19
|
+
kind: 'exact', path: '/pocket/completion/events',
|
|
20
|
+
handler: (req, res) => {
|
|
21
|
+
if (!gate(req, res)) return
|
|
22
|
+
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' })
|
|
23
|
+
const clientId = randomUUID()
|
|
24
|
+
let closed = false
|
|
25
|
+
let unsubscribe = () => {}
|
|
26
|
+
let heartbeat
|
|
27
|
+
const close = () => {
|
|
28
|
+
if (closed) return
|
|
29
|
+
closed = true
|
|
30
|
+
clearInterval(heartbeat)
|
|
31
|
+
unsubscribe()
|
|
32
|
+
clients.delete(clientId)
|
|
33
|
+
tracker.disconnect(clientId)
|
|
34
|
+
res.end()
|
|
35
|
+
}
|
|
36
|
+
const send = (snapshot) => {
|
|
37
|
+
if (closed) return
|
|
38
|
+
// Disconnect slow consumers instead of retaining unbounded snapshots.
|
|
39
|
+
if (!res.write(`data: ${JSON.stringify({ clientId, ...snapshot })}\n\n`)) close()
|
|
40
|
+
}
|
|
41
|
+
clients.set(clientId, close)
|
|
42
|
+
res.on('close', close)
|
|
43
|
+
res.on('error', close)
|
|
44
|
+
res.writeHead(200, {
|
|
45
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
46
|
+
'cache-control': 'no-store',
|
|
47
|
+
'x-accel-buffering': 'no',
|
|
48
|
+
})
|
|
49
|
+
unsubscribe = tracker.subscribe(send)
|
|
50
|
+
send(tracker.snapshot())
|
|
51
|
+
if (!closed) {
|
|
52
|
+
heartbeat = setInterval(() => { if (!res.write(': keepalive\n\n')) close() }, 30_000)
|
|
53
|
+
heartbeat.unref()
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
})
|
|
57
|
+
const unregisterView = ctx.webServer.register({
|
|
58
|
+
kind: 'exact', path: '/pocket/completion/view',
|
|
59
|
+
handler: async (req, res) => {
|
|
60
|
+
if (!gate(req, res)) return
|
|
61
|
+
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
|
|
62
|
+
let body = ''
|
|
63
|
+
try {
|
|
64
|
+
for await (const chunk of req) {
|
|
65
|
+
body += chunk
|
|
66
|
+
if (body.length > 4096) return json(res, 413, { error: 'too large' })
|
|
67
|
+
}
|
|
68
|
+
body = JSON.parse(body)
|
|
69
|
+
} catch {
|
|
70
|
+
return json(res, 400, { error: 'invalid json' })
|
|
71
|
+
}
|
|
72
|
+
if (!body || typeof body.clientId !== 'string'
|
|
73
|
+
|| !(body.sessionId === null || (typeof body.sessionId === 'string' && body.sessionId.length > 0))) {
|
|
74
|
+
return json(res, 400, { error: 'clientId and sessionId (string or null) required' })
|
|
75
|
+
}
|
|
76
|
+
// Checked after reading the body: the SSE may have disconnected meanwhile.
|
|
77
|
+
if (!clients.has(body.clientId)) return json(res, 409, { error: 'completion/client-expired' })
|
|
78
|
+
return json(res, 200, tracker.view(body.clientId, body.sessionId))
|
|
79
|
+
},
|
|
80
|
+
})
|
|
81
|
+
return () => {
|
|
82
|
+
unregisterEvents()
|
|
83
|
+
unregisterView()
|
|
84
|
+
for (const close of clients.values()) close()
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
/** Host-owned unread state. A follow subscription is deliberately not a view. */
|
|
4
|
+
export class CompletionTracker {
|
|
5
|
+
constructor({ initializing = false } = {}) {
|
|
6
|
+
this.epoch = randomUUID()
|
|
7
|
+
this.revision = 0
|
|
8
|
+
this.running = new Set()
|
|
9
|
+
this.unread = new Set()
|
|
10
|
+
this.views = new Map()
|
|
11
|
+
this.listeners = new Set()
|
|
12
|
+
this.pending = initializing ? [] : null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
snapshot() {
|
|
16
|
+
return { epoch: this.epoch, revision: this.revision, unreadIds: [...this.unread] }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
subscribe(listener) {
|
|
20
|
+
this.listeners.add(listener)
|
|
21
|
+
return () => this.listeners.delete(listener)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
publish() {
|
|
25
|
+
this.revision++
|
|
26
|
+
const snapshot = this.snapshot()
|
|
27
|
+
for (const listener of this.listeners) listener(snapshot)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Seed once, then replay events received while the asynchronous list was read. */
|
|
31
|
+
seed(items) {
|
|
32
|
+
if (this.pending === null) return
|
|
33
|
+
const pending = this.pending
|
|
34
|
+
this.pending = null
|
|
35
|
+
for (const item of items) {
|
|
36
|
+
if (item?.running === true && typeof item.sessionId === 'string') this.running.add(item.sessionId)
|
|
37
|
+
}
|
|
38
|
+
const observed = new Set()
|
|
39
|
+
for (const [method, args] of pending) {
|
|
40
|
+
const sessionId = args[0]
|
|
41
|
+
if (method === 'status' || method === 'added' || method === 'remove') {
|
|
42
|
+
// The list may already reflect this stop. Before the first baseline,
|
|
43
|
+
// a live stop is completion evidence unless an earlier event says idle
|
|
44
|
+
// or removed; historical idle rows alone are never completion evidence.
|
|
45
|
+
if (method === 'status' && args[1] === false && !observed.has(sessionId)) this.running.add(sessionId)
|
|
46
|
+
observed.add(sessionId)
|
|
47
|
+
}
|
|
48
|
+
this[method](...args)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
status(sessionId, running) {
|
|
53
|
+
if (typeof sessionId !== 'string' || !sessionId) return
|
|
54
|
+
if (this.pending !== null) { this.pending.push(['status', [sessionId, running]]); return }
|
|
55
|
+
const wasRunning = this.running.has(sessionId)
|
|
56
|
+
if (running) {
|
|
57
|
+
this.running.add(sessionId)
|
|
58
|
+
if (this.unread.delete(sessionId)) this.publish()
|
|
59
|
+
} else {
|
|
60
|
+
this.running.delete(sessionId)
|
|
61
|
+
if (wasRunning && ![...this.views.values()].includes(sessionId) && !this.unread.has(sessionId)) {
|
|
62
|
+
this.unread.add(sessionId)
|
|
63
|
+
this.publish()
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** An added summary establishes running state; it is not a stop event. */
|
|
69
|
+
added(sessionId, running) {
|
|
70
|
+
if (typeof sessionId !== 'string' || !sessionId) return
|
|
71
|
+
if (this.pending !== null) { this.pending.push(['added', [sessionId, running]]); return }
|
|
72
|
+
if (running) this.status(sessionId, true)
|
|
73
|
+
else this.running.delete(sessionId)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
remove(sessionId) {
|
|
77
|
+
if (this.pending !== null) { this.pending.push(['remove', [sessionId]]); return }
|
|
78
|
+
this.running.delete(sessionId)
|
|
79
|
+
for (const [client, viewed] of this.views) if (viewed === sessionId) this.views.delete(client)
|
|
80
|
+
if (this.unread.delete(sessionId)) this.publish()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
view(client, sessionId) {
|
|
84
|
+
if (this.pending !== null) { this.pending.push(['view', [client, sessionId]]); return this.snapshot() }
|
|
85
|
+
if (sessionId === null) this.views.delete(client)
|
|
86
|
+
else this.views.set(client, sessionId)
|
|
87
|
+
if (this.unread.delete(sessionId)) this.publish()
|
|
88
|
+
return this.snapshot()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
disconnect(client) {
|
|
92
|
+
if (this.pending !== null) { this.pending.push(['disconnect', [client]]); return }
|
|
93
|
+
this.views.delete(client)
|
|
94
|
+
}
|
|
95
|
+
}
|
package/src/devices.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
const hash = (s) => createHash('sha256').update(String(s)).digest('hex')
|
|
6
|
+
const eq = (a, b) => timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex'))
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Paired-device store. Tokens are stored as sha256 only.
|
|
10
|
+
* The owner token (M0 `token` file / config) authenticates as device "owner"
|
|
11
|
+
* and is the only identity allowed to open pairing windows.
|
|
12
|
+
*/
|
|
13
|
+
export function createDeviceStore({ dataDir, ownerToken, persist = true, now = Date.now }) {
|
|
14
|
+
const path = dataDir ? join(dataDir, 'devices.json') : undefined
|
|
15
|
+
let devices = []
|
|
16
|
+
if (persist && path && existsSync(path)) {
|
|
17
|
+
try { devices = JSON.parse(readFileSync(path, 'utf8')).devices ?? [] } catch { devices = [] }
|
|
18
|
+
}
|
|
19
|
+
const save = () => {
|
|
20
|
+
if (!persist || !path) return
|
|
21
|
+
mkdirSync(dataDir, { recursive: true, mode: 0o700 })
|
|
22
|
+
const tmp = `${path}.tmp`
|
|
23
|
+
writeFileSync(tmp, JSON.stringify({ version: 1, devices }, null, 2), { mode: 0o600 })
|
|
24
|
+
chmodSync(tmp, 0o600)
|
|
25
|
+
renameSync(tmp, path)
|
|
26
|
+
}
|
|
27
|
+
const ownerHash = hash(ownerToken)
|
|
28
|
+
return {
|
|
29
|
+
/** @returns {{id: string, name: string, owner: boolean} | undefined} */
|
|
30
|
+
authenticate(token) {
|
|
31
|
+
if (typeof token !== 'string' || token.length === 0) return undefined
|
|
32
|
+
const h = hash(token)
|
|
33
|
+
if (eq(h, ownerHash)) return { id: 'owner', name: 'owner', owner: true }
|
|
34
|
+
const d = devices.find((x) => eq(x.tokenHash, h))
|
|
35
|
+
if (!d) return undefined
|
|
36
|
+
d.lastSeenAt = now(); save()
|
|
37
|
+
return { id: d.id, name: d.name, owner: false }
|
|
38
|
+
},
|
|
39
|
+
add(name) {
|
|
40
|
+
const token = randomBytes(32).toString('base64url')
|
|
41
|
+
const d = { id: randomUUID(), name: String(name || 'device').slice(0, 64), tokenHash: hash(token), createdAt: now(), lastSeenAt: null }
|
|
42
|
+
devices.push(d); save()
|
|
43
|
+
return { deviceId: d.id, token }
|
|
44
|
+
},
|
|
45
|
+
list: () => devices.map(({ id, name, createdAt, lastSeenAt }) => ({ id, name, createdAt, lastSeenAt })),
|
|
46
|
+
rename(id, name) {
|
|
47
|
+
const d = devices.find((x) => x.id === id)
|
|
48
|
+
if (!d) return false
|
|
49
|
+
d.name = String(name || 'device').slice(0, 64)
|
|
50
|
+
save()
|
|
51
|
+
return true
|
|
52
|
+
},
|
|
53
|
+
revoke(id) {
|
|
54
|
+
const before = devices.length
|
|
55
|
+
devices = devices.filter((d) => d.id !== id)
|
|
56
|
+
if (devices.length !== before) save()
|
|
57
|
+
return devices.length !== before
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
}
|