Application Lifecycle

How the app runs from launch to shutdown, a runtime map of zmNinjaNg.

Entry Point (index.html to main.tsx)

Everything starts at app/index.html, the container for the React app. The browser, Electron’s Chromium (desktop), or the Capacitor WebView (mobile) loads it; the page loads src/main.tsx; and main.tsx finds the <div id="root"> element and mounts the React application into it.

// src/main.tsx
createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
)

Both names are imported directly: createRoot from react-dom/client and StrictMode from react. StrictMode deliberately mounts every component twice in development, running each effect’s setup, then its cleanup, then its setup again. That matters more here than in most apps, because effects in this codebase open MJPEG streams and WebSocket connections. Anything that connects on mount has to survive being torn down and reconnected immediately, so several hooks guard or delay their connect for exactly this reason. StrictMode has no effect in a production build.

Bootstrapping Phase (App.tsx)

When <App /> renders, the app is not yet ready to use. It must rehydrate its state from storage and bootstrap the active profile.

Rehydrating persisted state

The useProfileStore attempts to read saved profiles and the last active user from browser localStorage (the default Zustand persist storage; this is what runs on web, Electron, and the Capacitor webviews). Sensitive values like the encrypted password go through lib/security/secureStorage.ts, which delegates to the Capacitor secure-storage plugin on iOS/Android and to encrypted localStorage on web/Electron.

isInitialized starts as false, so the user sees <RouteLoadingFallback />, a spinner, while zustand/persist runs onRehydrateStorage and services/profile-initialization.ts picks up from there.

Profile Bootstrap

Once storage is rehydrated and a profile exists, isBootstrapping becomes true and a bootstrap overlay covers the app, showing progress steps and a Cancel button.

Two steps run before the overlay goes up, and they are the only ones that can abort. handleProfileRehydration clears stale auth and the query cache from the previous session, then installs the API client for the profile’s apiUrl. If either throws, the whole bootstrap is abandoned: isInitialized is set to true so the UI is not stuck on a spinner, and no server calls are attempted.

Everything after that is performBootstrap in services/profile-bootstrap.ts, launched without await so the UI stays responsive. It runs in order:

  1. bootstrapSSLTrust applies the profile’s self-signed-certificate setting. This has to be first, because it decides whether any later HTTPS call can connect at all.

  2. bootstrapAuth logs in with the stored credentials.

  3. bootstrapServerMap fetches /servers.json.

  4. bootstrapTimezone fetches the server’s timezone.

  5. bootstrapZmsPath derives the CGI URL from the server’s ZMS path.

  6. bootstrapGo2RTCPath picks up the go2rtc path if the server publishes one.

  7. bootstrapMultiPortStreaming reads MIN_STREAMING_PORT.

Every one of those seven wraps its own body in try/catch and logs a warning on failure. None of them rethrows, so a step that fails does not stop the next one, and the app finishes bootstrapping in a degraded state rather than not at all. That includes authentication: a login failure is logged as “this might be OK if server does not require auth” and the sequence continues, because a public ZoneMinder server is a real configuration. What that buys is also what it costs, since a wrong password and a public server look identical from here until the first authenticated query returns 401.

Two timers bound the whole thing, both from BOOTSTRAP_TIMEOUTS: performBootstrap is raced against totalTimeoutMs, and a separate timer of the same length flips isBootstrapping to false so the overlay cannot outlive it.

Bootstrap Server Map

After authentication, the bootstrap process calls bootstrapServerMap():

  1. Fetches /servers.json from the ZoneMinder API

  2. Builds a ServerId-to-URLs map via buildServerMap() from lib/zm/server-resolver.ts

  3. Stores the map in the module-level cache via setServerMap()

  4. The cache is cleared on profile switch

For single-server setups the map is empty. All URL lookups in resolveMonitorUrls and getPortalUrlForMonitor return the profile’s default URLs when the map is empty or a ServerId is not found.

Bootstrap Cancellation

If the server is unreachable or bootstrap takes too long, users can cancel:

