Skip to content
This repository was archived by the owner on Aug 29, 2026. It is now read-only.

Latest commit

 

History

History
327 lines (274 loc) · 14 KB

File metadata and controls

327 lines (274 loc) · 14 KB

04 — Lifecycle and Session Management

1. Application Boot Sequence

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:).


2. Login State Machine

                  ┌─────────────────────────────────────────────────────┐
                  │                   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

2.1 State Transitions

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

2.2 setAppToOpenAfterLogin

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.


3. GUI Component Lifecycle

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?()

3.1 Component Port Setup (inside buildWebView)

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()

3.2 getOrCreateGUIComponent Logic

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

4. Deno Component Lifecycle

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

4.1 Component Teardown

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

5. Login Redirect Logic in AppWebViewController

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.


6. Background Components (launchOnSystemStartup)

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:

  1. Add the "Background processing" capability under Signing & Capabilities.
  2. Add BGTaskSchedulerPermittedIdentifiers to Info.plist with these values:
    • io.privacysafe.chat.sync
    • io.privacysafe.contacts.sync
    • io.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.