WIP: Offline usage of Ferdium (fallback when server is down)#2372
WIP: Offline usage of Ferdium (fallback when server is down)#2372SpecialAro wants to merge 4 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Implements a “local backup / offline mode” fallback so the Ferdium app can still load previously synced user/services/workspaces when the configured server is down, while disabling configuration mutations until connectivity is restored.
Changes:
- Add offline-state snapshot persistence + hydration, plus app-level offline mode state machine (enter/exit, reconnect probing).
- Disable service/workspace/recipe configuration actions and related UI affordances while in offline mode.
- Update error/retry UX (InfoBars, sidebar status) and tweak request auto-retry behavior.
Reviewed changes
Copilot reviewed 37 out of 37 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| src/styles/layout.scss | Adds styling for an offline/online status element in the sidebar (light/dark themes). |
| src/stores/ServicesStore.ts | Guards service add/edit/delete/reorder actions when offline mode is active. |
| src/stores/RequestStore.ts | Adjusts required-request retry logic and suppresses required-request failure state in offline mode. |
| src/stores/GlobalErrorStore.ts | Updates auth failure handling in offline mode and triggers offline-backup refresh on successful requests. |
| src/stores/AppStore.ts | Introduces offline mode state, backup meta loading, offline activation/hydration, and reconnect checks. |
| src/helpers/offline-state.ts | New helper to load/save/serialize/hydrate the offline snapshot file. |
| src/features/workspaces/store.ts | Disables workspace create/update/delete/reorder actions in offline mode. |
| src/features/workspaces/containers/WorkspacesScreen.tsx | Threads isOfflineMode into workspace dashboard UI. |
| src/features/workspaces/containers/EditWorkspaceScreen.tsx | Threads isOfflineMode into the edit workspace form. |
| src/features/workspaces/components/WorkspacesDashboard.tsx | Hides/blocks workspace creation/edit navigation and shows an offline warning. |
| src/features/workspaces/components/WorkspaceServiceListItem.tsx | Adds a disabled mode for toggling workspace service membership. |
| src/features/workspaces/components/WorkspaceDrawer.tsx | Disables workspace settings entry points and add-new workspace UI in offline mode. |
| src/features/workspaces/components/EditWorkspaceForm.tsx | Disables inputs/actions and shows offline warning when offline mode is active. |
| src/features/workspaces/components/CreateWorkspaceForm.tsx | Adds a disabled state to prevent workspace creation submission. |
| src/containers/settings/SettingsWindow.tsx | Removes unused nested <Outlet /> rendering from Settings portal container. |
| src/containers/settings/ServicesScreen.tsx | Passes offline mode flag down to services settings dashboard. |
| src/containers/settings/RecipesScreen.tsx | Passes offline mode flag down to recipes settings dashboard. |
| src/containers/settings/EditSettingsScreen.tsx | Passes offline mode flag down to settings form. |
| src/containers/settings/EditServiceScreen.tsx | Passes offline mode flag down to edit service form. |
| src/containers/layout/AppLayoutContainer.tsx | Wires offline mode state/actions and health status into AppLayout and Sidebar. |
| src/containers/auth/AuthLayoutContainer.tsx | Wires offline mode entry action/state into auth layout when API is unhealthy. |
| src/components/ui/imageUpload/index.tsx | Adds a disabled state that prevents uploads and disables the delete button. |
| src/components/settings/settings/EditSettingsForm.tsx | Adds an offline-mode warning banner in settings. |
| src/components/settings/services/ServicesDashboard.tsx | Shows offline warning, disables navigation to recipes/add service FAB while offline. |
| src/components/settings/services/ServiceItem.tsx | Adds disabled navigation behavior for service rows. |
| src/components/settings/services/EditServiceForm.tsx | Disables all mutation controls and shows an offline warning when offline. |
| src/components/settings/recipes/RecipesDashboard.tsx | Shows offline warning and disables add-service interactions while offline. |
| src/components/settings/recipes/RecipeItem.tsx | Adds disabled support for recipe teaser buttons. |
| src/components/settings/SettingsLayout.tsx | Minor import formatting adjustment. |
| src/components/services/tabs/Tabbar.tsx | Prevents drag-sort and forwards offline mode state into sortable list. |
| src/components/services/tabs/TabItem.tsx | Disables context menu actions that mutate service state while offline. |
| src/components/services/tabs/TabBarSortableList.tsx | Threads offline mode flag into tab items. |
| src/components/layout/Sidebar.tsx | Adds offline/online status indicator with “switch back online” action when ready; hides recipes button offline. |
| src/components/layout/AppLayout.tsx | Adds “Run in offline mode” CTA to error/unhealthy API InfoBars and new props for API health/offline state. |
| src/components/auth/AuthLayout.tsx | Adds “Run in offline mode” CTA on auth screen when API is unhealthy and a backup is available. |
| src/actions/app.ts | Adds enterOfflineMode / exitOfflineMode actions. |
| src/@types/stores.types.ts | Adds isOfflineMode to AppStore and corrects allRecipesRequest typing to CachedRequest. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| let recipes = this.stores.recipes.all; | ||
| if (recipes.length === 0) { | ||
| recipes = await this.stores.recipes.allRecipesRequest.execute().promise; | ||
| } |
There was a problem hiding this comment.
_activateOfflineMode awaits this.stores.recipes.allRecipesRequest.execute().promise. allRecipesRequest is a CachedRequest, and its implementation never resolves/rejects the promise on error, so if getInstalledRecipes() throws this await can hang indefinitely and leave isEnteringOfflineMode stuck true. Avoid awaiting a CachedRequest.promise here (e.g., call execute() and read .result/.isError, or update CachedRequest to always settle its promise on error).
| export function saveOfflineState(snapshot: OfflineStateSnapshot): void { | ||
| try { | ||
| ensureDirSync(userDataPath('config')); | ||
| writeJsonSync(BACKUP_FILE, snapshot, { spaces: 2 }); | ||
| } catch (error) { | ||
| console.warn('Could not write offline backup state', error); | ||
| } |
There was a problem hiding this comment.
saveOfflineState uses ensureDirSync/writeJsonSync, which perform blocking filesystem I/O on the renderer thread. With the current integration this can run often, so it risks noticeable UI stutters. Prefer the async fs-extra variants (or move the persistence to the main process via IPC) and consider debouncing writes to reduce churn.
| updatedAt: new Date().toISOString(), | ||
| user: { | ||
| id: user.id, | ||
| email: user.email, | ||
| firstname: user.firstname, | ||
| lastname: user.lastname, | ||
| organization: user.organization, | ||
| accountType: user.accountType, | ||
| beta: user.beta, | ||
| locale: user.locale, | ||
| isSubscriptionOwner: user.isSubscriptionOwner, | ||
| team: user.team, | ||
| }, |
There was a problem hiding this comment.
The offline snapshot writes personally identifiable info (e.g., email, firstname/lastname, organization, team data) to offline-state.json in plaintext. Please consider minimizing the persisted fields to only what the offline UI strictly needs, and/or encrypting the snapshot (e.g., using OS keychain/credential store) to reduce local data exposure risk.
| export function serializeOfflineState({ | ||
| user, | ||
| services, | ||
| workspaces, | ||
| }: { | ||
| user: Record<string, any>; | ||
| services: ServiceModel[]; | ||
| workspaces: Workspace[]; | ||
| }): OfflineStateSnapshot { | ||
| return { | ||
| updatedAt: new Date().toISOString(), | ||
| user: { | ||
| id: user.id, | ||
| email: user.email, | ||
| firstname: user.firstname, | ||
| lastname: user.lastname, | ||
| organization: user.organization, | ||
| accountType: user.accountType, | ||
| beta: user.beta, | ||
| locale: user.locale, | ||
| isSubscriptionOwner: user.isSubscriptionOwner, | ||
| team: user.team, | ||
| }, | ||
| services: services.map(service => ({ | ||
| id: service.id, | ||
| recipeId: service.recipe.id, | ||
| name: service.name, | ||
| team: service.team, | ||
| customUrl: service.customUrl, | ||
| iconUrl: service.iconUrl, | ||
| order: service.order, | ||
| isEnabled: service.isEnabled, | ||
| isNotificationEnabled: service.isNotificationEnabled, | ||
| isBadgeEnabled: service.isBadgeEnabled, | ||
| isMediaBadgeEnabled: service.isMediaBadgeEnabled, | ||
| trapLinkClicks: service.trapLinkClicks, | ||
| isIndirectMessageBadgeEnabled: service.isIndirectMessageBadgeEnabled, | ||
| isMuted: service.isMuted, | ||
| isDarkModeEnabled: service.isDarkModeEnabled, | ||
| darkReaderSettings: service.darkReaderSettings, | ||
| isProgressbarEnabled: service.isProgressbarEnabled, | ||
| onlyShowFavoritesInUnreadCount: service.onlyShowFavoritesInUnreadCount, | ||
| spellcheckerLanguage: service.spellcheckerLanguage, | ||
| userAgentPref: service.userAgentPref, | ||
| isHibernationEnabled: service.isHibernationEnabled, | ||
| isWakeUpEnabled: service.isWakeUpEnabled, | ||
| useFavicon: service.useFavicon, | ||
| iconId: service.hasCustomUploadedIcon ? 'offline-backup' : '', | ||
| })), | ||
| workspaces: workspaces.map(workspace => ({ | ||
| id: workspace.id, | ||
| name: workspace.name, | ||
| order: workspace.order, | ||
| services: [...workspace.services], | ||
| userId: workspace.userId, | ||
| })), | ||
| }; | ||
| } | ||
|
|
||
| export function hydrateOfflineState( | ||
| snapshot: OfflineStateSnapshot, | ||
| recipes: any[], | ||
| ) { | ||
| const services = snapshot.services | ||
| .map(service => { | ||
| const recipe = recipes.find(r => r.id === service.recipeId); | ||
| if (!recipe) { | ||
| return null; | ||
| } | ||
|
|
||
| try { | ||
| return new ServiceModel(service, recipe); | ||
| } catch (error) { | ||
| console.warn('Could not hydrate offline service', service.id, error); | ||
| return null; | ||
| } | ||
| }) | ||
| .filter(Boolean); | ||
|
|
||
| return { | ||
| user: new UserModel(snapshot.user as any), | ||
| services, | ||
| workspaces: snapshot.workspaces.map(workspace => new Workspace(workspace)), | ||
| }; | ||
| } |
There was a problem hiding this comment.
This new helper contains non-trivial serialization/hydration logic that will be security/compatibility sensitive over time (e.g., handling missing recipes, schema evolution). The repo already has Jest coverage for other helpers, so adding unit tests for serializeOfflineState/hydrateOfflineState (round-trip, missing recipe handling, corrupt snapshot handling) would help prevent regressions.
| appStore.authRequestFailed = false; | ||
| appStore.refreshOfflineBackup(); | ||
| } |
There was a problem hiding this comment.
_handleRequests calls appStore.refreshOfflineBackup() for every successful Request. Since this hook runs for all requests (including frequent ones like healthCheckRequest), this can trigger repeated synchronous disk writes (see saveOfflineState), causing UI jank and unnecessary I/O. Consider only refreshing the backup after the specific “source-of-truth” requests succeed (user info/services/workspaces), and debounce/throttle the backup write (e.g., at most once every N minutes or when the serialized snapshot actually changes).
Pre-flight Checklist
Please ensure you've completed all of the following.
Description of Change
This is a WIP of a fallback whenever Ferdium Server (or any other server) is down.
Motivation and Context
Whenever the Server goes down, Ferdium cannot load user and service information, making the app unusable. With this feature, some functionality is preserved (workspace and recipe adding, editing and deleting is disabled). Even though this is not ideal, it is a starting point not to lock users out of using the services.
This fixes #2369 and fixes #1826 and partially addresses #83
Code was so far generated by Codex and rail-guarded by me as I don't have much time to implement this properly.
THIS PR IS NOT YET READY TO MERGE!
Screenshots (WIP)
Checklist
pnpm prepare-code)pnpm testpassesRelease Notes