The Cancel button on the bootstrap overlay calls cancelBootstrap(), which clears currentProfileId. With no active profile the router sends the user to /profiles to pick another one, or to /profiles/new when none exist.

Initialization Complete

Once bootstrap completes (or is cancelled):

  1. isInitialized becomes true, isBootstrapping becomes false.

  2. AppRoutes decides where to send the user, on the / route:

  • Active profile: Redirects to the last visited route, or /monitors if there is none.

  • Profiles exist, none active: Redirects to /profiles to pick one.

  • No profiles at all: Redirects to /profiles/new.

Authentication Flow

zmNinjaNg handles authentication differently than a typical SaaS app because it connects to potentially any ZoneMinder server, each with different auth requirements.

Token Exchange

On login, or when the app wakes up:

  1. Credentials: bootstrapAuth asks the profile store for the decrypted password (getDecryptedPassword) and passes it with the profile’s username to the auth store’s login(). The refresh token itself never sits in the persisted blob: stores/auth.ts reads and writes it through lib/security/secureStorage.ts, and drops it rather than falling back to plaintext when secure storage is unavailable.

  2. Login API: login() in api/auth.ts posts form-encoded credentials to /host/login.json. ZoneMinder wants a form body here, not JSON, so the call goes through client.postForm rather than the usual JSON path.

  3. Response: Server returns access_token and refresh_token, which LoginResponseSchema.parse validates before anything reads them.

  4. Store: Tokens are saved to useAuthStore (in memory mostly, refresh token persisted).

refreshToken() in the same file posts to /host/login.json as well, sending the refresh token instead of the credentials. There is one login endpoint, not two.

Refresh Loop

Access tokens expire on a schedule the ZoneMinder server chooses. The app has to replace one before it lapses, without the user noticing.

  • Hook: useTokenRefresh runs in AppRoutes (App.tsx).

  • Logic: It checks immediately, then every ZM_INTEGRATION.tokenCheckInterval (60 seconds), and again whenever the document becomes visible. If the access token expires within ZM_INTEGRATION.accessTokenLeewayMs (30 minutes), or has already expired, it calls getFreshAccessToken().

  • Why the visibility check: on mobile and in throttled browser tabs the interval timer stops firing while backgrounded, so a token can lapse with no check having run. The token is refreshed on the way back in, not on a timer that was asleep.

  • Why one shared call: the timer, a component’s proactive refresh, and a 401 recovery can all want a new token at once. refreshAccessToken in stores/auth.ts keeps a single in-flight promise, so concurrent callers attach to the same POST instead of racing three of them.

  • On failure: refreshAccessToken calls get().logout() and rethrows. The logout happens in the store, so useTokenRefresh only logs the error; it does not handle sign-out itself. Note what logout() does not do: it clears the tokens and sets isAuthenticated to false, but it does not clear the current profile and does not navigate anywhere. There is no redirect to a login screen, because there is no login screen. The user stays on the page they were on, and the next query needing auth fails with a 401 that resolveQueryError turns into a localized prompt to re-authenticate.

Steady State

Once logged in and on the Dashboard, several background processes keep the app alive.

