+194
-28
@@ -3,6 +3,11 @@
|
||||
*
|
||||
* Package shape (JSON in peardock:pkg):
|
||||
* { v:1, publicKeyHex, capability, role, alias?, expiresAt?, jti? }
|
||||
*
|
||||
* Note: [email protected] has two hang bugs in AutopassPairer that freeze the UI:
|
||||
* 1) await pass.deleteInvite() on a non-writable (readOnly) invitee never completes
|
||||
* 2) _whenWritable() returns without calling onresolve when already writable
|
||||
* We patch both after import.
|
||||
*/
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
@@ -13,6 +18,9 @@ import { classifyConnectionInput } from '../shared/crypto-auth.js'
|
||||
|
||||
const PKG_KEY = 'peardock:pkg'
|
||||
|
||||
/** @type {WeakSet<object>} */
|
||||
const patchedAutopass = new WeakSet()
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
@@ -35,14 +43,107 @@ function storeDirForInvite(invite) {
|
||||
return path.join(autopassRoot(), hash)
|
||||
}
|
||||
|
||||
/**
|
||||
* Yield so the Electron UI can paint status updates.
|
||||
*/
|
||||
function yieldUi() {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch hang bugs in autopass pairer / Autopass.deleteInvite.
|
||||
* @param {any} Autopass
|
||||
*/
|
||||
function patchAutopassHangs(Autopass) {
|
||||
if (!Autopass || patchedAutopass.has(Autopass)) return
|
||||
patchedAutopass.add(Autopass)
|
||||
|
||||
// Non-writers must not await append-based deleteInvite (never resolves)
|
||||
const origDelete = Autopass.prototype.deleteInvite
|
||||
if (typeof origDelete === 'function') {
|
||||
Autopass.prototype.deleteInvite = async function deleteInviteSafe(...args) {
|
||||
try {
|
||||
if (this.opened === false) await this.ready()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
// Only writers can append; skip for read-only invitees
|
||||
if (this.base && this.base.writable === false) return
|
||||
try {
|
||||
return await origDelete.apply(this, args)
|
||||
} catch {
|
||||
// Inviter already deleted the invite — ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix AutopassPairer instance hang: _whenWritable must resolve when already writable.
|
||||
* @param {any} pairer
|
||||
*/
|
||||
function patchPairerInstance(pairer) {
|
||||
if (!pairer || pairer.__peardockPatched) return
|
||||
pairer.__peardockPatched = true
|
||||
|
||||
const origWritable = pairer._whenWritable?.bind(pairer)
|
||||
pairer._whenWritable = function whenWritableFixed() {
|
||||
if (this.pass?.base?.writable) {
|
||||
if (typeof this.onresolve === 'function') this.onresolve(this.pass)
|
||||
return
|
||||
}
|
||||
if (typeof origWritable === 'function') {
|
||||
// Original may still return early without resolve when writable — re-check after
|
||||
try {
|
||||
origWritable()
|
||||
} catch {
|
||||
// fall through to manual wait
|
||||
}
|
||||
}
|
||||
if (this.pass?.base?.writable && typeof this.onresolve === 'function') {
|
||||
this.onresolve(this.pass)
|
||||
return
|
||||
}
|
||||
if (!this.pass?.base || typeof this.onresolve !== 'function') return
|
||||
const base = this.pass.base
|
||||
const check = () => {
|
||||
if (base.writable) {
|
||||
base.off?.('update', check)
|
||||
if (typeof this.onresolve === 'function') this.onresolve(this.pass)
|
||||
}
|
||||
}
|
||||
base.on?.('update', check)
|
||||
// Immediate check in case writable flipped during setup
|
||||
check()
|
||||
}
|
||||
|
||||
// If onadd already ran before finished() set onresolve, recover via pass field
|
||||
const origFinished = pairer.finished?.bind(pairer)
|
||||
pairer.finished = function finishedFixed() {
|
||||
if (this.pass && this.pass.base) {
|
||||
// Pairing already completed before finished() was called
|
||||
if (this.pass.base.writable || this.pass.opened) {
|
||||
return Promise.resolve(this.pass)
|
||||
}
|
||||
}
|
||||
if (typeof origFinished === 'function') return origFinished()
|
||||
return new Promise((resolve, reject) => {
|
||||
this.onresolve = resolve
|
||||
this.onreject = reject
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redeem an AutoPass invite and extract the PearDock connection package.
|
||||
* @param {string} inviteZ32
|
||||
* @param {{ name?: string, timeoutMs?: number }} [opts]
|
||||
* @param {{ name?: string, timeoutMs?: number, onProgress?: (msg: string) => void }} [opts]
|
||||
* @returns {Promise<{ publicKeyHex: string, capability: string, role: string|null, alias: string|null, jti?: string, expiresAt?: string }>}
|
||||
*/
|
||||
export async function redeemAutopassInvite(inviteZ32, opts = {}) {
|
||||
const invite = String(inviteZ32 || '').trim()
|
||||
const invite = String(inviteZ32 || '')
|
||||
.replace(/\s+/g, '')
|
||||
.trim()
|
||||
if (!invite) {
|
||||
const err = new Error('AutoPass invite required')
|
||||
err.code = 'AUTOPASS_PAIR_FAILED'
|
||||
@@ -54,6 +155,12 @@ export async function redeemAutopassInvite(inviteZ32, opts = {}) {
|
||||
throw err
|
||||
}
|
||||
|
||||
const progress = typeof opts.onProgress === 'function' ? opts.onProgress : () => {}
|
||||
const timeoutMs = Math.max(5_000, Number(opts.timeoutMs) || 45_000)
|
||||
|
||||
progress('Loading AutoPass…')
|
||||
await yieldUi()
|
||||
|
||||
let Autopass
|
||||
try {
|
||||
const mod = await import('autopass')
|
||||
@@ -63,33 +170,82 @@ export async function redeemAutopassInvite(inviteZ32, opts = {}) {
|
||||
e.code = 'AUTOPASS_UNAVAILABLE'
|
||||
throw e
|
||||
}
|
||||
patchAutopassHangs(Autopass)
|
||||
await yieldUi()
|
||||
|
||||
const dir = storeDirForInvite(invite)
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
|
||||
}
|
||||
|
||||
const store = new Corestore(path.join(dir, 'store'))
|
||||
const pair = Autopass.pair(store, invite, { name: opts.name || 'peardock-client' })
|
||||
progress('Opening local store…')
|
||||
await yieldUi()
|
||||
|
||||
const store = new Corestore(path.join(dir, 'store'))
|
||||
let pair = null
|
||||
let pass = null
|
||||
let timedOut = false
|
||||
let timer = null
|
||||
|
||||
const timeoutMs = opts.timeoutMs ?? 60_000
|
||||
let pass
|
||||
try {
|
||||
pass = await Promise.race([
|
||||
pair.finished(),
|
||||
new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error(`AutoPass pairing timed out after ${timeoutMs}ms`)), timeoutMs)
|
||||
}),
|
||||
])
|
||||
progress('Pairing with server (HyperDHT)…')
|
||||
await yieldUi()
|
||||
|
||||
pair = Autopass.pair(store, invite, { name: opts.name || 'peardock-client' })
|
||||
patchPairerInstance(pair)
|
||||
|
||||
// Ensure open has started; catch open failures
|
||||
const readyPromise = pair.ready().catch((err) => {
|
||||
const e = new Error(`AutoPass pair open failed: ${err.message || err}`)
|
||||
e.code = 'AUTOPASS_PAIR_FAILED'
|
||||
throw e
|
||||
})
|
||||
|
||||
const finishedPromise = (async () => {
|
||||
await readyPromise
|
||||
// finished() may hang on unpatched autopass; we race a timeout below
|
||||
return pair.finished()
|
||||
})()
|
||||
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
timedOut = true
|
||||
reject(
|
||||
Object.assign(
|
||||
new Error(
|
||||
`AutoPass pairing timed out after ${Math.round(timeoutMs / 1000)}s. ` +
|
||||
'Ensure the peardock server is online and still has this invite active, then try again.'
|
||||
),
|
||||
{ code: 'AUTOPASS_PAIR_FAILED' }
|
||||
)
|
||||
)
|
||||
}, timeoutMs)
|
||||
})
|
||||
|
||||
pass = await Promise.race([finishedPromise, timeoutPromise])
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = null
|
||||
|
||||
if (!pass) {
|
||||
const err = new Error('AutoPass pairing returned no instance')
|
||||
err.code = 'AUTOPASS_PAIR_FAILED'
|
||||
throw err
|
||||
}
|
||||
|
||||
progress('Syncing connection package…')
|
||||
await yieldUi()
|
||||
await pass.ready()
|
||||
|
||||
// Wait briefly for package sync if not yet present
|
||||
// Wait for peardock:pkg (pairing may resolve before view is fully replicated)
|
||||
let record = await pass.get(PKG_KEY)
|
||||
if (!record) {
|
||||
record = await waitForPackage(pass, timeoutMs / 2)
|
||||
if (!record?.value) {
|
||||
progress('Waiting for package from server…')
|
||||
record = await waitForPackage(pass, Math.min(timeoutMs / 2, 30_000))
|
||||
}
|
||||
if (!record?.value) {
|
||||
const err = new Error('AutoPass paired but peardock:pkg package not found')
|
||||
const err = new Error(
|
||||
'AutoPass paired but peardock:pkg was not found. Ask the admin to create a new invite and keep the server online.'
|
||||
)
|
||||
err.code = 'AUTOPASS_PAIR_FAILED'
|
||||
throw err
|
||||
}
|
||||
@@ -116,6 +272,7 @@ export async function redeemAutopassInvite(inviteZ32, opts = {}) {
|
||||
throw err
|
||||
}
|
||||
|
||||
progress('Package received')
|
||||
return {
|
||||
publicKeyHex,
|
||||
capability,
|
||||
@@ -128,8 +285,10 @@ export async function redeemAutopassInvite(inviteZ32, opts = {}) {
|
||||
if (!err.code) err.code = 'AUTOPASS_PAIR_FAILED'
|
||||
throw err
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer)
|
||||
// Always tear down pairer/store so rocksdb/swarm do not pin the process
|
||||
try {
|
||||
await pair.close?.()
|
||||
if (pair) await pair.close?.()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -143,6 +302,9 @@ export async function redeemAutopassInvite(inviteZ32, opts = {}) {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (timedOut) {
|
||||
// best-effort: leave no dangling hyperswarm
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,32 +314,36 @@ export async function redeemAutopassInvite(inviteZ32, opts = {}) {
|
||||
*/
|
||||
function waitForPackage(pass, timeoutMs) {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false
|
||||
const start = Date.now()
|
||||
const cleanup = () => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearInterval(timer)
|
||||
pass.off?.('update', onUpdate)
|
||||
}
|
||||
const finish = (val) => {
|
||||
cleanup()
|
||||
resolve(val)
|
||||
}
|
||||
const check = async () => {
|
||||
if (settled) return
|
||||
try {
|
||||
const rec = await pass.get(PKG_KEY)
|
||||
if (rec?.value) {
|
||||
cleanup()
|
||||
resolve(rec)
|
||||
finish(rec)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// retry
|
||||
}
|
||||
if (Date.now() - start >= timeoutMs) {
|
||||
cleanup()
|
||||
resolve(null)
|
||||
}
|
||||
if (Date.now() - start >= timeoutMs) finish(null)
|
||||
}
|
||||
const onUpdate = () => {
|
||||
check()
|
||||
}
|
||||
const cleanup = () => {
|
||||
clearInterval(timer)
|
||||
pass.off?.('update', onUpdate)
|
||||
}
|
||||
pass.on?.('update', onUpdate)
|
||||
const timer = setInterval(check, 500)
|
||||
const timer = setInterval(check, 400)
|
||||
check()
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user