Application Lifecycle
How the app runs from launch to shutdown, a runtime map of zmNinjaNg.
1. The Entry Point (index.html → main.tsx)
Everything starts at app/index.html, the container for the React app.
Load: The browser, Electron Chromium (desktop), or Capacitor WebView (mobile) loads
index.html.Script: It loads
src/main.tsx(the TypeScript entry point).Mount:
main.tsxfinds the<div id="root">element and “mounts” the React application into it.
// src/main.tsx
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
2. Bootstrapping Phase (App.tsx)
When <App /> renders, the app is not yet ready to use. It must
“hydrate” its state from storage and bootstrap the active profile.
Data Hydration
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/secureStorage.ts, which delegates to the Capacitor
secure-storage plugin on iOS/Android and to encrypted localStorage on
web/Electron.
State:
isInitializedstarts asfalse.Visual: User sees
<RouteLoadingFallback />(a spinner).Mechanism:
zustand/persisttriggersonRehydrateStorage.
Profile Bootstrap
Once storage is hydrated and a profile exists, the app bootstraps the profile:
State:
isBootstrappingbecomestrue.Visual: User sees a bootstrap overlay with progress steps and a Cancel button.
Steps:
Clear stale auth/cache from previous session
Initialize API client with profile’s
apiUrlAuthenticate with stored credentials
Fetch server timezone
Fetch ZMS path from server config
Fetch Go2RTC path (if configured)
Check multi-port streaming configuration
Bootstrap server map (
bootstrapServerMap())
Bootstrap Server Map
After authentication, the bootstrap process calls
bootstrapServerMap():
Fetches
/servers.jsonfrom the ZoneMinder APIBuilds a ServerId-to-URLs map via
buildServerMap()fromlib/server-resolver.tsStores the map in the module-level cache via
setServerMap()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:
Action: Click “Cancel” button on bootstrap overlay
Effect: Calls
cancelBootstrap()which clearscurrentProfileIdNavigation:
If other profiles exist → redirects to
/profiles(profile selection)If no profiles exist → redirects to
/profiles/new(add profile)
Initialization Complete
Once bootstrap completes (or is cancelled):
isInitializedbecomestrue,isBootstrappingbecomesfalse.AppRoutesdecides where to send the user:
No Profile: Redirects to
/profiles/new.Has Profile: Redirects to
/monitors(or last visited route).
3. The Authentication Flow
zmNinjaNg handles authentication differently than a typical SaaS app because it connects to potentially any ZoneMinder server, each with different auth requirements.
A. Token Exchange
When you log in or the app wakes up:
Credentials: We retrieve the username/password (decrypted from SecureStorage).
Login API: We call
POST /api/host/login.Response: Server returns
access_tokenandrefresh_token.Store: Tokens are saved to
useAuthStore(in memory mostly, refresh token persisted).
B. The “Refresh Loop”
Tokens expire (usually after 1 hour). We need to verify we are still logged in.
Hook:
useTokenRefreshruns inApp.tsx.Logic: It sets a timer. When the token is about to expire, it silently calls the refresh API to get a new one.
Nuance: If refresh fails (e.g., user changed password), we forcibly logout and redirect to login screen.
4. The “Main Loop” (Runtime)
Once logged in and on the Dashboard, several background processes keep the app alive.
Token Refresh: Background timer checks token expiry every 60 seconds and refreshes once within 30 minutes of expiry (
ZM_INTEGRATION.accessTokenLeewayMs)Event Polling: Dashboard widgets and event views poll for new events at configurable intervals (30-60 seconds)
Monitor Status: Alarm status polling (5 seconds on Monitor Detail page)
Stream Keep-Alive: Streaming connections (
useMonitorStream) monitor their own health. If a stream dies (socket close), they automatically try to reconnect with a new “Connection Key”WebSocket Keepalive & Reconnect: The notification WebSocket (
services/notifications.ts) sends a version-request ping every 60 seconds to maintain the connection. On disconnection, it reconnects automatically using exponential backoff with jitter (2s, 4s, 8s, … capped at 2 minutes). AnintentionalDisconnectflag ensures only user-initiated disconnects stop reconnection; network drops always retry. On mobile,@capacitor/networktriggers immediate reconnect when connectivity is restored. On desktop, avisibilitychangelistener checks liveness when a tab becomes visible.NotificationHandlerdelegates this work to three focused hooks:useNotificationAutoConnect(connection lifecycle and reconnection),useNotificationPushSetup(FCM token initialization on mobile), anduseNotificationDelivered(cold start notification processing and resume badge sync)Daemon Status: Server page checks ZoneMinder daemon health every 30 seconds
For a complete reference of all timers, polling intervals, and scheduled actions across the application, see Chapter 7: Complete Timer and Polling Reference <07-api-and-data-fetching>.
5. 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):
State: App goes to “Background”.
Limit: JS execution pauses (mostly).
Streams: Video streams are paused to save battery/data.
Resuming
When the user re-opens the app:
State: App comes to “Foreground”.
Check: We check
last_interactiontimestamp.Security: If enabled, we might ask for Biometric Auth (FaceID) before revealing the screen.
Reconnect: Video streams detect the interruption and reconnect.
WebSocket Liveness:
NotificationHandlersends a ping to the notification WebSocket and waits for a response. If the server doesn’t respond within 5 seconds, the connection is treated as dead and an immediate reconnect is triggered.Badge Clear: Delivered notifications and the native badge are cleared via
FirebaseMessaging.removeAllDeliveredNotifications().