// Bare worker entrypoint — Mirall's data-layer process (see .claude/solution-architecture.md
// for the process model and glossary). This file runs once, top to bottom, and the boot order is
// load-bearing: crash backstop + pipe-close shutdown hooks → bootstrap config → identity
// unlock (master secret M, via the KEK from Electron main) → Corestore + bees → one-time
// migrations → swarm + subsystem wiring (every connection hook attaches before the swarm
// accepts sockets) → resume of owned/foreign folder mounts → registration of the
// renderer-facing IPC command handlers (named `domain:verb`, grouped by the `// === … ===`
// section markers below) → ipc.start(), which replays frames queued during boot, then ready.
import os from 'bare-os'
import fs from 'bare-fs'
import b4a from 'b4a'
import crypto from 'hypercore-crypto'
import { createIPC, getBootstrapPromise } from '../shared/core/ipc.js'
import { setRuntimeConfig, getRuntimeConfig, setDownloadFolder, getDeepReconcileEvery, isHandshakeIdentityBindingEnabled, isOverlayEnabled, isInPlaceFilesEnabled, isSharePrepareProgressEnabled, isSeparateContentPlaneEnabled, getListFilesCap } from '../shared/core/runtime-config.js'
import { getDownloadDir } from '../shared/core/paths.js'
import { createLogger } from '../shared/core/logger.js'
import { installCrashBackstop } from '../shared/core/crash-backstop.js'
import { initStore, getStore, setMasterSecret } from '../shared/core/store.js'
import { migrateLocalBeesToEncrypted } from '../shared/storage/metadata-migration.js'
import { resolveMasterSecret } from '../shared/core/identity-resolve.js'
import { osKeychainProvider } from '../shared/core/unlock-providers.js'
import { getContentBackend, UNSUPPORTED, initBackends, teardownBackends, fanoutAttach, sweepBackends } from '../shared/transfer/content-backends.js'
import {
initProfile, getProfile, setProfile,
ensureMembershipManifestCap,
markOwnMembership, clearOwnMembership, getLocalPublicKeyHex,
readProfileRecord, markRequest, markRequestDenied,
captureJoinerMembership, getIdentitySigner,
markInvite, sweepExpiredInvites,
} from '../shared/spaces/profile.js'
import {
initSpaces, createSpace, joinSpace, listSpaces,
getSpace, loadDrives, purgeSpace, purgeSpaceDrive, forgetSpaceRecord, updateSpace,
markSpaceLeavingDurable, resumeInterruptedLeave,
toggleFavorite, upsertMember,
getSpaceContentKey, recordJoinRequest, listJoinRequests, listPendingRequests, clearJoinRequest,
recordApproval, materializeOwnDrive,
backfillSelfCreatedCreatorKey, pinCreatorKey, markCreatorDivergence, clearCreatorDivergence, flagUnverifiedJoinedCreators,
persistPendingLeave, clearPendingLeave, listPendingLeaves,
} from '../shared/spaces/space.js'
import { initSpaceKeys } from '../shared/spaces/space-keys.js'
import { classifyInvite } from '../shared/spaces/invite-policy.js'
import { encodeInvite, decodeInvite } from '../shared/invite-envelope.js'
import { initSwarm, joinSpaceTopic, leaveSpaceTopic, cleanupSpaceDrives, compactStore, destroySwarm, broadcastDeparture, getConnectedPeers, isOwnerOnline, broadcastProfileUpdate, sendLeaveFrameToConnectedPeers, awaitLeaveAcks, getSwarmStatus, reconnectAll, setMembershipControlHandler, setConnectionAttachHook, getSwarmDht, setOverlayReconnectHook, setRevokeServesForSpaceHook, setStalledOwnersHook, rescueStalledTransfers, sendMembershipGrant, sendMembershipDeny, broadcastMembershipCancel, reconcilePendingRequester, isApprovedMember, resolveInvite, markSpaceLeaving, unmarkSpaceLeaving, isSpaceLeaving, getBoundSignerKey, broadcastSharePrepareProgress, configurePendingLeaves, registerPendingLeave, unregisterPendingLeave, joinPendingLeaveTopic, leavePendingLeaveTopic, hasPendingLeave, takeLeaveAckedKeys, configurePendingCancels, registerPendingCancel, joinPendingCancelTopic, leavePendingCancelTopic, hasPendingCancel, sendPendingCancelToConnected } from '../shared/transfer/swarm.js'
import { initContentSwarm, destroyContentSwarm, setContentAttachHook, setContentResumeHook } from '../shared/transfer/content-swarm.js'
import { clampDisplayName, checkGrantAssertion } from '../shared/transfer/handshake-guard.js'
import { openSealedSck } from '../shared/transfer/sck-seal.js'
import { reconcileAssertedRoot } from '../shared/spaces/creator-root.js'
import { getConnectedMemberMeta, readmitConnectedMembers } from '../shared/transfer/swarm.js'
import {
configureMemberRegistry, openMemberView, closeMemberView, openMemberViewsForKnownSpaces, closeAllMemberViews, dropTombstone, isLeft,
isApprovedJoiner, isDeniedJoiner, setMembershipRevokedHook,
} from '../shared/spaces/member-registry.js'
import { reconnectGrantAllowed } from '../shared/spaces/member-set.js'
import { ownCatalogPublish, purgeOwnCatalog, catalogKeyField } from '../shared/shares/share-catalog.js'
import { initDownloads, listFiles, removeFile, revealFile, cleanupDownloadHistory, addFile, isDownloadedFile, getDownloadedPath, revealLocalPath, getVerifiedHash, isVerifiedDownload } from '../shared/transfer/files.js'
import { initLooseOverlay, looseDownload, loosePause, looseCancel, looseCancelSpace, looseCancelTransfer, looseCancelPublish, resumeLooseForOwner, handleLooseFsEvent, rehydrateLooseFiles, sweepLoosePresence } from '../shared/transfer/loose-overlay.js'
import { overlayPause, overlayCancel, overlayCancelByKey, overlayCancelSpace, resumeOverlayForOwner, overlayHasTransfer, setSharePrepareBroadcast, subscribeServeDetail, unsubscribeServeDetail, listServeSummaries, abortInFlightPublishes } from '../shared/transfer/backends/overlay/overlay-backend.js'
import { getJournalDir, revokeServesForSpace, bumpServeEpoch } from '../shared/transfer/backends/overlay/overlay-instance.js'
import { cleanupOrphanedJournals } from '../shared/transfer/backends/overlay/vendor/transfer.js'
import { cleanupOrphanedPartials } from '../shared/transfer/partial-sweep.js'
import { pausedStatusFor, unhashedStatusFor } from '../shared/transfer/transfer-status.js'
import { transferIdFor, isLooseTransferId } from '../shared/transfer/transfer-id.js'
import { makeKeyedCoalescer } from '../shared/state/coalesce.js'
import { initPendingTransfers, clearPendingForSpace, listPendingForSpace, listPendingOwnerKeys } from '../shared/transfer/pending-transfers.js'
import { getStorageInfo, cleanupOrphanedData, getSpaceCacheBytes, freeSpace } from '../shared/storage/storage.js'
import { spaceStorageSummary } from '../shared/storage/space-storage.js'
import { reclaimLegacyPeerCaches } from '../shared/storage/legacy-peer-cache.js'
import { classifyLeftovers, forgetUnreferencedPeerCores } from '../shared/storage/leftover.js'
import { sendFeedback } from '../shared/telemetry/feedback.js'
import { publishShare, tombstoneShare, readOwnShares, isValidShareName, generateShareId, ensureSharesCap } from '../shared/shares/shares.js'
import { migrateLegacyOwnedSharesToOverlay } from '../shared/shares/migrate-content-mode.js'
import { migrateCatalogsToEncrypted } from '../shared/shares/migrate-catalog-encrypt.js'
import { migrateOverlayIndexToEncrypted } from '../shared/transfer/backends/overlay/migrate-overlay-index-encrypt.js'
import { listSharesForSpace } from '../shared/shares/share-registry.js'
import { ensureFolderMirrorsCap, publishMirror, ensureMirror } from '../shared/folders/mirror-records.js'
import { listMirrorsForShare, listMirrorsForSpace } from '../shared/folders/mirror-registry.js'
import { AppError, ErrorCodes } from '../shared/core/errors.js'
import { initMounts, saveOwnedMount, getOwnedMount, deleteOwnedMount, listOwnedMounts, listAllMounts, setOwnedMountStatus } from '../shared/folders/mount-store.js'
import { validateMountPath, validateDownloadFolder } from '../shared/folders/mount-validate.js'
import { relKeyEscapes } from '../shared/folders/path-keys.js'
import {
initOwnedFolders, handleFsEventFromMain, onFsEvent, initialPublishScan,
previewInitialPublishScan, periodicReconcile, stopOwnedFolder, DEFAULT_IGNORE,
mountRootAvailable, countFolderFiles,
} from '../shared/folders/owned-folders.js'
import { exceedsShareFileLimit, shareFileLimitMessage, listingTruncated } from '../shared/folders/share-limits.js'
import {
initForeignFolders, initialMaterializeScan, previewMaterializeScan,
startForeignLoop, stopForeignLoop, setForeignEnabled, unmountForeignFolder,
foreignFetchActive, resumeAutoPausedForeignMount, autoPauseForeignMountGone,
} from '../shared/folders/foreign-folders.js'
import { saveForeignMount as persistForeignMount, getForeignMount, listForeignMounts } from '../shared/folders/mount-store.js'
const ipc = createIPC(Bare.IPC)
const log = createLogger('worklet')
// === Crash safety & shutdown ===
// Install the crash backstop FIRST — before any core-opening init below — so that NO
// boot-time data-layer error can abort the worker and take the whole data layer down with it
// (the user-visible symptom would be "the app won't start"). The hazard it guards against is a
// fire-and-forget rejection from a background core open — e.g. corestore opening a
// derived/announced core by discovery key that isn't stored locally → STORAGE_EMPTY — which
// Bare's default unhandled-rejection handler would turn into a worker abort. With the backstop
// up front it is logged and boot continues. (A genuinely fatal *awaited* init can still leave
// boot incomplete, but the worker stays alive and logs loudly instead of vanishing — strictly
// better than a silent abort.)
installCrashBackstop(log)
let shuttingDown = false
async function safeShutdown(reason) {
if (shuttingDown) return
shuttingDown = true
log.warn('shutdown:', reason)
// Hard deadline: a hung swarm/store teardown must never keep the worker alive.
// (This covers the "stuck on an await" case; if the event loop is starved by a
// busy loop the timer can't fire either — the parent's SIGKILL backstop is what
// reaps that case.)
const deadline = setTimeout(() => { try { Bare.exit(0) } catch {} }, 4000)
deadline.unref?.()
// Announce departure to connected peers FIRST (best-effort) and abort in-flight hashing so a
// quit-mid-index worker unwinds and teardown wins the parent's SIGTERM/SIGKILL race; then a
// short flush window so the departure datagram leaves UDX before destroySwarm drops the socket.
try { broadcastDeparture() } catch {}
try { abortInFlightPublishes() } catch {}
try { await new Promise((resolve) => { const to = setTimeout(resolve, 150); to.unref?.() }) } catch {}
try { closeAllMemberViews() } catch {}
try { await teardownBackends() } catch {}
try { await destroyContentSwarm() } catch {}
try { await destroySwarm() } catch {}
log.info('shutdown complete')
Bare.exit(0)
}
// Register the pipe-close teardown BEFORE the bootstrap await. If the parent dies
// during startup (before sending the bootstrap line), the IPC pipe closes while
// we're parked on getBootstrapPromise; without these handlers in place the
// worker would sit at that await forever as an idle orphan. safeShutdown's
// teardown steps all no-op safely when called before init.
Bare.IPC.on('end', () => { safeShutdown('ipc-end') })
Bare.IPC.on('close', () => { safeShutdown('ipc-close') })
Bare.IPC.on('error', (err) => { safeShutdown('ipc-error: ' + (err && err.message ? err.message : err)) })
// === Boot: config, identity unlock, store, migrations ===
const bootstrap = await getBootstrapPromise()
setRuntimeConfig(bootstrap)
log.info('starting...')
initStore(bootstrap.storage)
// Unlock identity: resolve the master secret M from identity.enc — Electron main supplies
// only the KEK (key-encryption key), never M itself — and open identity cores from keypairs
// derived from M. Without a KEK (MIRALL_INSECURE_IDENTITY / headless tests) the worker
// falls back to plaintext seed derivation.
if (bootstrap.identityKEK) {
const provider = osKeychainProvider(bootstrap.identityKEK)
setMasterSecret(await resolveMasterSecret({ store: getStore(), storagePath: bootstrap.storage, provider }))
}
const didMigrateMetadata = await migrateLocalBeesToEncrypted()
await initSpaceKeys()
await initProfile()
await initSpaces()
await initDownloads()
await initPendingTransfers()
const driveLoad = await loadDrives()
if (driveLoad.hadFailure) {
try { await cleanupOrphanedData() } catch (err) {
log.warn('orphan cleanup after drive-load failure failed:', err.message)
}
}
await ensureMembershipManifestCap()
await ensureSharesCap()
await ensureFolderMirrorsCap()
// One-time migration: move owned folder shares recorded with a retired contentMode
// (undefined / 'eager' / 'deferred') to overlay BEFORE the initial publish scans below, so
// such a share re-advertises into the catalog instead of resolving to UNSUPPORTED (empty
// listing, publishing stops).
try { await migrateLegacyOwnedSharesToOverlay() } catch (err) {
log.warn('legacy content-mode migration failed:', err.message)
}
// One-time migration: encrypt existing plaintext v2 catalogs with the SCK (space content key —
// holding it is what grants read access to a space) BEFORE the initial publish scans below, so
// the re-advertise repopulates the new encrypted core and the old plaintext core is purged first.
try { await migrateCatalogsToEncrypted() } catch (err) {
log.warn('catalog SCK-encrypt migration failed:', err.message)
}
// One-time migration: copy the overlay's plaintext local index into an M-encrypted core
// generation and purge the plaintext one, BEFORE initBackends opens the overlay so it opens
// the encrypted generation directly.
let didMigrateOverlayIndex = false
try { didMigrateOverlayIndex = (await migrateOverlayIndexToEncrypted())?.migrated === true } catch (err) {
log.warn('overlay-index at-rest migration failed:', err.message)
}
await initMounts()
initOwnedFolders(ipc)
initForeignFolders(ipc)
const knownSpaces = await listSpaces()
// Finish any leave a prior process interrupted (durable `leaving` marker still present) BEFORE
// the membership backfill — otherwise markOwnMembership below re-asserts active:true and silently
// resurrects the space, undoing the user's leave (and diverging from co-members who already
// revoked). Runs pre-swarm: the completion is purely durable-local; co-members converge on the
// member del via replication, like any offline leave.
for (const space of knownSpaces) {
if (!space.leaving) continue
// The interrupted teardown may have died BEFORE its leave frame reached anyone — arm the
// pending-leave replay (the live teardown's own machinery) so the departure still gets
// announced once a co-member connects. Armed before the resume deletes the record: the
// replay setup below skips markers whose record still exists, so a failed resume merely
// retires this marker until the next boot re-arms it. Solo spaces skip (nobody to tell).
if (space.topic && (space.members || []).length) {
try { await persistPendingLeave(space.spaceId, space.topic, Date.now()) } catch (err) {
log.warn('interrupted-leave replay arm failed:', space.spaceId, '-', err.message)
}
}
try {
await resumeInterruptedLeave(space.spaceId)
// The live teardown also purges these spaceId-keyed rows; nothing else ever reclaims them
// (the sweeps cover cores, not bee rows). Best-effort — the leave itself is already durable.
try {
await cleanupDownloadHistory(space.spaceId)
await clearPendingForSpace(space.spaceId)
} catch (err) {
log.warn('interrupted-leave transfer-row cleanup failed:', space.spaceId, '-', err.message)
}
log.info('completed interrupted leave at boot:', space.spaceId)
} catch (err) {
log.warn('resume interrupted leave failed:', space.spaceId, '-', err.message)
}
}
const activeSpaces = knownSpaces.filter((s) => !s.leaving)
for (const space of activeSpaces) {
try { await markOwnMembership(space.spaceId) } catch (err) {
log.warn('manifest backfill failed for space', space.spaceId, '-', err.message)
}
}
// One-time, idempotent backfill: stamp the member-set root (creatorKey) on self-created
// spaces whose records predate the field. The OR-Set membership fold (conflict-free
// add/remove roster) seeds its root of trust from it.
try { await backfillSelfCreatedCreatorKey() } catch (err) {
log.warn('creatorKey backfill failed:', err.message)
}
// One-time migration: flag joined spaces whose creatorKey was TOFU-pinned (trust on first
// use) from a bearer invite as unverified, so the handshake cross-check re-authenticates
// the member-set root on the next connection.
try { await flagUnverifiedJoinedCreators() } catch (err) {
log.warn('creatorKey migration failed:', err.message)
}
// wire backends + every connection handler/hook BEFORE the swarm starts
// accepting connections, so no inbound connection lands without the overlay
// channel attached, the content/membership handlers set, or the overlay instance
// created (a connection in that window would silently never get an overlay channel).
await initBackends(ipc) // overlay instance + IPC ref when the flag is on
initLooseOverlay(ipc)
if (isInPlaceFilesEnabled()) rehydrateLooseFiles().catch((err) => log.debug('loose rehydrate failed:', err.message))
const useContentPlane = isOverlayEnabled() && isSeparateContentPlaneEnabled()
if (isOverlayEnabled()) {
// Auto-resume overlay downloads (loose + folder) when their owner (re)connects.
const autoResume = (ownerKey, spaceId) => {
if (isInPlaceFilesEnabled()) resumeLooseForOwner(ownerKey, spaceId).catch((err) => log.debug('loose auto-resume failed:', err.message))
resumeOverlayForOwner(ownerKey, spaceId).catch((err) => log.debug('overlay folder auto-resume failed:', err.message))
}
// BOTH planes drive the resume. The control handshake marks the owner's presence lease
// synchronously before firing this hook, so a resume it triggers can never observe the stale
// "owner offline" that start()'s gate reads — whereas a content-plane hello routinely lands
// while the control socket is still re-handshaking, and its resume is dropped. With only the
// content hook installed, out-of-phase flapping starves the download of every trigger it has.
setOverlayReconnectHook(autoResume)
if (useContentPlane) {
// The content plane authenticates per owner with no space, so fan the resume across our
// spaces — coalesced per owner so reconnect churn doesn't re-run listSpaces() each time.
// Still needed alongside the control hook: a content-only flap re-runs no handshake.
const resumePending = new Map()
setContentResumeHook((ownerKey) => {
if (resumePending.has(ownerKey)) return
const timer = setTimeout(() => {
resumePending.delete(ownerKey)
listSpaces()
.then((spaces) => { for (const s of spaces) if (!s.leaving) autoResume(ownerKey, s.spaceId) })
.catch((err) => log.debug('content-hello resume fan-out failed:', err.message))
}, 250)
timer.unref?.()
resumePending.set(ownerKey, timer)
})
}
}
setMembershipControlHandler(handleMembershipControl)
if (useContentPlane) setContentAttachHook(fanoutAttach) // overlay binds its channel on content connections
else setConnectionAttachHook(fanoutAttach) // lets overlay bind its channel per connection
// Lets the convergence tick see a download whose owner has dropped off either plane.
setStalledOwnersHook(listPendingOwnerKeys)
setSharePrepareBroadcast((spaceId, p) => { if (isSharePrepareProgressEnabled()) broadcastSharePrepareProgress(spaceId, p) })
// A peer left a space we are still in: stop serving THAT PEER the space's bytes. Scoped to the
// leaver — a space-wide revoke here would also cut off every other member still legitimately
// downloading from us. The epoch bump then re-checks the rest against the live membership gate.
setRevokeServesForSpaceHook((spaceId, profileKey) => { revokeServesForSpace(spaceId, profileKey); bumpServeEpoch() })
// The same revocation, learned through replication (the observed-leave fold) instead of a direct
// frame. An epoch bump alone is inert here (the fold does not remove the member from the roster,
// so the serve gate would re-approve), so ACTIVELY drop the leaver's grants for the space.
setMembershipRevokedHook((spaceId, profileKey) => { revokeServesForSpace(spaceId, profileKey); bumpServeEpoch() })
initSwarm(ipc)
if (useContentPlane) initContentSwarm(getSwarmDht())
// Deferred past the core-opening init above so the one-time compaction (which
// scrubs the migrated plaintext from old SSTs) doesn't contend with boot I/O.
if (didMigrateMetadata || didMigrateOverlayIndex) {
compactStore().catch((err) => log.warn('post-migration compaction failed:', err.message))
}
// === Membership control ===
// One poke per followed member per share-record append — K raw frames while K records
// replicate, each driving a 3-IPC useShares refresh incl. per-member network head-pulls.
// Coalesce per space at the source; files-updated is additionally coalesced downstream
// into event:reconcile by the hint bus.
const sharesPoke = makeKeyedCoalescer((spaceId) => {
ipc.emit('event:shares-updated', { spaceId })
ipc.emit('event:files-updated', { spaceId })
}, { intervalMs: 250 })
// Durable (Tier-1) membership: derive each space's member set from replicated records and
// write it into space.members. Swarm metadata + the IPC emitter are injected so the registry
// needs no swarm import. (Runs alongside the handshake/gossip path under a conservative
// contract: it only ever agrees-or-adds, and removes a member only once the replicated
// evidence and the live connection state agree — a live handshake always outranks stale
// records.)
configureMemberRegistry({
metaFor: (spaceId, key) => getConnectedMemberMeta(spaceId, key),
isConnected: (spaceId, key) => !!getConnectedMemberMeta(spaceId, key),
profileFor: (spaceId, key) => readProfileRecord(key, spaceId),
readmitConnected: (spaceId, keys) => readmitConnectedMembers(spaceId, keys),
// A change in the derived member set changes whose files + shares we surface (both lists
// read peer content keyed on space.members), so refresh all three renderer views — not
// just the member list. On a removal these events are the only signal that drops the gone
// member's content from the file/share views.
emitMembersUpdated: (spaceId) => {
ipc.emit('event:members-updated', { spaceId })
ipc.emit('event:shares-updated', { spaceId })
ipc.emit('event:files-updated', { spaceId })
},
emitJoinRequest: (spaceId, req) => ipc.emit('event:member-join-request', { spaceId, ...req }),
emitJoinRequestsUpdated: (spaceId) => ipc.emit('event:join-requests-updated', { spaceId }),
// A followed member added/removed a share/<space>/* record (shares live in the profile bee, which
// doesn't move the member set). Poke the share + file lists so a derived-only member's share
// surfaces without waiting for an unrelated member-set change.
emitSharesUpdated: (spaceId) => sharesPoke.poke(spaceId),
})
async function handleMembershipControl(msg, ctx) {
try {
if (msg.type === 'membership:request') return await onJoinRequest(msg)
if (msg.type === 'membership:grant') return await onGrant(msg, ctx)
if (msg.type === 'membership:deny') return onDeny(msg)
if (msg.type === 'membership:cancel') return await onCancel(msg, ctx)
} catch (err) {
log.warn('membership control failed:', msg?.type, '-', err.message)
}
}
async function onJoinRequest(msg) {
const spaceId = (msg.spaceTopic || '').slice(0, 16)
const space = spaceId ? await getSpace(spaceId) : null
if (!space || space.schemaVersion !== 2) return
// We are still pending ourselves: we hold no content key, so we can neither grant
// nor meaningfully approve — ignore other peers' requests entirely.
if (space.status === 'pending') return
// Capture the leave-tombstone (the kept "this peer left" marker) BEFORE clearing it: a peer
// mid-leave can still be transiently in
// space.members (handleLeaveFrame's removeMember hasn't committed), so a peer we've observed
// leaving must go through fresh approval, never the reconnect re-grant shortcut below.
const hadLeft = isLeft(spaceId, msg.profileKey)
// A fresh request means they want back in — lift any leave-tombstone (in-memory + durable) so the
// gate and the fold treat them as a normal (re)joiner again.
await dropTombstone(spaceId, msg.profileKey)
const grant = () => {
// Honor the same pause resolveJoinRequest enforces: while the creator-root conflict is
// unresolved, hand out no content key. Harmless for a reconnecting member (it already holds
// the SCK); load-bearing for the approved-but-keyless branch below, whose grant would be the
// joiner's FIRST SCK delivery under the disputed trust anchor.
if (space.creatorDivergence) { log.warn('re-grant blocked — creator root divergence unresolved:', spaceId); return }
const sck = getSpaceContentKey(spaceId, space)
if (sck) sendMembershipGrant(msg.profileKey, space.topic, b4a.toString(sck, 'hex'), space.creatorKey, boundSignerPk(msg.profileKey))
}
// Reconnect (still a member, no leave observed) → re-grant idempotently. A just-left peer falls
// through to a fresh join request + approval banner instead.
if (reconnectGrantAllowed((space.members || []).some((m) => m.publicKey === msg.profileKey), hadLeft)) return grant()
// Approved but never confirmed: the durable approved/<S>/<joiner> receipt exists while the
// joiner's own member/<S> record hasn't converged — the state a joiner approved while OFFLINE
// re-knocks from (its grant frame was undeliverable, so it is still 'pending' and re-requests
// on every reconnect). Re-issue the grant BEFORE the invite classification: the original invite
// may be spent or expired by now and must not re-deny an already-approved joiner. Idempotent,
// and self-terminating once the joiner publishes its member record and the fold promotes it;
// hadLeft still forces a departed peer through fresh approval.
if (!hadLeft && isApprovedJoiner(spaceId, msg.profileKey)) return grant()
// Per-link policy. The record is replicated across the member set, so any member resolves it the
// same: expired → refuse (the replicated record is authoritative, so stripping or forging the
// envelope's expiry hint cannot bypass it); auto → grant; review/absent → fall through to the
// manual approval banner. A resolved record (inviteRec) marks a deliberate, still-valid link.
let inviteRec = null
if (msg.inviteId) {
inviteRec = await resolveInvite(space, msg.inviteId)
const verdict = classifyInvite(inviteRec)
if (verdict === 'expired') {
if (space.topic) sendMembershipDeny(msg.profileKey, space.topic)
return
}
if (verdict === 'auto') {
await resolveJoinRequest(space, msg.profileKey, 'approve')
return
}
}
// Denied while offline: the durable denied/<S>/<joiner> tombstone converged among members, but
// the joiner never received the live deny frame — its space is stuck 'pending' and re-knocks on
// every reconnect. Re-send the deny so a STUCK joiner (a bare reconnect replay: no currently-valid
// reviewable invite backs this knock) can discard the space; no fresh banner. But a valid review
// invite means the owner re-opened the door — fall through to the banner so they can approve or
// revoke, instead of silently re-denying a genuine re-invitation.
if (!hadLeft && !inviteRec && isDeniedJoiner(spaceId, msg.profileKey)) {
if (space.topic) sendMembershipDeny(msg.profileKey, space.topic)
return
}
const displayName = clampDisplayName(msg.displayName)
const changed = recordJoinRequest(spaceId, msg.profileKey, displayName, msg.avatar)
// The durable receipt runs on EVERY knock — markRequest short-circuits on an existing one,
// so a re-announced (heartbeat) request is nearly free while a first write that failed
// self-heals. A departed peer (hadLeft) that re-requests must write a FRESH receipt ts, so
// co-members reading it via replication surface the rejoin instead of suppressing it
// against our leave stamp. Only the renderer emit is deduped: an unchanged heartbeat
// keeps the banner quiet, and this sits strictly AFTER the replay branches above, so a
// re-knock still replays a lost grant/deny.
await markRequest(spaceId, msg.profileKey, { displayName, avatar: msg.avatar || null, refresh: hadLeft })
if (changed || hadLeft) {
ipc.emit('event:member-join-request', { spaceId, publicKey: msg.profileKey, displayName, avatar: msg.avatar || null })
}
}
// The bound ed25519 signer key of a currently-connected peer, as a buffer, to seal its SCK grant.
// A grant only reaches a connected peer, so this is the single reliable source (boundSignerKeys is
// populated from every verified identity frame); no need to thread it through the request record.
function boundSignerPk(profileKeyHex) {
const hex = getBoundSignerKey(profileKeyHex)
return hex ? b4a.from(hex, 'hex') : null
}
// Reconcile the granter's authenticated root assertion against our pin. noop with an
// assertion = an authenticated granter re-confirmed the pinned root, so a flagged divergence
// is no longer live (noop without one clears nothing); refuse = a confirmed conflict, the
// grant must stop. Returns { blocked, decision }.
async function reconcileGrantCreator(spaceId, space, asserted) {
const pinnedIsAuthenticated = !!space.creatorKey && !space.creatorUnverified
const decision = reconcileAssertedRoot({ pinned: space.creatorKey || null, pinnedIsAuthenticated, asserted })
if (decision === 'noop' && asserted && space.creatorDivergence) {
await clearCreatorDivergence(spaceId)
ipc.emit('event:membership-creator-divergence', { spaceId })
}
if (decision === 'refuse') {
log.warn('membership:grant creator divergence — confirmed', space.creatorKey?.slice(0, 12) + '...', 'vs granter', asserted?.slice(0, 12) + '...')
await markCreatorDivergence(spaceId)
ipc.emit('event:membership-creator-divergence', { spaceId })
return { blocked: true, decision }
}
return { blocked: false, decision }
}
async function onGrant(msg, ctx = {}) {
const spaceId = (msg.spaceTopic || '').slice(0, 16)
const space = spaceId ? await getSpace(spaceId) : null
if (!space || space.status !== 'pending') return
// Authenticate the member-set root assertion before trusting it. The granter is, by the read
// gate, an authorized member; verifying its identity binding (the signature tying its profile
// key to this socket's Noise key) proves it really is the peer it claims to be. We pin
// creatorKey from THIS authenticated assertion, not from the bearer invite.
const enforce = isHandshakeIdentityBindingEnabled()
const verdict = checkGrantAssertion(ctx.peerInfo, msg, { enforceBinding: enforce })
if (!verdict.ok) {
log.warn('rejected membership:grant —', verdict.reason)
return
}
// While binding enforcement is off the assertion is unverified, so we leave the provisional
// invite pin untouched; an assertion is adopted or refused only once it is authenticated
// (enforcement on).
const asserted = enforce ? verdict.creator : null
const { blocked, decision } = await reconcileGrantCreator(spaceId, space, asserted)
if (blocked) return
// The SCK arrives sealed to our bound signer key; a plaintext sck field is refused as a
// downgrade. A sealed 32-byte SCK is exactly 80 bytes (32 + crypto_box_SEALBYTES) = 160 hex
// chars; bound the length so a peer can't trigger a large allocation with a multi-megabyte
// sckSealed.
const signer = getIdentitySigner()
if (typeof msg.sckSealed !== 'string' || !/^[0-9a-f]{160}$/i.test(msg.sckSealed) || !signer) return
const sckBuf = openSealedSck(b4a.from(msg.sckSealed, 'hex'), signer)
if (!sckBuf || sckBuf.length !== 32) return
await materializeOwnDrive(spaceId, sckBuf)
if (asserted && (decision === 'adopt' || decision === 'confirm')) await pinCreatorKey(spaceId, asserted)
await broadcastProfileUpdate()
await openMemberView(spaceId) // space is now approved → derive its membership
ipc.emit('event:membership-granted', { spaceId })
}
// A pending joiner withdrew their request (an ephemeral Tier-3 lifecycle signal) — drop our banner. Only the
// members actually showing it author a durable tombstone (the cancel is broadcast to every
// socket; don't pollute uninvolved members' bees) so the withdrawal converges + survives restart.
async function onCancel(msg, ctx = {}) {
const spaceId = (msg.spaceTopic || '').slice(0, 16)
if (!spaceId || typeof msg.joinerKey !== 'string') return
const showing = listJoinRequests(spaceId).some((r) => r.publicKey === msg.joinerKey)
const had = clearJoinRequest(spaceId, msg.joinerKey)
if (showing) await markRequestDenied(spaceId, msg.joinerKey)
// Ack so the joiner stops replaying. applied:true whenever the withdrawal is DURABLY applied here
// (we just wrote the tombstone, or already hold one from a prior replay) — re-attesting on every
// replay so a single lost ack can't leave the joiner replaying forever. isDeniedJoiner reflects
// the tombstone that replicates the withdrawal to co-members.
const applied = showing || isDeniedJoiner(spaceId, msg.joinerKey)
ctx.reply?.({ type: 'membership:cancel-ack', spaceTopic: msg.spaceTopic, joinerKey: msg.joinerKey, applied })
if (had || showing) ipc.emit('event:join-requests-updated', { spaceId })
}
async function onDeny(msg) {
const spaceId = (msg.spaceTopic || '').slice(0, 16)
if (!spaceId) return
// The request was rejected — the joiner never became a member, so drop the
// pending space entirely instead of leaving it stranded in their list.
const space = await getSpace(spaceId)
if (space?.status === 'pending') await discardPendingSpace(spaceId)
ipc.emit('event:membership-denied', { spaceId })
}
// Single chokepoint for a member resolving a pending join request (an ephemeral Tier-3
// request becoming durable Tier-1 membership). On
// approve it authors the approval record (recordApproval) and hands the joiner the SCK
// grant directly; co-members converge on the new member by replicating that record (the
// fold re-derives) — no approval gossip. On deny it signals the joiner and tells
// co-members to drop the banner. Routing all decision sites (manual approve, auto-admit,
// deny) through here means none can omit a step. outcome: 'approve' | 'deny'.
async function resolveJoinRequest(space, joinerKey, outcome) {
const spaceId = space.spaceId
if (outcome === 'approve') {
// A confirmed creator-root conflict disputes the roster's trust anchor — handing out the
// SCK now would admit members under an unresolved identity split. This check is what makes
// the divergence banner's "approvals are paused" claim true.
if (space.creatorDivergence) {
log.warn('approval blocked — creator root divergence unresolved:', spaceId)
throw new AppError(ErrorCodes.CREATOR_DIVERGENCE_UNRESOLVED, 'approvals are paused while the creator root conflict is unresolved')
}
const sck = getSpaceContentKey(spaceId, space)
if (!sck) return false
await recordApproval(spaceId, joinerKey)
// The read-model is already correct here (member approved, request cleared), so clear the
// approver's banner now instead of gating it on the grant/capture below — matches the deny path.
ipc.emit('event:join-requests-updated', { spaceId })
// Grant FIRST so the joiner flips to approved promptly — delaying it widens a race where a
// co-member's (no-op) deny reaches a still-pending joiner and makes it discard the space.
let delivered = false
if (space.topic) {
// The grant is sealed to the joiner's bound signer key, read from its live connection
// (the joiner must be connected to be granted). Surface a failure loudly rather than leaving
// the joiner silently stuck on "waiting for approval".
const signerPk = boundSignerPk(joinerKey)
delivered = sendMembershipGrant(joinerKey, space.topic, b4a.toString(sck, 'hex'), space.creatorKey, signerPk)
if (!delivered) log.warn('approval grant not delivered —', joinerKey.slice(0, 8), '— signer key', signerPk ? 'present' : 'missing')
}
// THEN durably capture the joiner's OWN profile core while it is still connected (it stays
// connected through this awaited handler). Without this, a joiner that disconnects right after
// approval leaves NO peer holding its record, so the OR-Set fold can never converge it on
// anyone — the owner included. We serve it onward via our member-view follow. The capture is
// best-effort and time-bounded so slow replication can't stall the approval; the joiner
// usually hasn't authored/replicated its member record yet at this instant, so a miss here is
// normal and the fold converges it later anyway — keep it at debug.
const captured = await captureJoinerMembership(joinerKey, spaceId)
if (!captured) log.debug('approval: joiner membership record not captured —', joinerKey.slice(0, 8))
return { granted: true, delivered }
}
clearJoinRequest(spaceId, joinerKey)
await markRequestDenied(spaceId, joinerKey) // durable, replicated dismissal (+ drops our receipt)
if (space.topic) {
sendMembershipDeny(joinerKey, space.topic)
broadcastMembershipCancel(spaceId, space.topic, joinerKey) // co-members drop the banner
}
ipc.emit('event:join-requests-updated', { spaceId })
return true
}
// Tear down a space we only ever sat pending in: no own drive, owned/foreign
// mounts, or authored membership records exist, so the heavyweight leave path
// (which purges a drive that was never materialized) does not apply and crashes
// on the closing cores. This is the cancel path for both a deny and a manual
// "stop waiting". Every step is best-effort so a single failure can't reject the
// caller and surface as an Uncaught in the renderer.
async function discardPendingSpace(spaceId) {
markSpaceLeaving(spaceId)
closeMemberView(spaceId)
try {
const space = await getSpace(spaceId)
const peerMembers = (space?.members || []).filter((m) => !!m.driveKey)
try { await leaveSpaceTopic(spaceId) } catch (err) { log.warn('discard pending: leave topic failed:', err.message) }
try { await cleanupSpaceDrives(spaceId, peerMembers) } catch (err) { log.warn('discard pending: peer-drive cleanup failed:', err.message) }
try { await purgeSpace(spaceId) } catch (err) { log.warn('discard pending: remove failed:', err.message) }
try { await forgetUnreferencedPeerCores(space?.members || []) } catch (err) { log.warn('discard pending: peer-core gc failed:', err.message) }
} finally {
unmarkSpaceLeaving(spaceId)
}
}
// === Startup resume: sweeps, folder mounts, periodic timers ===
try {
// Sweep Downloads (loose/folder downloads) + every foreign mount dir (mirror fetches
// write partials at the file's nested location), reclaiming crash-orphaned partials
// while keeping any a paused/in-flight transfer can still resume from.
const foreignDirs = (await listForeignMounts()).map((m) => m.mountPath)
await cleanupOrphanedPartials(getDownloadDir(), foreignDirs)
} catch (err) {
log.warn('partial sweep failed:', err.message)
}
// One-shot: reclaim peer-download cache cores left behind by the retired eager content
// backend (the overlay backend keeps no such cache, and storage:info does not surface them).
// Fire-and-forget + flag-guarded so the compaction never blocks boot.
reclaimLegacyPeerCaches().catch((err) => log.warn('legacy peer-cache reclaim failed:', err.message))
try {
cleanupOrphanedJournals(getJournalDir())
} catch (err) {
log.warn('journal sweep failed:', err.message)
}
// Open derived member views (which seed the durable leave-tombstones into memory) BEFORE joining
// topics, so an inbound membership:request from a departed peer that reconnects can't be handled
// with an unseeded tombstone set — which would misread `hadLeft`, take the reconnect re-grant
// shortcut, and clear the durable tombstone before it was ever loaded. The fold self-heals as more
// peer bees replicate after the topics join below.
await openMemberViewsForKnownSpaces()
for (const space of activeSpaces) {
await joinSpaceTopic(space.spaceId)
}
// Replay leaves that never reached a member (leave-while-alone): re-join each marker's
// topic and let the swarm re-announce the leave frame on every new connection; the first
// co-member ack clears the marker and drops the topic again.
configurePendingLeaves(async (spaceId) => {
await clearPendingLeave(spaceId)
await leavePendingLeaveTopic(spaceId)
})
configurePendingCancels((spaceId) => leavePendingCancelTopic(spaceId))
try {
for (const pl of await listPendingLeaves()) {
// The marker is persisted mid-teardown, BEFORE the space record is purged. If the worker
// died in that window the record survives (the leave was interrupted, the user still has the
// space) — drop the stale marker rather than replaying a leave for a live space, which would
// otherwise swarm.leave() its topic on the first ack and strand it deaf until restart.
if (!pl.topic || await getSpace(pl.spaceId)) { await clearPendingLeave(pl.spaceId); continue }
registerPendingLeave(pl.spaceId, pl.topic, pl.ts || Date.now())
joinPendingLeaveTopic(pl.spaceId, pl.topic)
log.info('replaying pending leave for space', pl.spaceId)
}
} catch (err) {
log.warn('pending-leave replay setup failed:', err.message)
}
const periodicTimers = new Map()
const reconcileCounters = new Map()
const RECONCILE_INTERVAL_MS = 6 * 60 * 60 * 1000
// role:shareId → last-known existence of the mount path. Read during the startup
// watcher restart (below) and maintained by the mount-point probe loop.
const lastMountPointStatus = new Map()
// Persist + announce an owned mount's status in one step. The durable field is what a
// boot or refresh re-derives the badge from — a transient-only event vanishes on reload —
// and the event stays as the live decoration.
async function setOwnedStatus(spaceId, shareId, status, error) {
try { await setOwnedMountStatus(spaceId, shareId, status, error ?? null) } catch (err) {
log.debug('owned mount status persist failed:', shareId, '-', err.message)
}
ipc.emit('event:owned-folder-mount-status', { spaceId, shareId, status, ...(error ? { error } : {}) })
}
// Map a reconcile/scan outcome to the durable owned-mount status. A scan RESOLVES (not rejects)
// with { skipped } when it couldn't run — a missing root or a content mode this build can't serve
// — so treating any resolution as 'active' would durably record a healthy scan that never ran.
// Returns the settled result (null on failure) so callers can gate their scan-completed emit.
async function settleScanStatus(promise, spaceId, shareId) {
try {
const result = await promise
if (result?.skipped === 'mount-point-gone') await setOwnedStatus(spaceId, shareId, 'mount-point-gone')
else if (result?.skipped) await setOwnedStatus(spaceId, shareId, 'paused-error', result.skipped)
else await setOwnedStatus(spaceId, shareId, 'active')
return result
} catch (err) {
log.warn('owned reconcile failed for', shareId, '-', err.message)
await setOwnedStatus(spaceId, shareId, 'paused-error', err.message)
return null
}
}
function schedulePeriodicReconcile(spaceId, shareId, mountPath, ignore) {
const key = spaceId + ':' + shareId
const existing = periodicTimers.get(key)
if (existing) clearInterval(existing)
const timer = setInterval(() => {
// Every Nth periodic pass runs deep (content-hash) to catch an in-place rewrite
// that kept identical size+mtime; the rest are the fast stat-only diff.
const n = (reconcileCounters.get(key) || 0) + 1
reconcileCounters.set(key, n)
const every = getDeepReconcileEvery()
const deep = every > 0 && n % every === 0
settleScanStatus(periodicReconcile(spaceId, shareId, mountPath, ignore, { deep }), spaceId, shareId)
}, RECONCILE_INTERVAL_MS)
timer.unref?.()
periodicTimers.set(key, timer)
}
function cancelPeriodicReconcile(spaceId, shareId) {
const key = spaceId + ':' + shareId
const timer = periodicTimers.get(key)
if (timer) {
clearInterval(timer)
periodicTimers.delete(key)
}
}
try {
const mounts = await listOwnedMounts()
for (const mount of mounts) {
// Don't point a watcher at a path that isn't there. The mount-point probe
// loop restarts the watcher + reconcile once the path comes back.
if (!mountRootAvailable(mount.mountPath)) {
log.warn('owned mount path missing at startup:', mount.mountPath)
lastMountPointStatus.set('owned-folder:' + mount.shareId, false)
await setOwnedStatus(mount.spaceId, mount.shareId, 'mount-point-gone')
continue
}
lastMountPointStatus.set('owned-folder:' + mount.shareId, true)
ipc.emit('main-request', {
command: 'owned-folder:start-watcher',
args: { shareId: mount.shareId, mountPath: mount.mountPath, ignore: mount.ignore },
})
settleScanStatus(periodicReconcile(mount.spaceId, mount.shareId, mount.mountPath, mount.ignore), mount.spaceId, mount.shareId)
schedulePeriodicReconcile(mount.spaceId, mount.shareId, mount.mountPath, mount.ignore)
}
} catch (err) {
log.warn('owned-folder watcher restart failed:', err.message)
}
try {
const mounts = await listForeignMounts()
for (const mount of mounts) {
// Seed the probe baseline (parity with owned mounts above) so a later mount-point return
// registers as a gone→present transition and the probe can auto-resume mid-session.
lastMountPointStatus.set('foreign-folder:' + mount.shareId, mountRootAvailable(mount.mountPath))
// Backfill a participation record for a mount that predates this feature (or whose mount-time
// publish failed): only the fresh-mount handler publishes, and setMirrorState can't create one,
// so without this a restored mirror stays invisible to owners forever.
await ensureMirror(mount.spaceId, mount.shareId, { state: mount.enabled === false ? 'paused' : 'syncing' })
.catch((err) => log.debug('mirror record ensure at boot failed for', mount.shareId, '-', err.message))
if (!mount.enabled) {
// Auto-paused mirrors (mount-point-gone / enospc / perm) recover at boot if the
// local target is back and the fault cleared; a user pause ('paused') stays paused.
await resumeAutoPausedForeignMount(mount.spaceId, mount.shareId).catch((err) =>
log.debug('foreign auto-resume at boot deferred for', mount.shareId, '-', err.message))
continue
}
// Enabled but its local target is missing at boot — durably pause it now. The probe only
// pauses on a mid-session gone TRANSITION, so without this a gone-at-boot mirror would keep
// a stale durable 'active' all session (its poll loop even mkdir -p's the missing root).
if (!mountRootAvailable(mount.mountPath)) {
await autoPauseForeignMountGone(mount.spaceId, mount.shareId).catch((err) =>
log.debug('foreign gone-at-boot pause failed for', mount.shareId, '-', err.message))
continue
}
// Owner drive may not be replicated at boot — the polling loop tolerates this
// and retries every 30 s. We start the loop unconditionally and best-effort the
// initial scan; if it fails because the peer isn't online yet, the next tick
// picks up once the owner connects.
startForeignLoop(mount)
initialMaterializeScan(mount).catch((err) => {
log.debug('foreign mirror initial scan deferred for', mount.shareId, '-', err.message)
})
}
} catch (err) {
log.warn('foreign-folder restart failed:', err.message)
}
const MOUNT_PROBE_INTERVAL_MS = 60_000
async function probeMountPoints() {
const fsMod = await import('bare-fs')
const all = await listAllMounts()
for (const mount of all) {
const key = mount.role + ':' + mount.shareId
let exists = true
try { fsMod.default.statSync(mount.mountPath) } catch { exists = false }
const prev = lastMountPointStatus.get(key)
if (prev === exists) continue
const wasGone = prev === false
lastMountPointStatus.set(key, exists)
if (mount.role === 'owned-folder') {
if (!exists) {
// Source folder just disappeared — tear down the live machinery so we stop pointing a
// watcher at a dead path and stop reconciling, and persist the gone status. The
// published snapshot and mount config are left untouched.
ipc.emit('main-request', { command: 'owned-folder:stop-watcher', args: { shareId: mount.shareId } })
cancelPeriodicReconcile(mount.spaceId, mount.shareId)
await setOwnedStatus(mount.spaceId, mount.shareId, 'mount-point-gone')
} else if (wasGone) {
// Source folder came back (USB replugged, network mount up, moved back). Resume: restart
// the watcher, run one catch-up reconcile whose OUTCOME sets the durable status (so a
// failing re-scan records paused-error, not a fabricated 'active'), and re-arm the timer.
ipc.emit('main-request', {
command: 'owned-folder:start-watcher',
args: { shareId: mount.shareId, mountPath: mount.mountPath, ignore: mount.ignore },
})
settleScanStatus(periodicReconcile(mount.spaceId, mount.shareId, mount.mountPath, mount.ignore), mount.spaceId, mount.shareId)
schedulePeriodicReconcile(mount.spaceId, mount.shareId, mount.mountPath, mount.ignore)
}
// A plain present tick (neither branch — e.g. the first probe of a freshly-added mount) must
// NOT assert durable 'active': status is owned by scan outcomes, and blanket-writing 'active'
// here would clobber a real paused-error the mount scan just recorded.
} else {
if (!exists) {
// The poll loop may be idle (no writes → no I/O error to classify), leaving a
// stale durable 'active' that a refresh/boot would resurrect — persist the pause.
await autoPauseForeignMountGone(mount.spaceId, mount.shareId).catch((err) =>
log.debug('foreign auto-pause on gone path failed for', mount.shareId, '-', err.message))
}
if (exists && wasGone) {
// Local target returned mid-session — resume an auto-paused mirror (owned-branch
// parity). No-ops for a user pause or a still-faulted mount.
await resumeAutoPausedForeignMount(mount.spaceId, mount.shareId).catch((err) =>
log.warn('foreign auto-resume after mount return failed for', mount.shareId, '-', err.message))
}
// Report the mount's real status (a user pause / still-faulted mount stays paused),
// not a fabricated 'active' derived from mere path presence.
const current = exists ? await getForeignMount(mount.spaceId, mount.shareId) : null
ipc.emit('event:foreign-folder-mount-status', {
spaceId: mount.spaceId,
shareId: mount.shareId,
status: current ? (current.status || 'active') : 'mount-point-gone',
})
}
}
}
const mountProbeTimer = setInterval(() => {
probeMountPoints().catch((err) => log.debug('mount probe failed:', err.message))
}, MOUNT_PROBE_INTERVAL_MS)
mountProbeTimer.unref?.()
// Backstop for catalog-backed shares: tombstone catalog entries whose source
// vanished (chokidar unlinks cover the live case; this catches missed events).
const PRESENCE_SWEEP_INTERVAL_MS = 60_000
const presenceSweepTimer = setInterval(() => {
// overlay shares get a missed-unlink backstop via the backend fan-out
// (no-ops when overlay is off / there are no overlay shares).
sweepBackends().catch((err) => log.debug('overlay presence sweep failed:', err.message))
if (isInPlaceFilesEnabled()) sweepLoosePresence().catch((err) => log.debug('loose presence sweep failed:', err.message))
}, PRESENCE_SWEEP_INTERVAL_MS)
presenceSweepTimer.unref?.()
// Prune our own expired invite links (reusable-until-expiry records are never consumed). Best-effort:
// enforcement is by timestamp regardless, so a missed run only defers cleanup.
const INVITE_SWEEP_INTERVAL_MS = 60 * 60 * 1000
async function sweepAllExpiredInvites() {
for (const s of await listSpaces()) {
if (s.schemaVersion === 2) await sweepExpiredInvites(s.spaceId)
}
}
const inviteSweepTimer = setInterval(() => {
sweepAllExpiredInvites().catch((err) => log.debug('invite sweep failed:', err.message))
}, INVITE_SWEEP_INTERVAL_MS)
inviteSweepTimer.unref?.()
sweepAllExpiredInvites().catch((err) => log.debug('invite sweep failed:', err.message))
ipc.handle('shutdown', () => { safeShutdown('renderer-shutdown') })
// === IPC: folder-share handlers (shares, owned & foreign mounts) ===
ipc.handle('share:list', async (msg) => {
return await listSharesForSpace(msg.spaceId)
})
ipc.handle('share:create', async (msg) => {
const space = await getSpace(msg.spaceId)
if (!space) throw new AppError(ErrorCodes.NOT_FOUND, 'Space not found')
const name = (msg.name || '').trim()
if (!isValidShareName(name)) throw new AppError(ErrorCodes.SHARE_NAME_INVALID, 'Invalid share name')
const existingOwn = await readOwnShares(msg.spaceId)
if (existingOwn.some((s) => s.name === name)) {
throw new AppError(ErrorCodes.SHARE_NAME_COLLISION, 'A folder with this name already exists in this space')
}
const share = {
id: generateShareId(),
type: 'owned-folder',
name,
owner: getLocalPublicKeyHex(),
spaceId: msg.spaceId,
createdAt: Date.now(),
}
// Overlay is the only content backend: serve straight from the source file (no
// second copy), advertising into a replicated catalog peers list/fetch from.
// Stamped at creation (replicates). A build without overlay can't create shares.
if (!isOverlayEnabled()) throw new AppError(ErrorCodes.OVERLAY_REQUIRED, 'Folder sharing requires the overlay backend')
// A v2 space's catalog is SCK-encrypted; without the SCK (a pending, not-yet-approved
// member) we can't open our own catalog to advertise into. Refuse cleanly rather than let
// ownCatalog throw a raw Error out of the IPC handler.
if (space.schemaVersion === 2 && !getSpaceContentKey(msg.spaceId, space)) {
throw new AppError(ErrorCodes.EOWNERSHIP, 'Cannot share into a space you have not been approved for yet')
}
share.contentMode = 'overlay'
const { keyHex, encrypted } = await ownCatalogPublish(msg.spaceId)
Object.assign(share, catalogKeyField(keyHex, encrypted))
await publishShare(msg.spaceId, share)
ipc.emit('event:shares-updated', { spaceId: msg.spaceId })
return share
})
ipc.handle('share:delete', async (msg) => {
await tombstoneShare(msg.spaceId, msg.shareId)
ipc.emit('event:shares-updated', { spaceId: msg.spaceId })
return { ok: true }
})
async function loadShareDescriptor(spaceId, ownerKey, shareId) {
const all = await listSharesForSpace(spaceId)
const share = all.find((s) => s.id === shareId && s.owner === ownerKey)
if (!share) throw new AppError(ErrorCodes.NOT_FOUND, 'Share not found')
return share
}
ipc.handle('share:list-files', async (msg) => {
const share = await loadShareDescriptor(msg.spaceId, msg.ownerKey, msg.shareId)
// Overlay is the only content backend; an unsupported mode renders as unavailable.
const backend = getContentBackend(share)
if (backend === UNSUPPORTED) return { entries: [], complete: true, total: 0, totalBytes: 0 }
return await listOverlayShareFiles(msg.spaceId, share, backend)
})
// Contain a renderer-supplied (share:reveal-file) or catalog relPath inside the mount, so
// neither a compromised renderer nor a malicious owner catalog can reveal/open a file
// outside the share folder. Same guard as the data layer (path-keys.relKeyEscapes); reject
// before building the path.
function pathFromMount(mountPath, relPath) {
if (relKeyEscapes(relPath)) {
throw new AppError(ErrorCodes.EPATH, `file path rejected — unsafe segment escapes the share folder: ${relPath}`)
}
const sep = mountPath.includes('\\') ? '\\' : '/'
const root = mountPath.replace(/[/\\]+$/, '')
const abs = root + sep + relPath.split('/').join(sep)
if (!(abs === root || abs.startsWith(root + sep))) {
throw new AppError(ErrorCodes.EPATH, `file path rejected — resolves outside the share folder: ${relPath}`)
}
return abs
}
function statSizeOrNull(absPath) {
try { return fs.statSync(absPath).size } catch { return null }
}
function pathFromDownloadDir(drivePath) {
const dir = getDownloadDir()
const sep = dir.includes('\\') ? '\\' : '/'
const basename = drivePath.split('/').pop() || ''
return dir.replace(/[/\\]+$/, '') + sep + basename
}
// Consumer-side status for a catalog-backed overlay share row. A null contentHash means
// the owner is still hashing → `preparing` while the owner is online, else `unavailable`
// (entries are advertised before hashing completes). Downloads land in the downloads folder and are recorded in the downloaded
// registry (markDownloaded), so `downloaded` is detected via isDownloadedFile — a file
// counts as downloaded iff the registry lists it.
async function overlayConsumerRow(spaceId, share, entry, { ownerOnline, foreignMount, pending }) {
if (foreignMount && foreignMount.enabled) {
const abs = pathFromMount(foreignMount.mountPath, entry.relPath)
if (statSizeOrNull(abs) === entry.size) {
const verified = !!entry.contentHash && (await getVerifiedHash(spaceId, share.id + '|' + entry.relPath)) === entry.contentHash
return { status: 'synced', localPath: abs, verified }
}
// The mirror loop is pulling this row right now — 'downloading' so FolderView's
// bar/speed/verify lane (all gated on the status) render during materialization.
if (foreignFetchActive(spaceId, share.id, entry.relPath)) {
return { status: 'downloading', localPath: null, pendingBytes: 0 }
}
if (!entry.contentHash) return { status: unhashedStatusFor(ownerOnline), localPath: null }
return { status: ownerOnline ? 'remote' : 'unavailable', localPath: null }
}
const drivePath = '/' + share.name + '/' + entry.relPath
if (await isDownloadedFile(spaceId, drivePath, entry.contentHash)) {
const verified = await isVerifiedDownload(spaceId, share.id + '|' + entry.relPath, entry.contentHash)
return { status: 'downloaded', localPath: (await getDownloadedPath(spaceId, drivePath)) || pathFromDownloadDir(drivePath), verified }
}
// A failed (non-active) download surfaces as 'error' with the code, so the row
// offers Retry/Dismiss + the message — parity with the loose path, instead of a
// misleading 'paused-interrupted' that re-fetches the same bad bytes on Resume.
const transferId = transferIdFor(spaceId, share.id, entry.relPath)
const isActive = overlayHasTransfer(transferId)
const pendingRow = pending?.get(drivePath)
// An in-flight fetch is 'downloading' — derived here, not overlaid by the renderer.
if (isActive) return { status: 'downloading', localPath: null, pendingBytes: pendingRow?.bytesTransferred || 0 }
if (pendingRow?.errorCode) return { status: 'error', localPath: null, errorCode: pendingRow.errorCode }
// A pending row with no live transfer is an interrupted/paused download →
// paused-interrupted/paused-offline; overlayHasTransfer gates it so a refresh
// mid-download doesn't flip the row.
const paused = pausedStatusFor({ pendingRow, isActive, ownerOnline })
if (paused) return { status: paused.status, localPath: null, pendingBytes: paused.pendingBytes }
if (!entry.contentHash) return { status: unhashedStatusFor(ownerOnline), localPath: null }
return { status: ownerOnline ? 'remote' : 'unavailable', localPath: null }
}
async function listOverlayShareFiles(spaceId, share, backend) {
const isOwn = share.owner === getLocalPublicKeyHex()
// One bounded pass returns the first `cap` catalog entries AND the true {total, totalBytes}
// for the whole share, so a huge folder never materialises a 150k-row array and the count is
// always consistent with the rows (total >= entries.length). The rich display rows below are
// built only for the capped entries.
const cap = getListFilesCap()
const { entries, total, totalBytes, complete = true } = isOwn
? await backend.listOwn(spaceId, share.id, cap)
: await backend.listPeerWithMeta(spaceId, share, cap)
const ownerOnline = isOwn ? true : isOwnerOnline(share.owner)
const ownedMount = isOwn ? await getOwnedMount(spaceId, share.id) : null
const foreignMount = isOwn ? null : await getForeignMount(spaceId, share.id)
const pending = isOwn ? null : new Map((await listPendingForSpace(spaceId)).map((p) => [p.filePath, p]))
const out = []
for (const entry of entries) {
let row
try {
// pathFromMount throws on an unsafe peer-supplied relPath — skip that one
// entry rather than aborting the whole listing (a malicious owner catalog
// must not make the share un-browsable).
if (isOwn) {
row = { status: entry.contentHash ? 'synced' : 'preparing', localPath: ownedMount ? pathFromMount(ownedMount.mountPath, entry.relPath) : null }
} else {
row = await overlayConsumerRow(spaceId, share, entry, { ownerOnline, foreignMount, pending })
}
} catch (err) {
log.warn('skipping overlay file row with an unsafe path:', entry.relPath, '-', err.message)
continue
}
out.push({ relPath: entry.relPath, size: entry.size, hash: entry.contentHash || '', mtime: entry.mtime, status: row.status, localPath: row.localPath, verified: row.verified || false, pendingBytes: row.pendingBytes, errorCode: row.errorCode, transferId: isOwn ? undefined : transferIdFor(spaceId, share.id, entry.relPath) })
}
// Truncation is a FACT the worker reports, never something the renderer infers from
// (total > rows): on an incomplete read `total` is itself partial, so that inference collapses
// to false exactly when the rows were capped — and the truncation goes silent.
const truncated = listingTruncated({ rowCount: entries.length, total, cap, complete })
if (truncated) log.debug(`share:list-files showing ${out.length} of ${total} rows for share ${share.id} (capped at ${cap})`)
return { entries: out, complete, total, totalBytes, truncated, fileLimit: truncated ? cap : null }
}
ipc.handle('share:reveal-folder', async (msg) => {
const share = await loadShareDescriptor(msg.spaceId, msg.ownerKey, msg.shareId)
const isOwn = share.owner === getLocalPublicKeyHex()
let target
if (isOwn) {
const ownedMount = await getOwnedMount(msg.spaceId, msg.shareId)
if (!ownedMount) throw new AppError(ErrorCodes.NOT_FOUND, 'Folder is not mounted on this device')
target = ownedMount.mountPath
} else {
const foreignMount = await getForeignMount(msg.spaceId, msg.shareId)
if (!foreignMount) throw new AppError(ErrorCodes.NOT_FOUND, 'Mirror not mounted')
target = foreignMount.mountPath
}
return revealLocalPath(target)
})
ipc.handle('share:reveal-file', async (msg) => {
const share = await loadShareDescriptor(msg.spaceId, msg.ownerKey, msg.shareId)
const isOwn = share.owner === getLocalPublicKeyHex()
let target
if (isOwn) {
const ownedMount = await getOwnedMount(msg.spaceId, msg.shareId)
if (!ownedMount) throw new AppError(ErrorCodes.NOT_FOUND, 'Folder is not mounted on this device')
target = pathFromMount(ownedMount.mountPath, msg.relPath)
} else {
const foreignMount = await getForeignMount(msg.spaceId, msg.shareId)
if (foreignMount && foreignMount.enabled) {
target = pathFromMount(foreignMount.mountPath, msg.relPath)
} else {
const drivePath = '/' + share.name + '/' + msg.relPath
target = (await getDownloadedPath(msg.spaceId, drivePath)) || pathFromDownloadDir(drivePath)
}
}
return revealLocalPath(target)
})
ipc.handle('share:folder-info', async (msg) => {
const share = await loadShareDescriptor(msg.spaceId, msg.ownerKey, msg.shareId)
const backend = getContentBackend(share)
if (backend === UNSUPPORTED) return { fileCount: 0, totalBytes: 0, blobsLength: null }
// overlay: counts come from the catalog (no drive blobs)
const isOwn = share.owner === getLocalPublicKeyHex()
// limit=0 → count + sum the catalog in one pass WITHOUT retaining any rows, so a 150k-file
// folder (e.g. opened in MirrorFolderModal) can't rebuild the full array here and OOM.
const { total, totalBytes } = isOwn
? await backend.listOwn(msg.spaceId, share.id, 0)
: await backend.listPeerWithMeta(msg.spaceId, share, 0)
return { fileCount: total, totalBytes, blobsLength: null }
})
// Space-wide storage for the space view's storage widget: one aggregate across
// every folder share plus the loose files (src/shared/storage/space-storage.js).
ipc.handle('space:storage-summary', async (msg) => {
if (isSpaceLeaving(msg.spaceId)) return { totalBytes: 0, onDeviceBytes: 0 } // teardown is closing cores — don't race it
return await spaceStorageSummary(msg.spaceId)
})
ipc.handle('share:read-file', async (msg) => {
const share = await loadShareDescriptor(msg.spaceId, msg.ownerKey, msg.shareId)
const isOwn = share.owner === getLocalPublicKeyHex()
const backend = getContentBackend(share)
if (backend === UNSUPPORTED) throw new AppError(ErrorCodes.NOT_FOUND, 'Share uses an unsupported content mode')
// overlay: request the file via the backend (catalog/overlay), not a drive
if (isOwn) return { ok: true, alreadyOwned: true }
return await backend.requestDownload(msg.spaceId, share, msg.relPath)
})
ipc.handle('share:discard-partial', async (msg) => {
const share = await loadShareDescriptor(msg.spaceId, msg.ownerKey, msg.shareId)
const drivePath = '/' + share.name + '/' + msg.relPath
// Overlay folder downloads run on the shared engine; it clears the partial +
// pending row and emits the share refresh itself.
await overlayCancelByKey(msg.spaceId, drivePath, transferIdFor(msg.spaceId, msg.shareId, msg.relPath))
return { ok: true }
})
ipc.handle('event:owned-folder-fs-event', async (msg) => {
try {
await handleFsEventFromMain(msg)
} catch (err) {
log.warn('owned-folder fs event failed:', err.message)
}
})
ipc.handle('event:loose-file-fs-event', async (msg) => {
try {
await handleLooseFsEvent(msg)
} catch (err) {
log.warn('loose-file fs event failed:', err.message)
}
})
const previewAborts = new Map()
ipc.handle('owned-folder:preview', async (msg) => {
const ignore = msg.ignore || DEFAULT_IGNORE
const shareId = msg.shareId && msg.shareId !== 'preview' ? msg.shareId : null
const previewId = msg.previewId || null
const signal = previewId ? { aborted: false } : null
if (previewId) previewAborts.set(previewId, signal)
try {
return await previewInitialPublishScan(msg.spaceId, shareId, msg.mountPath, ignore, {
signal,
onProgress: previewId
? (p) => ipc.emit('event:owned-folder-preview-progress', { previewId, ...p })
: null,
})
} finally {
if (previewId) previewAborts.delete(previewId)
}
})
ipc.handle('owned-folder:cancel-preview', async (msg) => {
const sig = previewAborts.get(msg.previewId)
if (sig) sig.aborted = true
return { ok: true }
})
ipc.handle('owned-folder:validate', async (msg) => {
return await validateMountPath(msg.mountPath, 'owned-folder', { shareId: msg.shareId })
})
ipc.handle('owned-folder:mount', async (msg) => {
const own = await readOwnShares(msg.spaceId)
const share = own.find((s) => s.id === msg.shareId)
if (!share) throw new AppError(ErrorCodes.NOT_FOUND, 'Share not found')
const { mountPath, advisories } = await validateMountPath(msg.mountPath, 'owned-folder', { shareId: msg.shareId })
const ignore = msg.ignore && msg.ignore.length > 0 ? msg.ignore : DEFAULT_IGNORE
// The admission gate. This is the CREATE path (the renderer's add-folder wizard is its only
// caller) — relocate, the periodic reconcile and the watcher's publishAdd are deliberately NOT
// gated, so a share that grows past the limit keeps publishing instead of breaking on restart.
// The modal blocks first; this is the authoritative check.
const fileCount = await countFolderFiles(mountPath, ignore)
if (exceedsShareFileLimit(fileCount)) {
throw new AppError(ErrorCodes.SHARE_FILE_LIMIT, shareFileLimitMessage(fileCount))
}
const mount = {
spaceId: msg.spaceId,
shareId: msg.shareId,
mountPath,
ignore,
createdAt: Date.now(),
}
await saveOwnedMount(mount)
// Seed the probe baseline so the first mount-point tick doesn't read this brand-new mount as a
// gone→present transition (which would otherwise run against an unseeded key).
lastMountPointStatus.set('owned-folder:' + msg.shareId, mountRootAvailable(mountPath))
await setOwnedStatus(msg.spaceId, msg.shareId, 'scanning')
ipc.emit('main-request', {
command: 'owned-folder:start-watcher',
args: { shareId: msg.shareId, mountPath, ignore },
})
settleScanStatus(initialPublishScan(msg.spaceId, msg.shareId, mountPath, ignore), msg.spaceId, msg.shareId)
.then((result) => {
if (result && !result.skipped) ipc.emit('event:owned-folder-scan-completed', { spaceId: msg.spaceId, shareId: msg.shareId, ...result })
schedulePeriodicReconcile(msg.spaceId, msg.shareId, mountPath, ignore)
})
return { mount, advisories }
})
ipc.handle('owned-folder:get', async (msg) => {
return await getOwnedMount(msg.spaceId, msg.shareId)
})
// Re-point an owned folder at a new on-disk location after the original source
// was moved, renamed, or disconnected. The hash-based reconcile recognizes
// unchanged content at the new path and uploads nothing, so mirror peers see
// no churn — this is why relocate beats delete-and-re-add for recovery.
ipc.handle('owned-folder:relocate', async (msg) => {
const mount = await getOwnedMount(msg.spaceId, msg.shareId)
if (!mount) throw new AppError(ErrorCodes.NOT_FOUND, 'Mount not found')
const { mountPath, advisories } = await validateMountPath(msg.mountPath, 'owned-folder', { shareId: msg.shareId })
// Tear down anything still bound to the old (likely missing) path first.
cancelPeriodicReconcile(msg.spaceId, msg.shareId)
ipc.emit('main-request', { command: 'owned-folder:stop-watcher', args: { shareId: msg.shareId } })
mount.mountPath = mountPath
await saveOwnedMount(mount)
lastMountPointStatus.set('owned-folder:' + msg.shareId, true)
await setOwnedStatus(msg.spaceId, msg.shareId, 'scanning')
ipc.emit('main-request', {
command: 'owned-folder:start-watcher',
args: { shareId: msg.shareId, mountPath, ignore: mount.ignore },
})
// Relocate diffs by content hash (deep): the new path is typically a moved/copied
// tree whose mtimes differ, but identical content must upload nothing so mirror
// peers see no churn. The fast size+mtime diff would re-upload on the new mtimes.
settleScanStatus(initialPublishScan(msg.spaceId, msg.shareId, mountPath, mount.ignore, { deep: true }), msg.spaceId, msg.shareId)
.then((result) => {
if (result && !result.skipped) ipc.emit('event:owned-folder-scan-completed', { spaceId: msg.spaceId, shareId: msg.shareId, ...result })
schedulePeriodicReconcile(msg.spaceId, msg.shareId, mountPath, mount.ignore)
})
return { mount, advisories }
})
ipc.handle('owned-folder:delete', async (msg) => {
const own = await readOwnShares(msg.spaceId)
const share = own.find((s) => s.id === msg.shareId)
if (!share) {
log.warn('delete requested for unknown share:', msg.shareId)
}
cancelPeriodicReconcile(msg.spaceId, msg.shareId)
stopOwnedFolder(msg.spaceId, msg.shareId)
ipc.emit('main-request', { command: 'owned-folder:stop-watcher', args: { shareId: msg.shareId } })
// Overlay keeps no per-share drive blobs to tombstone — the share record
// tombstone below retires the catalog from every consumer's view.
await deleteOwnedMount(msg.spaceId, msg.shareId)
await tombstoneShare(msg.spaceId, msg.shareId)
ipc.emit('event:shares-updated', { spaceId: msg.spaceId })
ipc.emit('event:share-files-updated', { spaceId: msg.spaceId, shareId: msg.shareId })
return { ok: true }
})
ipc.handle('owned-folder:list-all', async () => {
const mounts = await listOwnedMounts()
return mounts.map((m) => ({ ...m, mountPointMissing: !mountRootAvailable(m.mountPath) }))
})
ipc.handle('mounts:list-all', async () => {
return await listAllMounts()
})
ipc.handle('foreign-folder:validate', async (msg) => {
return await validateMountPath(msg.mountPath, 'foreign-folder', { shareId: msg.shareId })
})
ipc.handle('foreign-folder:preview', async (msg) => {
const previewId = msg.previewId || null
const signal = previewId ? { aborted: false } : null
if (previewId) previewAborts.set(previewId, signal)
try {
return await previewMaterializeScan(msg.spaceId, msg.ownerKey, msg.shareId, msg.mountPath, {
signal,
onProgress: previewId
? (p) => ipc.emit('event:foreign-folder-preview-progress', { previewId, ...p })
: null,
})
} finally {
if (previewId) previewAborts.delete(previewId)
}
})
ipc.handle('foreign-folder:cancel-preview', async (msg) => {
const sig = previewAborts.get(msg.previewId)
if (sig) sig.aborted = true
return { ok: true }
})
ipc.handle('foreign-folder:mount', async (msg) => {
const { mountPath, advisories } = await validateMountPath(msg.mountPath, 'foreign-folder', { shareId: msg.shareId })
const mount = {
spaceId: msg.spaceId,
shareId: msg.shareId,
ownerKey: msg.ownerKey,
mountPath,
enabled: true,
attachedAt: Date.now(),
status: 'scanning',
}
await persistForeignMount(mount)
ipc.emit('event:foreign-folder-mount-status', { spaceId: msg.spaceId, shareId: msg.shareId, status: 'scanning' })
try { await publishMirror(msg.spaceId, msg.shareId, { state: 'syncing' }) }
catch (err) { log.warn('mirror record publish failed:', msg.shareId, '-', err.message) }
ipc.emit('event:mirrors-updated', { spaceId: msg.spaceId, shareId: msg.shareId })
// Start the poll loop regardless of the initial scan's outcome: a scan that rejects must still
// leave a running loop so the record re-derives from 'syncing' instead of stranding there.
initialMaterializeScan(mount)
.catch((err) => {
log.warn('mirror initial scan failed:', err.message)
ipc.emit('event:foreign-folder-mount-status', { spaceId: msg.spaceId, shareId: msg.shareId, status: 'paused-error', error: err.message })
})
.finally(() => { startForeignLoop(mount) })
return { mount, advisories }
})
ipc.handle('foreign-folder:get', async (msg) => {
return await getForeignMount(msg.spaceId, msg.shareId)
})
ipc.handle('foreign-folder:set-enabled', async (msg) => {
return await setForeignEnabled(msg.spaceId, msg.shareId, !!msg.enabled)
})
ipc.handle('foreign-folder:unmount', async (msg) => {
await unmountForeignFolder(msg.spaceId, msg.shareId)
return { ok: true }
})
ipc.handle('foreign-folder:list-all', async () => {
return await listForeignMounts()
})
// === IPC: profile & space handlers ===
ipc.handle('profile:get', async () => await getProfile())
ipc.handle('profile:set', async (msg) => {
await setProfile({ displayName: msg.displayName, avatar: msg.avatar })
broadcastProfileUpdate().catch(err => log.warn('profile broadcast failed:', err.message))
return await getProfile()
})
// The self-first roster (avatars included) for ONE space. Rosters ship slim in spaces:list —
// avatars are base64 data-URLs up to the sanitizeAvatar cap, far too heavy for an
// every-refetch payload — so per-space consumers read this on demand.
function fullRoster(space, profile) {
const others = (space.members || []).filter((m) => !profile || m.publicKey !== profile.publicKey)
if (!profile) return others
const self = {
publicKey: profile.publicKey,
driveKey: null,
displayName: profile.displayName,
avatar: profile.avatar,
}
return [self, ...others]
}
// The catalog-key fields are worker-internal (handshake fallbacks) — no roster payload
// ships them to the renderer.
function stripCatalogKeys({ looseCatalogKey, looseCatalogKeyEnc, ...m }) {
return m
}
function slimMember(m) {
const { avatar, ...slim } = stripCatalogKeys(m)
return slim
}
// The one projection for every Space[] the worker ships (spaces:list AND the boot
// event:state) — slim self-first rosters plus memberCount/pendingCount. A second
// unprojected emit path would leak raw rosters and desync the renderer's Space type.
async function slimSpaces(profile) {
// A space mid-leave (or one whose interrupted-leave completion failed at boot) must not
// surface as a normal space: it has no drive, no swarm, and is about to be forgotten.
const allSpaces = (await listSpaces()).filter((s) => !s.leaving)
return allSpaces.map(s => {
const memberKeys = new Set((s.members || []).map(m => m.publicKey))
if (profile) memberKeys.add(profile.publicKey)
const members = fullRoster(s, profile).map(slimMember)
return {
...s,
members,
memberCount: members.length,
pendingCount: listPendingRequests(s.spaceId, memberKeys).length,
}
})
}
ipc.handle('spaces:list', async () => slimSpaces(await getProfile()))
ipc.handle('space:members', async (msg) => {
const space = await getSpace(msg.spaceId)
if (!space || space.leaving) return []
return fullRoster(space, await getProfile()).map(stripCatalogKeys)
})
ipc.handle('space:mirrors', async (msg) => {
const mirrors = msg.shareId
? await listMirrorsForShare(msg.spaceId, msg.shareId)
: await listMirrorsForSpace(msg.spaceId)
return mirrors.map((m) => ({ mirrorer: m.mirrorer, shareId: m.shareId, state: m.state, mountedAt: m.mountedAt }))
})
ipc.handle('space:create', async (msg) => {
log.info('creating space:', msg.name)
const space = await createSpace(msg.name, msg.icon)
await markOwnMembership(space.spaceId, { refresh: true })
await joinSpaceTopic(space.spaceId)
await openMemberView(space.spaceId) // the creator's own space derives its membership too
log.info('space created:', space.spaceId)
return space
})
// Block a rejoin until a concurrent leave of the same space has fully torn down (the leaving flag
// clears only after the catalog record + drive are purged), so the rejoin sees no stale record and
// mints a fresh driveSuffix instead of resurrecting the just-purged deterministic drive key.
async function awaitSpaceLeaveSettled(spaceId, capMs = 15000) {
if (!isSpaceLeaving(spaceId)) return true
log.info('join: waiting for in-progress leave to settle:', spaceId)
const start = Date.now()
while (isSpaceLeaving(spaceId) && Date.now() - start < capMs) {
await new Promise((resolve) => setTimeout(resolve, 100))
}
return !isSpaceLeaving(spaceId)
}
ipc.handle('space:join', async (msg) => {
const decoded = decodeInvite(msg.inviteCode)
if (!decoded) {
const err = new Error('Invalid invite code')
err.code = 'INVITE_INVALID'
throw err
}
// Soft pre-check for instant feedback. `x` is a strippable hint, so allow 60s for clock skew;
// the minting member's record is the authority on the handshake.
if (decoded.expiresAt && decoded.expiresAt + 60_000 < Date.now()) {
const err = new Error('This invite link has expired')
err.code = 'INVITE_EXPIRED'
throw err
}
const name = (typeof msg.name === 'string' && msg.name.trim()) || decoded.name || 'Shared Space'
// Rejoining a space we just left: wait for the leave teardown to settle. While it runs the
// catalog record still holds the old driveSuffix, and joinSpace would reuse it — resurrecting
// the deterministic drive key and forking replication against the blocks co-members still hold
// (the INVALID_OPERATION "Nodes is out of bounds" reconnect loop). Once leaving clears, the
// record is gone, so the rejoin mints a fresh suffix → a genuinely new, fork-free drive. If the
// teardown is still running past the cap, refuse the join rather than fork — the user retries.
if (!(await awaitSpaceLeaveSettled(decoded.topic.slice(0, 16)))) {
const err = new Error('A leave of this space is still finishing — try joining again in a moment')
err.code = 'LEAVE_IN_PROGRESS'
throw err
}
const rejoinSpaceId = decoded.topic.slice(0, 16)
log.info('joining space', decoded.v === 1 ? '(envelope)' : '(legacy)')
const space = await joinSpace(decoded.topic, name, msg.icon, { schemaVersion: decoded.schemaVersion, inviteId: decoded.inviteId, creator: decoded.creator })
await markOwnMembership(space.spaceId, { refresh: true })
// A genuine rejoin supersedes any pending outbound leave: the fresh member/<S> record (strictly
// newer ts) outranks the old tombstone on co-members, so retire the marker + its replay topic.
// Guard on an ACTIVE marker (never touch the shared topic maps otherwise — re-pasting an invite
// for a space we already belong to reaches here with no marker, and an unguarded
// leavePendingLeaveTopic would tear down that LIVE space's topic), and only after the rejoin is
// durable so a joinSpace failure can't drop a still-needed replay. joinSpaceTopic below re-joins.
if (hasPendingLeave(rejoinSpaceId)) {
unregisterPendingLeave(rejoinSpaceId)
await clearPendingLeave(rejoinSpaceId)
await leavePendingLeaveTopic(rejoinSpaceId)
}
// Pre-seed the inviter as an offline shell member (when the envelope carries
// their identity) so the space isn't empty until their handshake lands. Keyed
// by their real public key, so the handshake's upsertMember merges into this
// entry — filling driveKey/avatar and flipping them online — rather than adding
// a duplicate. Skipped if the invite predates this field or names ourselves.
if (decoded.owner && decoded.owner !== getLocalPublicKeyHex()) {
await upsertMember(space.spaceId, {
publicKey: decoded.owner,
displayName: decoded.ownerName || 'Unknown',
})
}
await joinSpaceTopic(space.spaceId)
await openMemberView(space.spaceId) // no-op while pending; opens on re-join of an approved space
log.info('space joined:', space.spaceId)
return space
})
ipc.handle('space:invite', async (msg) => {
const space = await getSpace(msg.spaceId)
if (!space?.topic) return null
// Hard block: a member-only capability. While pending we hold no content key, so
// any invite we minted could never confer read access (the redeemer would stall
// pending exactly as we do) — but it WOULD leak the space topic to outsiders. Refuse
// at the data layer, not just in the UI, so membership is enforced where it's authored.
if (space.status === 'pending') {
const err = new Error('Cannot invite to a space you have not joined')
err.code = 'NOT_A_MEMBER'
throw err
}
// Embed our identity so the joiner can show us as an offline member before we
// first connect. Display name is a snapshot at invite time; the handshake later
// corrects it if we've since renamed.
const profile = await getProfile()
// Mint a replicated per-link record (v2 only) when the caller asks for auto-approve OR an expiry —
// the new UI always sends an expiry. A bare programmatic call (no opts) stays record-less = a
// plain manual, never-expiring invite.
let inviteId, expiresAt
if (space.schemaVersion === 2 && (msg.autoAdmit || Number.isInteger(msg.expiresInMs))) {
inviteId = b4a.toString(crypto.randomBytes(16), 'hex')
expiresAt = Number.isInteger(msg.expiresInMs) ? Date.now() + msg.expiresInMs : null
await markInvite(space.spaceId, inviteId, { autoApprove: !!msg.autoAdmit, expiresAt })
}
return encodeInvite({
topic: space.topic,
name: space.name,
owner: profile?.publicKey,
ownerName: profile?.displayName,
// The OR-Set root (whoever created the space), so every joiner — and every member
// who re-shares this invite — seeds its membership fold from the same peer. Distinct
// from `owner` above, which is us (the inviter). Absent on pre-creatorKey joined
// spaces; the fold's transition fallback covers those.
creator: space.creatorKey,
schemaVersion: space.schemaVersion,
autoAdmit: !!(inviteId && msg.autoAdmit),
inviteId,
expiresAt,
})
})
ipc.handle('space:approve-member', async (msg) => {
const space = await getSpace(msg.spaceId)
// The real gate: approval IS handing out the content key, so a peer who holds no key
// (pending, or otherwise unauthorized) physically cannot approve anyone — enforced by
// the sck check inside resolveJoinRequest.
if (!space || space.schemaVersion !== 2 || space.status === 'pending') return false
return resolveJoinRequest(space, msg.publicKey, 'approve')
})
ipc.handle('space:deny-member', async (msg) => {
const space = await getSpace(msg.spaceId)
if (!space || space.status === 'pending') return false
// Approval is monotonic: if another member already let them in (they hold the SCK),
// a deny can't revoke without key rotation — clear our stale banner and no-op.
if (await isApprovedMember(msg.spaceId, msg.publicKey)) {
if (clearJoinRequest(msg.spaceId, msg.publicKey)) ipc.emit('event:join-requests-updated', { spaceId: msg.spaceId })
return false
}
return resolveJoinRequest(space, msg.publicKey, 'deny')
})
ipc.handle('space:pending-requests', async (msg) => {
const space = await getSpace(msg.spaceId)
const memberKeys = new Set((space?.members || []).map(m => m.publicKey))
return listPendingRequests(msg.spaceId, memberKeys)
})
ipc.handle('space:update', async (msg) => {
const space = await getSpace(msg.spaceId)
if (space?.status === 'pending') return null
log.info('updating space:', msg.spaceId)
return await updateSpace(msg.spaceId, msg.name, msg.icon)
})
ipc.handle('space:toggle-favorite', async (msg) => {
return await toggleFavorite(msg.spaceId)
})
// Persist a pending-leave marker BEFORE the record purge erases the topic, so the swarm can
// re-announce the leave to members who were offline at leave time (and boot re-joins the topic)
// until they provably apply it — without this they keep us as a ghost member forever. Arm iff
// some OTHER member did NOT ack: this excludes a solo space (no members → nobody to tell → no
// immortal marker) and covers a member that dropped mid-leave (never acked → still owed the
// replay), which the raw awaitLeaveAcks boolean conflated with "nobody was connected". ts is the
// leave stamp: a genuine later rejoin writes a strictly newer member/<S> ts and outranks the replay.
async function armPendingLeaveIfUnwitnessed(spaceId, space) {
if (!space?.topic) return false
const others = (space.members || []).map((m) => m.publicKey)
if (others.length === 0) return false
const acked = takeLeaveAckedKeys(spaceId)
if (others.every((k) => acked.has(k))) return false
try {
const leaveTs = Date.now()
await persistPendingLeave(spaceId, space.topic, leaveTs)
registerPendingLeave(spaceId, space.topic, leaveTs)
log.info('leave unwitnessed — pending-leave marker armed:', spaceId)
return true
} catch (err) {
log.warn('pending-leave persist failed:', err.message)
return false
}
}
// The teardown's leaveSpaceTopic dropped the topic — re-join it for the replay so a co-member
// returning THIS session still receives the leave (boot covers restarts). Re-check the live
// marker rather than a stale armed-flag: an ack that landed mid-teardown already cleared the
// marker and left the topic, and re-joining here would strand a zombie topic nothing can leave.
function rejoinPendingLeaveTopicAfterTeardown(spaceId, space) {
if (!hasPendingLeave(spaceId) || !space?.topic) return
try { joinPendingLeaveTopic(spaceId, space.topic) } catch (err) {
log.warn('pending-leave topic rejoin failed:', err.message)
}
}
ipc.handle('space:leave', async (msg) => {
// A teardown is already in flight (it can outlive the IPC response) — a re-click must be a no-op,
// not a second run that clobbers the in-flight leave-ack tracking and re-purges half-torn state.
// Claim the flag SYNCHRONOUSLY before any await, so two near-simultaneous leaves can't both pass
// the guard during the getSpace round-trip below.
if (isSpaceLeaving(msg.spaceId)) return { ok: true }
markSpaceLeaving(msg.spaceId)
// A pending space was never joined — take the lightweight cancel path instead of
// the drive-purge teardown, which assumes a materialized drive and crashes without one.
const pending = await getSpace(msg.spaceId)
if (pending?.status === 'pending') {
unmarkSpaceLeaving(msg.spaceId) // a pending cancel is not a drive teardown
log.info('cancel pending join:', msg.spaceId)
// Tell members who saw our request to drop it. A lost frame self-heals: register a pending
// cancel replayed on every connection until a member acks it applied, and re-join the topic
// (discard purges it) so a member offline now can still be reached. The initial send goes
// through sendPendingCancelToConnected so the ack from a currently-connected member is honored.
if (pending.topic) {
registerPendingCancel(msg.spaceId, pending.topic, getLocalPublicKeyHex())
sendPendingCancelToConnected()
}
await discardPendingSpace(msg.spaceId)
// Re-join for replay ONLY if a member hasn't already acked during the teardown above (which
// clears the pending cancel) — otherwise we'd re-join a purged topic nothing ever leaves.
if (pending.topic && hasPendingCancel(msg.spaceId)) joinPendingCancelTopic(msg.spaceId, pending.topic)
return { ok: true }
}
closeMemberView(msg.spaceId)
log.info('leave starting:', msg.spaceId)
// markSpaceLeaving already set above (before the getSpace await) so files:list reads short-circuit.
// Run the full teardown as a background task tracking its current phase. The
// load-bearing signals (leave frame, clearOwnMembership) and the durable catalog-record
// delete all happen before the heavyweight, occasionally-slow steps (swarm leave, peer
// core close/compaction, purge) — so if one of those stalls we can answer the renderer
// and let the rest finish in the background, instead of hanging the UI for the full IPC
// timeout. `phase` names where it is so a stall is diagnosable from the logs.
const tracker = { phase: 'start' }
let space = null
const teardown = (async () => {
try {
// Durable leaving marker BEFORE the member del: a quit anywhere in this teardown is now
// completed at the next boot (resumeInterruptedLeave) instead of being reverted by the
// markOwnMembership backfill. Best-effort — without it a crash reverts as before.
tracker.phase = 'mark-leaving'
try { await markSpaceLeavingDurable(msg.spaceId) } catch (err) {
log.warn('durable leaving mark failed:', err.message)
}
// Author the durable departure (member/<S> del) BEFORE broadcasting the frame, so it is
// written + announced when co-members apply the leave — their member-view live-follow can then
// re-host it and serve it to members who were offline at leave time.
tracker.phase = 'clearOwnMembership'
try { await clearOwnMembership(msg.spaceId) } catch (err) {
log.warn('clearOwnMembership failed:', err.message)
}
tracker.phase = 'leave-frame'
try { sendLeaveFrameToConnectedPeers(msg.spaceId) } catch (err) {
log.warn('leave-frame broadcast failed:', err.message)
}
// Tear down this space's folder machinery BEFORE purging its drive: a still-live
// chokidar watcher, foreign mirror loop, periodic reconcile, or cached share view
// would otherwise keep writing to the closed-and-purged drive and recreate state
// mid-purge (write-after-purge).
tracker.phase = 'folder-teardown'
try {
for (const m of (await listOwnedMounts()).filter((x) => x.spaceId === msg.spaceId)) {
cancelPeriodicReconcile(msg.spaceId, m.shareId)
stopOwnedFolder(msg.spaceId, m.shareId)
ipc.emit('main-request', { command: 'owned-folder:stop-watcher', args: { shareId: m.shareId } })
await deleteOwnedMount(msg.spaceId, m.shareId)
}
// Retire our share advertisements (deletedAt), like the unshare path. Without this our
// profile bee keeps advertising share/<S>/<id>, so on a rejoin a co-member reads it back
// and a folder they mirrored re-surfaces before any re-approval. The per-space drive is
// purged below, so only the profile-bee record needs tombstoning here.
for (const s of await readOwnShares(msg.spaceId)) {
await tombstoneShare(msg.spaceId, s.id)
}
for (const m of (await listForeignMounts()).filter((x) => x.spaceId === msg.spaceId)) {
await unmountForeignFolder(msg.spaceId, m.shareId)
}
} catch (err) {
log.warn('leave: folder teardown failed:', err.message)
}
log.info('leave: own state cleared, waiting flush...')
// Wait (bounded) for connected members to confirm they applied our leave — an observed signal
// that their durable revokeApproval ran — instead of a blind fixed sleep. Resolves early on
// full ack coverage; falls back to the cap for pre-upgrade peers that never ack.
tracker.phase = 'flush'
await awaitLeaveAcks(msg.spaceId, { capMs: 2000, floorMs: 500 })
log.info('leave: flush done, gathering members...')
space = await getSpace(msg.spaceId)
const members = space?.members || []
const peerDrivenMembers = members.filter(m => !!m.driveKey)
const peerDriveCount = peerDrivenMembers.length
await armPendingLeaveIfUnwitnessed(msg.spaceId, space)
// Drop the catalog record up front so the leave is durable: if any purge step
// below fails (or the worker dies mid-teardown), the space is already gone from
// the list and won't reappear — leftover cores are reclaimable, a stuck space is
// not. The drive stays in the in-memory map for purgeSpaceDrive.
tracker.phase = 'forgetSpaceRecord'
try { await forgetSpaceRecord(msg.spaceId) } catch (err) {
log.warn('leave: catalog record delete failed:', err.message)
}
// Cache-size precompute is best-effort; cap at 2s so a stuck drive read
// can't block the rest of the leave teardown.
tracker.phase = 'cache-bytes'
let totalBytes = 0
try {
totalBytes = await Promise.race([
getSpaceCacheBytes(msg.spaceId),
new Promise((resolve) => setTimeout(() => resolve(0), 2000)),
])
} catch (err) {
log.warn('cannot precompute space cache size:', err.message)
}
log.info('leave: cache bytes computed:', totalBytes)
// Must match the number of progress calls below: 1 disconnecting + N
// cleaningPeer + (N>0 ? compactingPeerCache : 0) + 4 local phases.
const totalSteps = 5 + peerDriveCount + (peerDriveCount > 0 ? 1 : 0)
let step = 0
const progress = (phase, data) => {
step++
const payload = { spaceId: msg.spaceId, step, totalSteps, phase }
if (data) payload.data = data
if (step === 1) payload.totalBytes = totalBytes
ipc.emit('event:leave-progress', payload)
}
// Cancel + discard any in-flight downloads for this space before the purges, so the
// overlay/loose engine stops fetching (no orphaned partial) and can't re-write the
// download-history rows cleanupDownloadHistory/clearPendingForSpace purge below.
await overlayCancelSpace(msg.spaceId)
await looseCancelSpace(msg.spaceId)
// ...and stop SERVING this space to everyone else. The cancels above only walk OUR OWN
// fetch slots — as the owner we have none, so without this a leave stops nothing on the
// serving side and the content plane keeps streaming the space's bytes. Runs BEFORE the
// purges: it needs serveIndex to still resolve hash → space.
revokeServesForSpace(msg.spaceId)
bumpServeEpoch()
tracker.phase = 'leaveSpaceTopic'
progress('disconnecting')
await leaveSpaceTopic(msg.spaceId)
log.info('leave: topic left, cleaning peer drives...')
// Defer the disk-reclaim compaction (compact: false): purging tombstones the cores (the
// leave is effective immediately), but a forced full-range compaction scans the WHOLE store
// and is expensive on a large one — one pass per drive (peer caches here, local cache below)
// would serially block the leave for tens of seconds. Run a SINGLE coalesced pass in the
// background after the purges instead.
tracker.phase = 'cleanupSpaceDrives'
await cleanupSpaceDrives(msg.spaceId, peerDrivenMembers, (phase, data) => {
progress(phase, data)
}, { compact: false })
log.info('leave: peer drives cleaned, purging local...')
tracker.phase = 'purge-local'
await cleanupDownloadHistory(msg.spaceId)
await clearPendingForSpace(msg.spaceId)
await purgeSpaceDrive(msg.spaceId, (phase) => {
progress(phase)
}, { compact: false })
try { await purgeOwnCatalog(msg.spaceId, space) } catch (err) {
log.warn('leave: own catalog purge failed:', err.message)
}
await purgeSpace(msg.spaceId)
tracker.phase = 'forgetUnreferencedPeerCores'
try { await forgetUnreferencedPeerCores(members) } catch (err) {
log.warn('leave: peer-core gc failed:', err.message)
}
// One background compaction pass for everything purged above — never awaited, so the
// leave completes as soon as the cores are tombstoned; the bytes come back shortly after.
compactStore()
.catch((err) => log.warn('leave: background reclaim compaction failed:', err.message))
tracker.phase = 'complete'
log.info('leave: complete (reclaim compaction running in background):', msg.spaceId)
} finally {
unmarkSpaceLeaving(msg.spaceId)
rejoinPendingLeaveTopicAfterTeardown(msg.spaceId, space)
}
})()
// Never hold the renderer past this deadline: the leave is already durable (catalog record
// dropped) and propagated (leave frame sent) well before the slow steps, so answer now and let
// any straggling teardown finish in the background. The warn names the stalled phase so a real
// hang is pinpointable from the console instead of surfacing only as a 30s IPC timeout.
const LEAVE_RESPOND_DEADLINE_MS = 12000
const stalled = await Promise.race([
teardown.then(() => false, () => false),
new Promise((resolve) => setTimeout(() => resolve(true), LEAVE_RESPOND_DEADLINE_MS)),
])
if (stalled) {
log.warn('leave: teardown exceeded', LEAVE_RESPOND_DEADLINE_MS, 'ms — stalled at phase:', tracker.phase, '— space', msg.spaceId, '— finishing in background')
teardown.catch((err) => log.warn('leave: background teardown failed:', err.message))
}
return { ok: true }
})
// === IPC: presence, file & transfer handlers ===
ipc.handle('members:online', async (msg) => {
// Include self: the local peer never leases itself in presence, and every consumer
// wants "who is reachable INCLUDING me".
return [getLocalPublicKeyHex(), ...getConnectedPeers(msg.spaceId)]
})
// Sender-side download indicator: open/close a per-file detail subscription so the
// worker only streams per-peer progress for a file whose row is expanded. Subscribe
// returns the current snapshot so the dropdown renders immediately; the ledger sweep
// pushes the authoritative snapshot while subscribed (no renderer poll).
ipc.handle('serving:summary-list', async (msg) => listServeSummaries(msg.spaceId))
ipc.handle('serving:detail-subscribe', async (msg) => subscribeServeDetail(msg.spaceId, msg.path))
ipc.handle('serving:detail-unsubscribe', async (msg) => unsubscribeServeDetail(msg.spaceId, msg.path))
ipc.handle('files:list', async (msg) => {
if (isSpaceLeaving(msg.spaceId)) return [] // teardown is closing the drive — don't race it
const space = await getSpace(msg.spaceId)
return await listFiles(msg.spaceId, space?.members || [])
})
ipc.handle('files:remove', async (msg) => {
await removeFile(msg.spaceId, msg.path)
ipc.emit('event:files-updated', { spaceId: msg.spaceId })
return { ok: true }
})
ipc.handle('files:discard-partial', async (msg) => {
// Loose downloads run on the overlay engine; it clears the partial + pending row
// and emits files-updated + the decoration done frame itself.
await looseCancel(msg.spaceId, msg.path)
return { ok: true }
})
ipc.handle('files:reveal', async (msg) => {
await revealFile(msg.spaceId, msg.path)
return { ok: true }
})
ipc.handle('files:add', async (msg) => {
log.info('adding file:', msg.fileName, 'from', msg.filePath)
await addFile(msg.spaceId, msg.filePath, msg.fileName)
ipc.emit('event:files-updated', { spaceId: msg.spaceId })
return { ok: true }
})
ipc.handle('files:download', async (msg) => {
const space = await getSpace(msg.spaceId)
const member = (space?.members || []).find((m) => m.publicKey === msg.ownerKey)
const res = await looseDownload(msg.spaceId, member, msg.path)
// Couldn't start: the owner may simply be unreachable on the bulk plane. Don't make the user
// wait for the next tick to find that out — the rescue throttles itself, so clicks stay cheap.
if (res?.queued) rescueStalledTransfers().catch((err) => log.debug('stalled-transfer rescue failed:', err.message))
return res
})
ipc.handle('files:cancel-download', async (msg) => {
const id = msg.transferId
// Route on the id's shape, not on a live transfer — the same defect the pause handler below was
// fixed for. A dropped connection settles the fetch a beat before the click lands, and gating on
// has() routed a settled row to NEITHER engine: the partial and the pending row survived a
// discard that reported ok, and the row auto-resumed on the next reconnect.
if (isLooseTransferId(id)) await looseCancelTransfer(id)
else await overlayCancel(id)
return { ok: true }
})
ipc.handle('files:pause-download', async (msg) => {
const id = msg.transferId
// Route on the id's shape, not on a live transfer: a dropped connection can settle the fetch a
// moment before the click lands, and gating on has() would silently pause nothing — leaving the
// row to auto-resume on the next reconnect against the user's intent.
if (isLooseTransferId(id)) loosePause(id)
else overlayPause(id)
return { ok: true }
})
ipc.handle('files:cancel-publish', async (msg) => {
await looseCancelPublish(msg.spaceId, msg.path)
return { ok: true }
})
// === IPC: feedback, storage & settings handlers ===
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
function formatTimestamp (d) {
const pad = (n) => String(n).padStart(2, '0')
const offsetMin = -d.getTimezoneOffset()
const sign = offsetMin >= 0 ? '+' : '-'
const absMin = Math.abs(offsetMin)
const offH = Math.floor(absMin / 60)
const offM = absMin % 60
const offset = offM === 0 ? `UTC${sign}${offH}` : `UTC${sign}${offH}:${pad(offM)}`
return `${MONTHS[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()} at ${pad(d.getHours())}:${pad(d.getMinutes())} (${offset})`
}
ipc.handle('feedback:send', async (msg) => {
const profile = await getProfile()
const displayName = profile?.displayName || 'Unknown User'
const email = typeof msg.email === 'string' && msg.email.trim() ? msg.email.trim() : null
const timestamp = formatTimestamp(new Date())
const cfg = getRuntimeConfig()
const appVersion = cfg.appVersion || (cfg.dev ? 'dev' : 'unknown')
const comment = msg.comment || '(no comment)'
const headerLines = [`Feedback from ${displayName}`]
if (email) headerLines.push(email)
headerLines.push(`v${appVersion} · ${os.platform()} ${os.release()} (${os.arch()})`)
headerLines.push(timestamp)
const header = headerLines.join('\n') + '\n\n'
const captionLimit = msg.screenshot ? 1024 : 4096
const room = captionLimit - header.length
const finalComment = comment.length > room ? comment.slice(0, room - 3) + '...' : comment
const caption = header + finalComment
const screenshotBuffer = msg.screenshot
? Buffer.from(msg.screenshot, 'base64')
: null
await sendFeedback(caption, screenshotBuffer)
return { ok: true }
})
ipc.handle('storage:info', async () => await getStorageInfo())
ipc.handle('storage:cleanup', async () => await cleanupOrphanedData())
ipc.handle('storage:leftover-scan', async () => await classifyLeftovers())
ipc.handle('storage:free-space', async () => {
const res = await freeSpace()
// Reclaim spans every space; emit per-space so each open file view re-derives (a spaceId-less
// poke matches no scoped consumer under the reconcile model).
for (const s of await listSpaces()) ipc.emit('event:files-updated', { spaceId: s.spaceId })
return res
})
ipc.handle('settings:set-download-folder', async (msg) => {
const folder = validateDownloadFolder(msg?.folder)
setDownloadFolder(folder)
return { ok: true }
})
ipc.handle('network:status:get', async () => getSwarmStatus())
ipc.handle('network:reconnect', async () => await reconnectAll())
ipc.handle('features:get', async () => ({ overlay: isOverlayEnabled(), inPlaceFiles: isInPlaceFilesEnabled() }))
// Live verbose-logging toggle, driven from the renderer dev console
// (window.mirall.verbose). The logger reads getRuntimeConfig().verbose on every
// call, so flipping it here takes effect immediately with no relaunch. The
// spread preserves every other runtime-config field (buildConfig round-trips
// them losslessly).
ipc.handle('setVerbose', async (msg) => {
setRuntimeConfig({ ...getRuntimeConfig(), verbose: !!msg.verbose })
return { verbose: !!msg.verbose }
})
ipc.handle('ping', async () => ({ pong: true, timestamp: Date.now() }))
// === Go live: flush queued frames, announce ready ===
ipc.start()
ipc.emit('event:worker-ready')
log.info('ready')
const profile = await getProfile()
if (!profile) {
ipc.emit('event:profile-needed')
} else {
ipc.emit('event:state', { profile, spaces: await slimSpaces(profile) })
}