Every interval below except the token check comes from BANDWIDTH_SETTINGS in lib/zmninja-ng-constants.ts, read through useBandwidthSettings(). Each has a normal and a low value, and switching the profile to low-bandwidth mode slows all of them at once. Both values are given.

  1. Token Refresh: Background timer checks token expiry every 60 seconds (ZM_INTEGRATION.tokenCheckInterval) and refreshes once within 30 minutes of expiry (ZM_INTEGRATION.accessTokenLeewayMs). This one is not bandwidth-scaled: a lapsed token breaks everything, so it is not a knob.

  2. Event Polling: Dashboard event widgets poll on eventsWidgetInterval (30s / 60s). The monitor-detail recent-events list uses monitorRecentEventsInterval (30s / 60s).

  3. Monitor Status: The monitor list polls monitorStatusInterval (20s / 40s). Alarm state on the Monitor Detail page polls the faster alarmStatusInterval (5s / 10s), because an alarm the user cannot see within a few seconds is not worth showing.

  4. Stream Keep-Alive: Streaming connections (useMonitorStream, via the useStreamLifecycle hook it composes) monitor their own health. If a stream dies, they reconnect with a fresh connection key (connkey), releasing the dead one with ZMS_COMMANDS.cmdQuit first so ZMS does not leak the old process.

  5. WebSocket Keepalive & Reconnect: The notification WebSocket (services/notifications.ts) sends a version-request ping every wsKeepaliveInterval (60s / 120s) to maintain the connection. On disconnection, it reconnects automatically using exponential backoff with jitter (2s, 4s, 8s, … plus or minus 25%). The cap depends on whether the app is on screen: maxReconnectDelay (2 minutes) while the document is hidden, NOTIFICATIONS_SERVICE.foregroundMaxReconnectDelayMs (15 seconds) while it is visible, so a user watching a disconnected badge is not left waiting on a two-minute timer. An intentionalDisconnect flag ensures only user-initiated disconnects stop reconnection; network drops always retry. On mobile, @capacitor/network triggers immediate reconnect when connectivity is restored. On desktop, a visibilitychange listener checks liveness when a tab becomes visible. NotificationHandler delegates this work to three focused hooks: useNotificationAutoConnect (connection lifecycle and reconnection), useNotificationPushSetup (FCM token initialization on mobile), and useNotificationDelivered (cold start notification processing and resume badge sync)

  6. Daemon Status: Server page checks ZoneMinder daemon health on daemonCheckInterval (30s / 60s)

For a reference of all timers, polling intervals, and scheduled actions across the application, see API and Data Fetching.

Mobile Lifecycle (Capacitor)

On iOS and Android, the app has unique lifecycle states handled by the OS.

Backgrounding

When the user swipes the app away (but doesn’t close it):

Capacitor fires pause and appStateChange, and JavaScript execution mostly stops. Intervals stop firing, so anything that depends on a timer is stale on return.

Nothing explicitly pauses the MJPEG streams. The OS suspends the webview, the socket goes quiet, and the stream is simply dead when the app comes back. Recovery happens on resume, not on the way out.

The montage while aggregating is the one exception, and only when allModePauseHidden is on: useHiddenPause fires MONTAGE_GRID.pauseHiddenGraceMs after the page goes hidden and disables each tile’s stream hooks, which CMD_QUITs the connkey instead of leaving the server’s nph-zms process running until it notices the socket is gone. See Pages and Views for the rest of the All-mode guardrails.

App.tsx flushes the log buffer on pause, so entries are not lost if the OS kills the process while it is backgrounded.

Resuming

When the user re-opens the app:

  • State: App comes to Foreground.

  • Streams: useVisibilityResume fires and useMonitorStream mints a fresh connkey and rebinds. It debounces on minHiddenMs (1500ms by default) so that a quick alt-tab does not trigger a reconnect storm.

  • Token: useTokenRefresh re-checks expiry on visibilitychange, because its 60-second interval was not running while suspended.

  • WebSocket Liveness: useNotificationAutoConnect calls service.checkAlive(5000), which sends a ping and waits for a response. If the server does not respond within those 5 seconds, the connection is treated as dead and a forced reconnect is triggered: the socket still reads as open, so an ordinary reconnectNow() would decline (refs #274). When the store already reads disconnected, resume skips the ping and reconnects immediately rather than waiting on a backoff timer that the suspended WebView froze. That resume nudge is a single attempt: when it fails, the foreground backoff cap above is what retries.

  • Badge Clear: useNotificationDelivered listens for appStateChange, ingests any notifications delivered while backgrounded into the history store, then clears the native badge via FirebaseMessaging.removeAllDeliveredNotifications().

Note two things resume does not do. There is no idle timeout: the app does not track a last-interaction timestamp and does not re-lock after a period away. And biometric authentication is not a resume gate. Kiosk lock is user-initiated (useKioskLock, from the sidebar or the fullscreen montage controls), and KioskOverlay offers biometrics only as a way to dismiss a lock the user already set. useKioskStore is ephemeral and resets to unlocked on app restart.