/** * App Store ↔ Pear link resolution, VFS materialization, and in-guest launch. */ function appstorePearJoinPath(...parts) { const raw = parts .filter((p) => p != null && String(p) !== '') .map((p) => String(p).replace(/\\/g, '/')) .join('/') .replace(/\/+/g, '/') const absolute = raw.startsWith('/') const segs = [] for (const seg of raw.split('/')) { if (!seg || seg === '.') continue if (seg === '..') { if (segs.length) segs.pop() continue } segs.push(seg) } const out = segs.join('/') return absolute ? '/' + out.replace(/^\//, '') : out } function appstorePearDirname(p) { const s = String(p || '').replace(/\\/g, '/') const i = s.lastIndexOf('/') if (i <= 0) return s.startsWith('/') ? '/' : '.' return s.slice(0, i) || '/' } function appstoreParsePearLink(link) { const raw = String(link || '').trim() if (!raw.startsWith('pear://')) { throw new Error('invalid pear:// link') } let rest = raw.slice('pear://'.length).split('/')[0] let length = null if (rest.startsWith('0.')) { const dot = rest.indexOf('.', 2) if (dot > 2) { const n = Number(rest.slice(2, dot)) if (Number.isFinite(n) && n >= 0) length = Math.floor(n) rest = rest.slice(dot + 1) } } const keyZ32 = rest.trim() if (!keyZ32) throw new Error('invalid pear:// link (missing key)') return { keyZ32, length, pearLink: raw } } function appstoreFindHdmsMountByKey(ctx, keyZ32) { const hdms = ctx.disk && ctx.disk.hdmsController if (!hdms || !hdms.active || !keyZ32) return null for (const [label, slot] of hdms.byLabel) { const k = slot?.entry?.key if (k && String(k) === String(keyZ32)) { return { label, mountRoot: appstorePearJoinPath('/mnt', label), drive: slot.drive, local: true } } } for (const entry of hdms.registry?.drives || []) { if (!entry || String(entry.key || '') !== String(keyZ32)) continue const slot = hdms.byLabel.get(entry.label) if (slot?.drive) { return { label: entry.label, mountRoot: appstorePearJoinPath('/mnt', entry.label), drive: slot.drive, local: true } } } return null } function appstoreEphemeralFetchLabel(keyZ32) { const slug = String(keyZ32 || 'fetch') .replace(/[^a-zA-Z0-9]/g, '') .slice(0, 48) let label = 'as-ro-' + (slug || 'fetch') if (label.length > 63) label = label.slice(0, 63) if (!/^[a-zA-Z0-9]/.test(label)) label = 'as-ro-' + label.replace(/^[^a-zA-Z0-9]+/, '') if (label.length > 63) label = label.slice(0, 63) return label } async function appstoreSleep(ms) { await new Promise((resolve) => setTimeout(resolve, ms)) } async function appstoreVfsExists(vfs, path) { try { if (typeof vfs.stat === 'function') { await vfs.stat(path) return true } await vfs.readFile(path) return true } catch { return false } } async function appstoreCollectFiles(vfs, root) { /** @type {string[]} */ const out = [] /** @type {string[]} */ const queue = [root] while (queue.length) { const dir = queue.shift() if (!vfs || typeof vfs.readdir !== 'function') continue let names try { names = await vfs.readdir(dir) } catch { continue } if (!Array.isArray(names)) continue for (const name of names) { if (!name || name === '.' || name === '..') continue if (name === '.bareos_empty') continue const abs = appstorePearJoinPath(dir, name) let st try { st = typeof vfs.stat === 'function' ? await vfs.stat(abs) : null } catch { st = null } const isDir = st && (st.isDirectory === true || st.type === 'directory') if (isDir) queue.push(abs) else out.push(abs) } } return out } async function appstoreMirrorVfsDir(vfs, srcDir, dstDir) { const files = await appstoreCollectFiles(vfs, srcDir) let copied = 0 for (const src of files) { const rel = src.slice(srcDir.length).replace(/^\//, '') const dst = appstorePearJoinPath(dstDir, rel) if (typeof vfs.mkdir === 'function') { await vfs.mkdir(appstorePearDirname(dst), { recursive: true }).catch(() => {}) } const buf = await vfs.readFile(src) await vfs.writeFile(dst, buf) copied++ } return copied } async function appstoreWaitForTree(vfs, root, timeoutMs = 12000) { const deadline = Date.now() + timeoutMs while (Date.now() < deadline) { if (await appstoreVfsExists(vfs, appstorePearJoinPath(root, 'package.json'))) return true if (await appstoreVfsExists(vfs, appstorePearJoinPath(root, 'sources/index.js'))) return true if (await appstoreVfsExists(vfs, appstorePearJoinPath(root, 'app.bundle.js'))) return true await appstoreSleep(300) } return false } /** * Open a readonly HDMS mount for a pear key (ephemeral) when not already local. * @param {Record} ctx * @param {string} keyZ32 */ async function appstoreOpenPearMount(ctx, keyZ32) { const local = appstoreFindHdmsMountByKey(ctx, keyZ32) if (local) return local const hdms = ctx.disk && ctx.disk.hdmsController if (!hdms || !hdms.active) { throw new Error( 'appstore fetch: log in (unlock identity) so HDMS can mount the pear:// drive' ) } if (ctx.identity?.state !== 'unlocked') { throw new Error('appstore fetch: identity must be unlocked (use login)') } let label = appstoreEphemeralFetchLabel(keyZ32) if (hdms.byLabel.has(label)) { for (let n = 2; n <= 99; n++) { const candidate = (label.slice(0, 58) + '-' + n).slice(0, 63) if (!hdms.byLabel.has(candidate)) { label = candidate break } } } await hdms.addReadonly(ctx, label, keyZ32, { persist: false }) const mountRoot = appstorePearJoinPath('/mnt', label) const swarm = hdms.swarm if (swarm && typeof swarm.flush === 'function') { await Promise.race([swarm.flush().catch(() => {}), appstoreSleep(8000)]) } const ready = await appstoreWaitForTree(ctx.vfs, mountRoot) if (!ready) { throw new Error( `appstore fetch: timed out waiting for content on ${mountRoot} (is pear seed replicating?)` ) } return { label, mountRoot, ephemeral: true, local: false } } /** * Copy pear release tree into an appstore package directory. * @param {Record} ctx * @param {string} pearLink * @param {string} pkgDir */ async function appstoreFetchPearTree(ctx, pearLink, pkgDir) { const vfs = ctx.vfs if (!vfs || typeof vfs.readFile !== 'function' || typeof vfs.writeFile !== 'function') { throw new Error('appstore fetch: ctx.vfs unavailable') } const parsed = appstoreParsePearLink(pearLink) const mount = await appstoreOpenPearMount(ctx, parsed.keyZ32) if (typeof vfs.mkdir === 'function') { await vfs.mkdir(pkgDir, { recursive: true }).catch(() => {}) } const fileCount = await appstoreMirrorVfsDir(vfs, mount.mountRoot, pkgDir) if (fileCount <= 0) { throw new Error(`appstore fetch: no files copied from ${mount.mountRoot}`) } /** @type {Record} */ let pkgJson = null try { const buf = await vfs.readFile(appstorePearJoinPath(pkgDir, 'package.json')) if (buf && buf.byteLength) { pkgJson = JSON.parse(ctx.b4a.toString(buf, 'utf8')) } } catch { /* ignore */ } return { ok: true, fileCount, method: mount.local ? 'hdms-local-mount' : 'hdms-readonly-fetch', mountLabel: mount.label, mountRoot: mount.mountRoot, keyZ32: parsed.keyZ32, length: parsed.length, packageJson: pkgJson } } async function appstoreReadUtf8(vfs, b4a, path) { const buf = await vfs.readFile(path) if (!buf || !buf.byteLength) return null if (b4a && typeof b4a.toString === 'function') return b4a.toString(buf, 'utf8') return new TextDecoder().decode(buf instanceof Uint8Array ? buf : new Uint8Array(buf)) } /** * Resolve runnable entry script under a materialized package dir. * @param {Record} ctx * @param {string} pkgDir */ async function appstoreResolvePackageEntry(ctx, pkgDir) { const vfs = ctx.vfs const b4a = ctx.b4a let main = 'index.js' try { const raw = await appstoreReadUtf8(vfs, b4a, appstorePearJoinPath(pkgDir, 'package.json')) if (raw) { const pkg = JSON.parse(raw) if (pkg && pkg.main) main = String(pkg.main).replace(/^\.\//, '') } } catch { /* ignore */ } const candidates = [ appstorePearJoinPath(pkgDir, 'sources', main), appstorePearJoinPath(pkgDir, main), appstorePearJoinPath(pkgDir, 'sources/index.js'), appstorePearJoinPath(pkgDir, 'index.js') ] for (const p of candidates) { if (await appstoreVfsExists(vfs, p)) return p } return null } /** * Execute a plain JS entry (console.log apps) inside the guest shell. * @param {Record} ctx * @param {string} scriptPath */ async function appstoreExecGuestScript(ctx, scriptPath) { const vfs = ctx.vfs const b4a = ctx.b4a const consoleRef = ctx.console if (!consoleRef || typeof consoleRef.log !== 'function') { throw new Error('appstore launch: ctx.console unavailable') } let src = await appstoreReadUtf8(vfs, b4a, scriptPath) if (!src) throw new Error('appstore launch: empty script: ' + scriptPath) src = src.replace(/^\uFEFF/, '').replace(/^#![^\n]*\n/, '') const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor const fn = new AsyncFunction('console', src) await fn(consoleRef) } /** * Launch a materialized Pear app package in-guest. * @param {Record} ctx * @param {string} pkgDir * @param {{ refetch?: boolean, pearLink?: string }} [opts] */ async function appstoreLaunchPackage(ctx, pkgDir, opts = {}) { const vfs = ctx.vfs if (!vfs) throw new Error('appstore launch: ctx.vfs unavailable') let entry = await appstoreResolvePackageEntry(ctx, pkgDir) if ((!entry || opts.refetch) && opts.pearLink) { await appstoreFetchPearTree(ctx, opts.pearLink, pkgDir) entry = await appstoreResolvePackageEntry(ctx, pkgDir) } if (!entry) { throw new Error( `appstore launch: no entry script under ${pkgDir} (expected sources/index.js or package.json main)` ) } await appstoreExecGuestScript(ctx, entry) return { ok: true, entry, pkgDir } }