The boot sequence is initiated from RootViewController.viewDidLoad(), the
iOS equivalent of InitActivity.onCreate. CoreSession.start(dataDir:) is
the single entry point for the platform core and must complete before any
AppWebViewController is presented.
RootViewController.viewDidLoad()
│
└─ Task { await bootstrap() }
│
├─ requestRuntimePermissions()
│ AVCaptureDevice.requestAccess(for: .audio) (microphone)
│ AVCaptureDevice.requestAccess(for: .video) (camera)
│
├─ CoreSession.start(dataDir:)
│
├─ ConnectivityMonitor.init() NWPathMonitor starts
│
├─ CoreInjectedFns(connectivity:) Capability struct assembled
│
├─ FsInjectedFns(baseDir: dataDir/fs) FS directory created if absent
│
├─ CoreHost.init(dataDir:, injectedFns:, fsInjectedFns:)
│ │
│ ├─ JSEngineHost.init(label: "core")
│ │ └─ JSContext created
│ │ console capture installed
│ │ exception handler installed
│ │
│ ├─ CoreInjectedFns.registerAll(on: engine) 15 ports registered
│ ├─ FsInjectedFns.registerAll(on: engine) 21 ports registered
│ ├─ JSEngineHost.installDispatcher() __nativeCallDispatch installed
│ │
│ ├─ engine.load("common-preload.js")
│ ├─ engine.load("core-3nweb-client-lib.bundle.js") [see BUILDING.md]
│ ├─ engine.load("core-load.js")
│ │ └─ globalThis.__coreDispatch installed
│ │ globalThis.platform.device_fs installed
│ │ globalThis.__coreCall installed
│ │ globalThis.__coreCallAsync installed
│ │
│ └─ CoreCalls.initCore(dataDir:)
│ └─ JS: Core.make(conf, ...) called
│ JS: core.start() called
│ JS: capsForStartup available
│
├─ CoreSession actor created (shared = session)
│
├─ UI callbacks wired on CoreInjectedFns
│ (onStartActivity, onCloseActivity, onFocusActivity, onExitAll)
│
└─ Task { await host.waitForSignedInUser() }
│ (long-polls __coreCallAsync "getSignedUserIdEventually")
└─ session.notifySignedIn(userId:) [when login completes]
After CoreSession.start() returns, the application presents the initial view.
If no user is logged in, this is startup.app.privacysafe.io. If a cached login
is available, core-load.js can bypass the startup flow via
Core.startDirectlyFromCache(userId:storageKey:).
┌─────────────────────────────────────────────────────┐
│ CoreSession │
│ │
│ loggedUserId: String? ── nil = not logged in │
│ loginWaiters: [Continuation] ── pending waiters │
└─────────────────────────────────────────────────────┘
│
┌─────────────────────────┼─────────────────────────────┐
│ │ │
▼ ▼ ▼
isUserLoggedIn awaitLoginInProgress() notifySignedIn(userId:)
→ Bool → suspends until called from
(sync, reads notifySignedIn fires Task { waitForSignedInUser }
loggedUserId) resumes all waiters sets loggedUserId
resumes loginWaiters
State: NOT_LOGGED_IN (loggedUserId == nil)
AppWebViewController.loadComponent()
│
├─ [appDomain == startupDomain OR launcherDomain] → proceed
│
└─ [appDomain is non-system app]
│
├─ session.setAppToOpenAfterLogin(appDomain) memorise
└─ navigate to startupDomain redirect
State: LOGGING_IN (after startup app calls signIn.start())
Multiple AppWebViewControllers may call awaitLoginInProgress()
All are suspended until notifySignedIn fires.
State: LOGGED_IN (loggedUserId != nil)
AppWebViewController.loadComponent()
│
└─ [appDomain == startupDomain] → redirect to launcherDomain
[all other domains] → getOrCreateGUIComponent
This method stores the requested app domain for post-login routing. System domains are excluded:
func setAppToOpenAfterLogin(_ appDomain: String) {
switch appDomain {
case Bundled.startupDomain, Bundled.launcherDomain: break
default: nonSystemAppToOpenWhenLoggedIn = appDomain
}
}The stored value is available to the root navigation shell (not implemented in this skeleton) to re-open the intended app after the launcher is shown.
Each AppWebViewController instance corresponds to exactly one GUI component
(one WKWebView hosted at one app domain).
UIViewController lifecycle Component state
──────────────────────────────── ──────────────────────────────────────────
viewDidLoad()
└─ Task { await loadComponent() }
│
├─ Login checks (§2.1)
│
├─ session.getOrCreateGUIComponent(...)
│ └─ coreHost.launchApp(domain:)
│ → (connectorId, entrypoint) from JS core
│
├─ GUIComponentHandle created
│ corePort = CoreHost.makePort("c-<connectorId>")
│ coreHost.attachComponentPort(connectorId:)
│
├─ buildWebView(handle:)
│ ComponentCorePort assembled and wired
│ IpcBridge created
│ w3n-shim.js injected as WKUserScript
│ AppResourceSchemeHandler registered
│ WKWebView created
│
├─ webView.load(URLRequest(url: entrypointURL))
│ → AppResourceSchemeHandler.webView(_:start:)
│ serves index.html → page loads → w3n-shim.js runs
│
└─ handle.setActive(true)
viewDidAppear()
└─ handle.setActive(true)
viewDidDisappear()
└─ handle.setActive(false)
deinit
└─ handle.disconnect()
└─ coreHost.componentClosed(connectorId:)
corePort.close()
closeActivity?()
AppWebViewController ComponentCorePort CorePort ("c-<id>")
────────────────────────── ─────────────────────── ────────────────────────────
_sendToCore = { data in JSContext:
handle.corePort __portRecv_c-<id>(bytes)
.sendIntoJS(data) } ──► → fromCore.next(envelope)
→ ObjectsConnector
processes message
onMessageFromCore = { data ◄── __portSend_c-<id>(bytes)
IpcBridge called by core-load.js
.deliverToPage(data) } deliverToComponent()
getOrCreateGUIComponent(appDomain:, connectorId:?, entrypoint:?)
│
├─ connectorId present AND found in guiComponents → return existing handle
│
├─ coreHost.launchApp(domain:)
│ ↓ JS: __coreDispatch.launchAppFromAndroid({appDomain})
│ returns (connectorId, entrypoint)
│
├─ connectorId == "proceed-to-launcher" → return nil
│ (caller falls through to awaitLoginInProgress + launcher redirect)
│
└─ Create GUIComponentHandle, store in guiComponents, return
Headless deno components are created by CoreSession.startDenoComponent(),
which is called from CoreInjectedFns.jsStartComponentFn when the JS core
invokes the js_startComponent port.
js_startComponent (JS core) ──► CoreInjectedFns.jsStartComponentFn
──► CoreSession.startDenoComponent(
connectorId:, appDomain:, entrypoint:)
│
├─ coreHost.appCodeFileSize(connectorId:, path: entrypoint)
│ → must be > 0
│
├─ coreHost.readAppCodeFile(connectorId:, path:, start: 0, end: size)
│ → component source code as UTF-8 string
│
└─ DenoComponentHost.init(
appDomain:, entrypoint:, connectorId:, code:,
coreHost:, commonInjectedFns:)
│
├─ JSEngineHost.init(label: "w3n-deno://<domain><entrypoint>")
│
├─ CoreHost.makePort("c-<connectorId>")
│ sets up __portRecv_c-<id> in core JSContext
│
├─ CommonInjectedFns.registerAll (delay, crypto_random)
├─ inject "ipc_listCoreObjPath"
├─ inject "ipc_disconnectFromCore"
├─ engine.installDispatcher()
│
├─ engine.load("common-preload.js")
├─ engine.load("app-preload.js")
│ installs _awaitPreloadInit, Deno stub, w3n setup
│
└─ engine.context.evaluateScript(bootWrapper)
awaits _awaitPreloadInit()
then evaluates component code
ipc_disconnectFromCore (JS) ──► CoreHost.componentClosed(connectorId:)
──► __coreCall("onComponentClosedInAndroid", {connectorId})
──► JS: componentCloseFns[id]?.()
──► JS: delete __portRecv_c-<id>
DenoComponentHost.close() ──► CorePort.close()
──► sets __portRecv_<portName> to undefined
in core JSContext
The following decision table governs loadComponent() behaviour.
isUserLoggedIn |
appDomain |
Action |
|---|---|---|
| true | startupDomain |
Redirect to launcherDomain; close self |
| true | any other | Proceed to getOrCreateGUIComponent |
| false | startupDomain |
Proceed to getOrCreateGUIComponent |
| false | launcherDomain |
Proceed to getOrCreateGUIComponent |
| false | any other | setAppToOpenAfterLogin; redirect to startupDomain; close self |
| any | (no CoreSession.shared) |
Redirect to startupDomain |
After getOrCreateGUIComponent returns nil (core returned
"proceed-to-launcher"), the controller additionally awaits
awaitLoginInProgress() before redirecting to launcherDomain. This handles
the race where login has already started by the time the component requests
access.
Several bundled app manifests declare launchOnSystemStartup deno components:
- Chat:
background-instance.mjs - Contacts:
contactDenoServices.js - Treasure:
treasureDenoServices.js
These components are started on Android via CoreRunnerService which is
START_STICKY and starts on device boot via InitOnBoot. iOS has no equivalent.
Strategy C (BGProcessingTask) is implemented in BackgroundTaskManager.swift
for all three components. This gives each component several minutes of background
CPU time when the device is charging and idle, scheduled approximately every
15 minutes. This is adequate for Contacts and Treasure sync. For Chat, real-time
message delivery requires Strategy B (push notifications) in addition.
Setup required in Xcode:
- Add the "Background processing" capability under Signing & Capabilities.
- Add
BGTaskSchedulerPermittedIdentifiersto Info.plist with these values:io.privacysafe.chat.syncio.privacysafe.contacts.syncio.privacysafe.treasure.sync
Without these steps the BGTaskScheduler.shared.register calls in
BackgroundTaskManager.registerTasks() are no-ops and tasks will never fire.
Strategy B (push notifications for Chat) is not yet implemented. The
BackgroundTaskManager architecture is a suitable base: once a push notification
extension receives an APNs payload, it calls BackgroundTaskManager directly to
run a single Chat sync cycle without waiting for OS scheduling.
Strategy A (foreground-only) remains available as a simplification: start
the components in CoreSession.notifySignedIn and accept that background
delivery is lost when the app is suspended.