Files
bare-operating-system/packages/bare-os-coreutils/lib/pear/appstore-pear.js
T
2026-08-18 18:11:28 -04:00

344 lines
10 KiB
JavaScript

/**
* 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<string, unknown>} 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<string, unknown>} 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<string, unknown>} */
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<string, unknown>} 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<string, unknown>} 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<string, unknown>} 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 }
}