| … | … | @@ -28,7 +28,6 @@ import Hyperswarm from 'hyperswarm' |
| 28 | 28 | import { createServer } from 'favela-peer/lib/rpc.js' |
| 29 | 29 | import { startControl } from 'favela-peer/lib/control.js' |
| 30 | 30 | import { boot, inProcessClient } from '../lib/host.js' |
| 31 | import { watchProjection } from '../lib/project.js' | |
| 32 | 31 | |
| 33 | 32 | const storage = process.env.FAVELA_STORAGE || join(os.homedir(), '.favela') |
| 34 | 33 | const sock = process.env.FAVELA_SOCK || join(storage, 'favela.sock') |
| … | … | @@ -89,12 +88,8 @@ async function main () { |
| 89 | 88 | createServer(node, sock, { call: (cmd, args) => client.call(cmd, args), onError: () => {} }) |
| 90 | 89 | say('favela-host up', { feed: node.key.toString('hex'), storage, sock, url: surface.url, atproto: peerConfig || 'node storage', version: pkgVersion() }) |
| 91 | 90 | |
| 92 | // favela-bridge's --watch, inside the app: event-driven on the node's own feed. Waits for the atproto | |
| 93 | // half; a node with no identity projects nothing. | |
| 94 | let projection = null | |
| 95 | watchProjection(node, atproto, { log: (line) => say('bsky', { line }) }) | |
| 96 | .then((w) => { projection = w; say(w.watching ? 'bsky projection watching' : 'no atproto identity — nothing to project') }) | |
| 97 | .catch((e) => say('bsky projection did not start', { error: (e && e.message) || String(e) })) | |
| 91 | // favela-bridge's --watch now starts inside bootAtproto (lib/atproto.js), the boot this shares with the | |
| 92 | // Android worklet, which never had it. One watch per node: starting a second here would race the first. | |
| 98 | 93 | |
| 99 | 94 | // Idempotent and BOUNDED: a close that never settles must not keep an OTA from respawning (the |
| 100 | 95 | // supervisor respawns only once we exit). The first caller's exit code wins: 75 for release-ready, |
| … | … | @@ -105,7 +100,7 @@ async function main () { |
| 105 | 100 | if (stopping) return |
| 106 | 101 | stopping = true |
| 107 | 102 | const deadline = setTimeout(() => { say('shutdown: deadline reached, exiting anyway', { code }); process.exit(code) }, 15000) |
| 108 | try { if (projection) projection.stop() } catch {} | |
| 103 | try { if (node.projection) node.projection.stop() } catch {} | |
| 109 | 104 | try { if (control) await control.close() } catch {} |
| 110 | 105 | try { surface.server.close() } catch {} |
| 111 | 106 | try { const hpp = await Promise.race([atproto, new Promise((resolve) => setTimeout(() => resolve(null), 2000))]); if (hpp) await hpp.close() } catch {} |
| … | … | @@ -416,7 +416,7 @@ export default { ATPROTO_OPS, atprotoConfigPath, recoveryVaultPath } |
| 416 | 416 | * ⚠ Returns null rather than throwing when there is no identity. A node with no DID is the normal |
| 417 | 417 | * state, not a failure, and a read-only node that boots beats a node that does not. |
| 418 | 418 | */ |
| 419 | export async function bootAtproto (node, { log = () => {}, configFile = null, storage = null } = {}) { | |
| 419 | export async function bootAtproto (node, { log = () => {}, configFile = null, storage = null, project = null } = {}) { | |
| 420 | 420 | // `configFile` + `storage`: hpp's EXISTING config and data, when this machine already has them (the |
| 421 | 421 | // desktop app on a server). Absent — the handset, a fresh desktop — both live under node storage. |
| 422 | 422 | if (configFile) node.atprotoConfigFile = configFile |
| … | … | @@ -464,5 +464,15 @@ export async function bootAtproto (node, { log = () => {}, configFile = null, st |
| 464 | 464 | // Attaching it here rather than in each caller's boot means the desktop host and the Android |
| 465 | 465 | // worklet both get it without either knowing it happened. |
| 466 | 466 | node.atproto = hpp |
| 467 | // ⛔ AND THE PROJECTION WATCH, for the same reason. It was started by the desktop entry alone, so the | |
| 468 | // Android worklet — which boots through here too — never projected a post on its own: a post reached | |
| 469 | // bsky only when someone pressed publish in Settings (2026-10-14). Started here, neither shell can be | |
| 470 | // without it, and there is exactly one per node (two would race to post the same record). | |
| 471 | // `project` is injectable for tests; the watch itself logs every refusal and failure. | |
| 472 | if (up > 0) { | |
| 473 | const { watchProjection } = await import('./project.js') | |
| 474 | node.projection = await watchProjection(node, hpp, { log: (line) => log('bsky', { line }), ...(project ? { project } : {}) }) | |
| 475 | log(node.projection.watching ? 'bsky projection watching' : 'bsky projection not watching') | |
| 476 | } | |
| 467 | 477 | return hpp |
| 468 | 478 | } |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-host", |
| 3 | "version": "0.1.15", | |
| 3 | "version": "0.1.16", | |
| 4 | 4 | "description": "THE favela app on a desktop, home node or server: a favela-peer node, favela-surface rendering it, the atproto signer and the bsky projection, in one process.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| … | … | @@ -32,7 +32,10 @@ function fixture (t, name) { |
| 32 | 32 | process.env.PEER_CONFIG = realCfg || '' |
| 33 | 33 | process.env.ATPROTO_OVERLAY_ROOT = realOverlay || '' |
| 34 | 34 | }) |
| 35 | return { storage, node: { storage }, client: inProcessClient({ storage }) } | |
| 35 | const node = { storage } | |
| 36 | // A booted node watches for posts to project; a test must not leave that running past its hpp. | |
| 37 | t.teardown(() => { try { if (node.projection) node.projection.stop() } catch {} }) | |
| 38 | return { storage, node, client: inProcessClient(node) } | |
| 36 | 39 | } |
| 37 | 40 | |
| 38 | 41 | test('a node with no identity boots no atproto, and says so by returning null', async (t) => { |
| … | … | @@ -99,3 +102,24 @@ test('given hpp\'s own config and storage, it boots THOSE and writes nothing und |
| 99 | 102 | t.absent(fs.existsSync(join(node.storage, 'atproto', 'statebase')), 'no second store under node storage') |
| 100 | 103 | t.is((await client.call('did-status')).did, r.did, 'did-status reads the same config') |
| 101 | 104 | }) |
| 105 | ||
| 106 | // ⛔ THE PHONE NEVER PROJECTED A POST ON ITS OWN. The desktop entry started watchProjection after boot; | |
| 107 | // the Android worklet boots through bootAtproto too and never did, so a post from the phone reached bsky | |
| 108 | // only when someone pressed publish in Settings (2026-10-14). The watch now starts HERE, the one boot | |
| 109 | // both shells share, so neither can be without it. | |
| 110 | test('booting the identity starts the bsky projection watch — one pass at boot', async (t) => { | |
| 111 | const { node, client } = fixture(t, 'watch') | |
| 112 | await client.call('did-mint', { passphrase: PASS }) | |
| 113 | const runs = [] | |
| 114 | const hpp = await bootAtproto(node, { project: async (n, h, opts) => { runs.push(opts.send) } }) | |
| 115 | t.teardown(async () => { try { await hpp.close() } catch {} }) | |
| 116 | t.ok(node.projection && node.projection.watching, 'the node is watching for posts to project') | |
| 117 | await new Promise((resolve) => setTimeout(resolve, 50)) | |
| 118 | t.alike(runs, [true], 'and ran one SENDING pass at boot') | |
| 119 | }) | |
| 120 | ||
| 121 | test('a node with no identity starts no watch', async (t) => { | |
| 122 | const { node } = fixture(t, 'nowatch') | |
| 123 | await bootAtproto(node) | |
| 124 | t.absent(node.projection, 'nothing to project, nothing watching') | |
| 125 | }) |