@shellui/core 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (247) hide show
  1. package/README.md +17 -0
  2. package/dist/ContentView-CZG-ro_B.js +146 -0
  3. package/dist/ContentView-CZG-ro_B.js.map +1 -0
  4. package/dist/CookiePreferencesView-MhO9FO-4.js +213 -0
  5. package/dist/CookiePreferencesView-MhO9FO-4.js.map +1 -0
  6. package/dist/DefaultLayout-Dbb3uJED.js +394 -0
  7. package/dist/DefaultLayout-Dbb3uJED.js.map +1 -0
  8. package/dist/FullscreenLayout-1SgPHWw-.js +30 -0
  9. package/dist/FullscreenLayout-1SgPHWw-.js.map +1 -0
  10. package/dist/HomeView-DYU-O_Il.js +21 -0
  11. package/dist/HomeView-DYU-O_Il.js.map +1 -0
  12. package/dist/NotFoundView-CeYjJNg0.js +52 -0
  13. package/dist/NotFoundView-CeYjJNg0.js.map +1 -0
  14. package/dist/OverlayShell-pzbqQW25.js +642 -0
  15. package/dist/OverlayShell-pzbqQW25.js.map +1 -0
  16. package/dist/SettingsView-Bndrta44.js +2207 -0
  17. package/dist/SettingsView-Bndrta44.js.map +1 -0
  18. package/dist/ViewRoute-ChSPabOy.js +32 -0
  19. package/dist/ViewRoute-ChSPabOy.js.map +1 -0
  20. package/dist/WindowsLayout-CXGNPKoY.js +633 -0
  21. package/dist/WindowsLayout-CXGNPKoY.js.map +1 -0
  22. package/dist/app.d.ts +3 -0
  23. package/dist/app.d.ts.map +1 -0
  24. package/dist/components/ContentView.d.ts +10 -0
  25. package/dist/components/ContentView.d.ts.map +1 -0
  26. package/dist/components/HomeView.d.ts +2 -0
  27. package/dist/components/HomeView.d.ts.map +1 -0
  28. package/dist/components/LoadingOverlay.d.ts +2 -0
  29. package/dist/components/LoadingOverlay.d.ts.map +1 -0
  30. package/dist/components/NotFoundView.d.ts +2 -0
  31. package/dist/components/NotFoundView.d.ts.map +1 -0
  32. package/dist/components/RouteErrorBoundary.d.ts +2 -0
  33. package/dist/components/RouteErrorBoundary.d.ts.map +1 -0
  34. package/dist/components/ViewRoute.d.ts +7 -0
  35. package/dist/components/ViewRoute.d.ts.map +1 -0
  36. package/dist/components/ui/alert-dialog.d.ts +32 -0
  37. package/dist/components/ui/alert-dialog.d.ts.map +1 -0
  38. package/dist/components/ui/breadcrumb.d.ts +20 -0
  39. package/dist/components/ui/breadcrumb.d.ts.map +1 -0
  40. package/dist/components/ui/button-group.d.ts +7 -0
  41. package/dist/components/ui/button-group.d.ts.map +1 -0
  42. package/dist/components/ui/button.d.ts +12 -0
  43. package/dist/components/ui/button.d.ts.map +1 -0
  44. package/dist/components/ui/dialog.d.ts +24 -0
  45. package/dist/components/ui/dialog.d.ts.map +1 -0
  46. package/dist/components/ui/drawer.d.ts +38 -0
  47. package/dist/components/ui/drawer.d.ts.map +1 -0
  48. package/dist/components/ui/select.d.ts +5 -0
  49. package/dist/components/ui/select.d.ts.map +1 -0
  50. package/dist/components/ui/sidebar.d.ts +46 -0
  51. package/dist/components/ui/sidebar.d.ts.map +1 -0
  52. package/dist/components/ui/sonner.d.ts +6 -0
  53. package/dist/components/ui/sonner.d.ts.map +1 -0
  54. package/dist/components/ui/switch.d.ts +8 -0
  55. package/dist/components/ui/switch.d.ts.map +1 -0
  56. package/dist/constants/urls.d.ts +6 -0
  57. package/dist/constants/urls.d.ts.map +1 -0
  58. package/dist/constants/urls.js +8 -0
  59. package/dist/constants/urls.js.map +1 -0
  60. package/dist/features/alertDialog/DialogContext.d.ts +12 -0
  61. package/dist/features/alertDialog/DialogContext.d.ts.map +1 -0
  62. package/dist/features/config/ConfigProvider.d.ts +15 -0
  63. package/dist/features/config/ConfigProvider.d.ts.map +1 -0
  64. package/dist/features/config/types.d.ts +177 -0
  65. package/dist/features/config/types.d.ts.map +1 -0
  66. package/dist/features/config/useConfig.d.ts +8 -0
  67. package/dist/features/config/useConfig.d.ts.map +1 -0
  68. package/dist/features/cookieConsent/CookieConsentModal.d.ts +6 -0
  69. package/dist/features/cookieConsent/CookieConsentModal.d.ts.map +1 -0
  70. package/dist/features/cookieConsent/CookiePreferencesView.d.ts +2 -0
  71. package/dist/features/cookieConsent/CookiePreferencesView.d.ts.map +1 -0
  72. package/dist/features/cookieConsent/cookieConsent.d.ts +22 -0
  73. package/dist/features/cookieConsent/cookieConsent.d.ts.map +1 -0
  74. package/dist/features/cookieConsent/useCookieConsent.d.ts +15 -0
  75. package/dist/features/cookieConsent/useCookieConsent.d.ts.map +1 -0
  76. package/dist/features/drawer/DrawerContext.d.ts +24 -0
  77. package/dist/features/drawer/DrawerContext.d.ts.map +1 -0
  78. package/dist/features/layouts/AppLayout.d.ts +12 -0
  79. package/dist/features/layouts/AppLayout.d.ts.map +1 -0
  80. package/dist/features/layouts/DefaultLayout.d.ts +10 -0
  81. package/dist/features/layouts/DefaultLayout.d.ts.map +1 -0
  82. package/dist/features/layouts/FullscreenLayout.d.ts +9 -0
  83. package/dist/features/layouts/FullscreenLayout.d.ts.map +1 -0
  84. package/dist/features/layouts/LayoutProviders.d.ts +9 -0
  85. package/dist/features/layouts/LayoutProviders.d.ts.map +1 -0
  86. package/dist/features/layouts/OverlayShell.d.ts +10 -0
  87. package/dist/features/layouts/OverlayShell.d.ts.map +1 -0
  88. package/dist/features/layouts/WindowsLayout.d.ts +24 -0
  89. package/dist/features/layouts/WindowsLayout.d.ts.map +1 -0
  90. package/dist/features/layouts/utils.d.ts +16 -0
  91. package/dist/features/layouts/utils.d.ts.map +1 -0
  92. package/dist/features/modal/ModalContext.d.ts +20 -0
  93. package/dist/features/modal/ModalContext.d.ts.map +1 -0
  94. package/dist/features/sentry/initSentry.d.ts +14 -0
  95. package/dist/features/sentry/initSentry.d.ts.map +1 -0
  96. package/dist/features/settings/SettingsContext.d.ts +10 -0
  97. package/dist/features/settings/SettingsContext.d.ts.map +1 -0
  98. package/dist/features/settings/SettingsIcons.d.ts +22 -0
  99. package/dist/features/settings/SettingsIcons.d.ts.map +1 -0
  100. package/dist/features/settings/SettingsProvider.d.ts +5 -0
  101. package/dist/features/settings/SettingsProvider.d.ts.map +1 -0
  102. package/dist/features/settings/SettingsRoutes.d.ts +7 -0
  103. package/dist/features/settings/SettingsRoutes.d.ts.map +1 -0
  104. package/dist/features/settings/SettingsView.d.ts +2 -0
  105. package/dist/features/settings/SettingsView.d.ts.map +1 -0
  106. package/dist/features/settings/components/Advanced.d.ts +2 -0
  107. package/dist/features/settings/components/Advanced.d.ts.map +1 -0
  108. package/dist/features/settings/components/Appearance.d.ts +2 -0
  109. package/dist/features/settings/components/Appearance.d.ts.map +1 -0
  110. package/dist/features/settings/components/DataPrivacy.d.ts +2 -0
  111. package/dist/features/settings/components/DataPrivacy.d.ts.map +1 -0
  112. package/dist/features/settings/components/Develop.d.ts +2 -0
  113. package/dist/features/settings/components/Develop.d.ts.map +1 -0
  114. package/dist/features/settings/components/LanguageAndRegion.d.ts +2 -0
  115. package/dist/features/settings/components/LanguageAndRegion.d.ts.map +1 -0
  116. package/dist/features/settings/components/ServiceWorker.d.ts +2 -0
  117. package/dist/features/settings/components/ServiceWorker.d.ts.map +1 -0
  118. package/dist/features/settings/components/UpdateApp.d.ts +2 -0
  119. package/dist/features/settings/components/UpdateApp.d.ts.map +1 -0
  120. package/dist/features/settings/components/develop/DialogTestButtons.d.ts +2 -0
  121. package/dist/features/settings/components/develop/DialogTestButtons.d.ts.map +1 -0
  122. package/dist/features/settings/components/develop/DrawerTestButtons.d.ts +2 -0
  123. package/dist/features/settings/components/develop/DrawerTestButtons.d.ts.map +1 -0
  124. package/dist/features/settings/components/develop/ModalTestButtons.d.ts +2 -0
  125. package/dist/features/settings/components/develop/ModalTestButtons.d.ts.map +1 -0
  126. package/dist/features/settings/components/develop/ToastTestButtons.d.ts +2 -0
  127. package/dist/features/settings/components/develop/ToastTestButtons.d.ts.map +1 -0
  128. package/dist/features/settings/hooks/useSettings.d.ts +2 -0
  129. package/dist/features/settings/hooks/useSettings.d.ts.map +1 -0
  130. package/dist/features/sonner/SonnerContext.d.ts +29 -0
  131. package/dist/features/sonner/SonnerContext.d.ts.map +1 -0
  132. package/dist/features/theme/ThemeProvider.d.ts +11 -0
  133. package/dist/features/theme/ThemeProvider.d.ts.map +1 -0
  134. package/dist/features/theme/themes.d.ts +114 -0
  135. package/dist/features/theme/themes.d.ts.map +1 -0
  136. package/dist/features/theme/useTheme.d.ts +10 -0
  137. package/dist/features/theme/useTheme.d.ts.map +1 -0
  138. package/dist/i18n/I18nProvider.d.ts +9 -0
  139. package/dist/i18n/I18nProvider.d.ts.map +1 -0
  140. package/dist/i18n/config.d.ts +23 -0
  141. package/dist/i18n/config.d.ts.map +1 -0
  142. package/dist/i18n/translations/en/common.json.d.ts +19 -0
  143. package/dist/i18n/translations/en/cookieConsent.json.d.ts +53 -0
  144. package/dist/i18n/translations/en/settings.json.d.ts +358 -0
  145. package/dist/i18n/translations/fr/common.json.d.ts +19 -0
  146. package/dist/i18n/translations/fr/cookieConsent.json.d.ts +53 -0
  147. package/dist/i18n/translations/fr/settings.json.d.ts +358 -0
  148. package/dist/index-lmRk5L6z.js +2160 -0
  149. package/dist/index-lmRk5L6z.js.map +1 -0
  150. package/dist/index.d.ts +7 -0
  151. package/dist/index.d.ts.map +1 -0
  152. package/dist/index.js +12 -0
  153. package/dist/index.js.map +1 -0
  154. package/dist/lib/utils.d.ts +3 -0
  155. package/dist/lib/utils.d.ts.map +1 -0
  156. package/dist/lib/z-index.d.ts +29 -0
  157. package/dist/lib/z-index.d.ts.map +1 -0
  158. package/dist/router/router.d.ts +3 -0
  159. package/dist/router/router.d.ts.map +1 -0
  160. package/dist/router/routes.d.ts +4 -0
  161. package/dist/router/routes.d.ts.map +1 -0
  162. package/dist/sidebar-ClIeZ2zb.js +303 -0
  163. package/dist/sidebar-ClIeZ2zb.js.map +1 -0
  164. package/dist/style.css +1 -0
  165. package/dist/switch-8SzUJz7Q.js +44 -0
  166. package/dist/switch-8SzUJz7Q.js.map +1 -0
  167. package/dist/types.js +2 -0
  168. package/dist/types.js.map +1 -0
  169. package/package.json +93 -0
  170. package/postcss.config.js +6 -0
  171. package/src/app.tsx +119 -0
  172. package/src/components/ContentView.tsx +258 -0
  173. package/src/components/HomeView.tsx +19 -0
  174. package/src/components/LoadingOverlay.tsx +12 -0
  175. package/src/components/NotFoundView.tsx +84 -0
  176. package/src/components/RouteErrorBoundary.tsx +95 -0
  177. package/src/components/ViewRoute.tsx +47 -0
  178. package/src/components/ui/alert-dialog.tsx +181 -0
  179. package/src/components/ui/breadcrumb.tsx +155 -0
  180. package/src/components/ui/button-group.tsx +52 -0
  181. package/src/components/ui/button.tsx +51 -0
  182. package/src/components/ui/dialog.tsx +160 -0
  183. package/src/components/ui/drawer.tsx +200 -0
  184. package/src/components/ui/select.tsx +24 -0
  185. package/src/components/ui/sidebar.tsx +406 -0
  186. package/src/components/ui/sonner.tsx +36 -0
  187. package/src/components/ui/switch.tsx +45 -0
  188. package/src/constants/urls.ts +4 -0
  189. package/src/features/alertDialog/DialogContext.tsx +468 -0
  190. package/src/features/config/ConfigProvider.ts +96 -0
  191. package/src/features/config/types.ts +195 -0
  192. package/src/features/config/useConfig.ts +15 -0
  193. package/src/features/cookieConsent/CookieConsentModal.tsx +122 -0
  194. package/src/features/cookieConsent/CookiePreferencesView.tsx +328 -0
  195. package/src/features/cookieConsent/cookieConsent.ts +84 -0
  196. package/src/features/cookieConsent/useCookieConsent.ts +39 -0
  197. package/src/features/drawer/DrawerContext.tsx +116 -0
  198. package/src/features/layouts/AppLayout.tsx +63 -0
  199. package/src/features/layouts/DefaultLayout.tsx +625 -0
  200. package/src/features/layouts/FullscreenLayout.tsx +55 -0
  201. package/src/features/layouts/LayoutProviders.tsx +20 -0
  202. package/src/features/layouts/OverlayShell.tsx +171 -0
  203. package/src/features/layouts/WindowsLayout.tsx +860 -0
  204. package/src/features/layouts/utils.ts +99 -0
  205. package/src/features/modal/ModalContext.tsx +112 -0
  206. package/src/features/sentry/initSentry.ts +72 -0
  207. package/src/features/settings/SettingsContext.tsx +19 -0
  208. package/src/features/settings/SettingsIcons.tsx +452 -0
  209. package/src/features/settings/SettingsProvider.tsx +341 -0
  210. package/src/features/settings/SettingsRoutes.tsx +66 -0
  211. package/src/features/settings/SettingsView.tsx +327 -0
  212. package/src/features/settings/components/Advanced.tsx +128 -0
  213. package/src/features/settings/components/Appearance.tsx +306 -0
  214. package/src/features/settings/components/DataPrivacy.tsx +142 -0
  215. package/src/features/settings/components/Develop.tsx +174 -0
  216. package/src/features/settings/components/LanguageAndRegion.tsx +329 -0
  217. package/src/features/settings/components/ServiceWorker.tsx +363 -0
  218. package/src/features/settings/components/UpdateApp.tsx +206 -0
  219. package/src/features/settings/components/develop/DialogTestButtons.tsx +137 -0
  220. package/src/features/settings/components/develop/DrawerTestButtons.tsx +67 -0
  221. package/src/features/settings/components/develop/ModalTestButtons.tsx +30 -0
  222. package/src/features/settings/components/develop/ToastTestButtons.tsx +179 -0
  223. package/src/features/settings/hooks/useSettings.tsx +10 -0
  224. package/src/features/sonner/SonnerContext.tsx +286 -0
  225. package/src/features/theme/ThemeProvider.tsx +16 -0
  226. package/src/features/theme/themes.ts +561 -0
  227. package/src/features/theme/useTheme.tsx +71 -0
  228. package/src/i18n/I18nProvider.tsx +32 -0
  229. package/src/i18n/config.ts +107 -0
  230. package/src/i18n/translations/en/common.json +16 -0
  231. package/src/i18n/translations/en/cookieConsent.json +50 -0
  232. package/src/i18n/translations/en/settings.json +355 -0
  233. package/src/i18n/translations/fr/common.json +16 -0
  234. package/src/i18n/translations/fr/cookieConsent.json +50 -0
  235. package/src/i18n/translations/fr/settings.json +355 -0
  236. package/src/index.css +412 -0
  237. package/src/index.html +100 -0
  238. package/src/index.ts +31 -0
  239. package/src/lib/utils.ts +6 -0
  240. package/src/lib/z-index.ts +29 -0
  241. package/src/main.tsx +26 -0
  242. package/src/router/router.tsx +8 -0
  243. package/src/router/routes.tsx +115 -0
  244. package/src/service-worker/register.ts +1199 -0
  245. package/src/service-worker/sw-dev.ts +87 -0
  246. package/src/service-worker/sw.ts +105 -0
  247. package/tailwind.config.js +60 -0
@@ -0,0 +1,1199 @@
1
+ /* eslint-disable no-console */
2
+ import { Workbox } from 'workbox-window';
3
+ import { shellui, getLogger } from '@shellui/sdk';
4
+
5
+ const logger = getLogger('shellcore');
6
+
7
+ let wb: Workbox | null = null;
8
+ let updateAvailable = false;
9
+ let waitingServiceWorker: ServiceWorker | null = null;
10
+ let registrationPromise: Promise<void> | null = null;
11
+ let statusListeners: Array<(status: { registered: boolean; updateAvailable: boolean }) => void> =
12
+ [];
13
+ let isInitialRegistration = false; // Track if this is the first registration (no reload needed)
14
+ let eventListenersAdded = false; // Track if event listeners have been added to prevent duplicates
15
+ let toastShownForServiceWorkerId: string | null = null; // Track which service worker we've shown toast for (prevents duplicates within same page load)
16
+ let isIntentionalUpdate = false; // Track if we're performing an intentional update (user clicked Install Now)
17
+ // CRITICAL: Track registration state to prevent disabling during registration or immediately after page load
18
+ // Initialize start time to page load time to provide grace period immediately after refresh
19
+ // This prevents race conditions where error handlers fire before registration completes
20
+ let isRegistering = false; // Track if registration is currently in progress
21
+ let registrationStartTime = typeof window !== 'undefined' ? Date.now() : 0; // Track when registration started (initialize to page load time)
22
+ const REGISTRATION_GRACE_PERIOD = 5000; // Don't auto-disable within 5 seconds of page load/registration start
23
+
24
+ // Store event handler references so we can remove them if needed
25
+ type EventHandler = (event?: unknown) => void;
26
+ let waitingHandler: EventHandler | null = null;
27
+ let activatedHandler: EventHandler | null = null;
28
+ let controllingHandler: EventHandler | null = null;
29
+ let registeredHandler: EventHandler | null = null;
30
+ let redundantHandler: EventHandler | null = null;
31
+ let serviceWorkerErrorHandler: EventHandler | null = null;
32
+ let messageErrorHandler: EventHandler | null = null;
33
+
34
+ /** Global set by host or by us from config so Tauri can be forced (e.g. when __TAURI__ is not yet injected in dev). */
35
+ declare global {
36
+ interface Window {
37
+ __SHELLUI_TAURI__?: boolean;
38
+ }
39
+ }
40
+
41
+ function hasTauriOnWindow(w: Window | null): boolean {
42
+ if (!w) return false;
43
+ const o = w as Window & {
44
+ __TAURI__?: unknown;
45
+ __TAURI_INTERNALS__?: unknown;
46
+ __SHELLUI_TAURI__?: boolean;
47
+ };
48
+ if (o.__SHELLUI_TAURI__ === true) return true;
49
+ return !!(o.__TAURI__ ?? o.__TAURI_INTERNALS__);
50
+ }
51
+
52
+ /** True when the app is running inside Tauri (desktop). Service worker is disabled there; a different caching system is used. */
53
+ export function isTauri(): boolean {
54
+ if (typeof window === 'undefined') return false;
55
+ if (hasTauriOnWindow(window)) return true;
56
+ try {
57
+ if (window !== window.top && hasTauriOnWindow(window.top)) return true;
58
+ if (window.parent && window.parent !== window && hasTauriOnWindow(window.parent)) return true;
59
+ } catch {
60
+ // Cross-origin: can't access top/parent
61
+ }
62
+ return false;
63
+ }
64
+
65
+ // Cache for service worker file existence check to avoid duplicate fetches
66
+ let swFileExistsCache: Promise<boolean> | null = null;
67
+ let swFileExistsCacheTime = 0;
68
+ const SW_FILE_EXISTS_CACHE_TTL = 5000; // Cache for 5 seconds
69
+
70
+ // Notify all listeners of status changes
71
+ async function notifyStatusListeners() {
72
+ const registered = await isServiceWorkerRegistered();
73
+ const status = {
74
+ registered,
75
+ updateAvailable,
76
+ };
77
+ statusListeners.forEach((listener) => listener(status));
78
+ }
79
+
80
+ export interface ServiceWorkerRegistrationOptions {
81
+ enabled: boolean;
82
+ onUpdateAvailable?: () => void;
83
+ }
84
+
85
+ /**
86
+ * Disable caching automatically when errors occur
87
+ * This helps prevent hard-to-debug issues
88
+ */
89
+ async function disableCachingAutomatically(reason: string): Promise<void> {
90
+ // CRITICAL: Don't disable if registration is in progress or just started
91
+ // This prevents race conditions where errors fire during registration
92
+ const timeSinceRegistrationStart = Date.now() - registrationStartTime;
93
+ if (isRegistering || timeSinceRegistrationStart < REGISTRATION_GRACE_PERIOD) {
94
+ console.warn(
95
+ `[Service Worker] NOT disabling - registration in progress or within grace period. Reason: ${reason}, isRegistering: ${isRegistering}, timeSinceStart: ${timeSinceRegistrationStart}ms`,
96
+ );
97
+ logger.warn(
98
+ `Not disabling service worker - registration in progress or within grace period: ${reason}`,
99
+ );
100
+ return;
101
+ }
102
+
103
+ logger.error(`Auto-disabling caching due to error: ${reason}`);
104
+
105
+ try {
106
+ // Unregister service worker first
107
+ await unregisterServiceWorker();
108
+
109
+ // Disable service worker in settings
110
+ // We need to access settings through localStorage since we're in a module
111
+ if (typeof window !== 'undefined') {
112
+ const STORAGE_KEY = 'shellui:settings';
113
+ try {
114
+ const stored = localStorage.getItem(STORAGE_KEY);
115
+ if (stored) {
116
+ const settings = JSON.parse(stored);
117
+ // Only update if service worker is currently enabled to avoid unnecessary updates
118
+ if (settings.serviceWorker?.enabled !== false) {
119
+ settings.serviceWorker = { enabled: false };
120
+ // Migrate legacy key so old cached shape is updated
121
+ if (settings.caching !== undefined) {
122
+ delete settings.caching;
123
+ }
124
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
125
+
126
+ // Notify the app to reload settings via message system
127
+ shellui.sendMessageToParent({
128
+ type: 'SHELLUI_SETTINGS_UPDATED',
129
+ payload: { settings },
130
+ });
131
+
132
+ // Also dispatch event for local listeners
133
+ window.dispatchEvent(
134
+ new CustomEvent('shellui:settings-updated', {
135
+ detail: { settings },
136
+ }),
137
+ );
138
+
139
+ // Show a toast notification
140
+ shellui.toast({
141
+ title: 'Service Worker Disabled',
142
+ description: `The service worker has been automatically disabled due to an error: ${reason}`,
143
+ type: 'error',
144
+ duration: 10000,
145
+ });
146
+ }
147
+ } else {
148
+ // No settings stored, create default with service worker disabled
149
+ const defaultSettings = {
150
+ serviceWorker: { enabled: false },
151
+ };
152
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(defaultSettings));
153
+
154
+ shellui.toast({
155
+ title: 'Service Worker Disabled',
156
+ description: `The service worker has been automatically disabled due to an error: ${reason}`,
157
+ type: 'error',
158
+ duration: 10000,
159
+ });
160
+ }
161
+ } catch (error) {
162
+ logger.error('Failed to disable service worker in settings:', { error });
163
+ // Still show toast even if settings update fails
164
+ shellui.toast({
165
+ title: 'Service Worker Error',
166
+ description: `Service worker error: ${reason}. Please disable it manually in settings.`,
167
+ type: 'error',
168
+ duration: 10000,
169
+ });
170
+ }
171
+ }
172
+ } catch (error) {
173
+ logger.error('Failed to disable caching automatically:', { error });
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Check if service worker file exists
179
+ * Uses caching to prevent duplicate fetches when called concurrently
180
+ */
181
+ export async function serviceWorkerFileExists(): Promise<boolean> {
182
+ const now = Date.now();
183
+
184
+ // Return cached promise if it's still valid and in progress
185
+ if (swFileExistsCache && now - swFileExistsCacheTime < SW_FILE_EXISTS_CACHE_TTL) {
186
+ return swFileExistsCache;
187
+ }
188
+
189
+ // Create a new fetch promise and cache it
190
+ swFileExistsCache = (async () => {
191
+ try {
192
+ // Use a timestamp to prevent caching
193
+ const response = await fetch(`/sw.js?t=${Date.now()}`, {
194
+ method: 'GET',
195
+ cache: 'no-store',
196
+ headers: {
197
+ 'Cache-Control': 'no-cache',
198
+ Pragma: 'no-cache',
199
+ },
200
+ });
201
+
202
+ // Handle 500 errors - server error, but don't disable caching immediately
203
+ // This might be a transient server issue, just return false
204
+ if (response.status >= 500) {
205
+ console.warn(
206
+ `[Service Worker] Server error (${response.status}) when fetching service worker - not disabling`,
207
+ );
208
+ logger.warn(
209
+ `Server error (${response.status}) when fetching service worker - not disabling to avoid false positives`,
210
+ );
211
+ return false;
212
+ }
213
+
214
+ // If not ok or 404, file doesn't exist
215
+ if (!response.ok || response.status === 404) {
216
+ return false;
217
+ }
218
+
219
+ // Check content type - should be JavaScript
220
+ const contentType = response.headers.get('content-type') || '';
221
+ const isJavaScript =
222
+ contentType.includes('javascript') ||
223
+ contentType.includes('application/javascript') ||
224
+ contentType.includes('text/javascript');
225
+
226
+ // If content type is HTML, it's likely Vite's dev server returning index.html
227
+ // which means the file doesn't exist
228
+ if (contentType.includes('text/html')) {
229
+ return false;
230
+ }
231
+
232
+ // Try to read a bit of the content to verify it's actually a service worker
233
+ const text = await response.text();
234
+ // Service worker files typically start with imports or have workbox/precache references
235
+ const looksLikeServiceWorker =
236
+ text.includes('workbox') ||
237
+ text.includes('precache') ||
238
+ text.includes('serviceWorker') ||
239
+ text.includes('self.addEventListener');
240
+
241
+ // If we got a response but it doesn't look like a service worker, log warning but don't disable
242
+ // This could be a dev server issue or temporary problem
243
+ if (!isJavaScript && !looksLikeServiceWorker) {
244
+ console.warn('[Service Worker] File does not look like a service worker - not disabling');
245
+ logger.warn(
246
+ 'Service worker file appears to be invalid or corrupted - not disabling to avoid false positives',
247
+ );
248
+ return false;
249
+ }
250
+
251
+ return isJavaScript || looksLikeServiceWorker;
252
+ } catch (error) {
253
+ // Network errors - don't disable caching, might be offline or transient issue
254
+ if (error instanceof TypeError && error.message.includes('Failed to fetch')) {
255
+ // Don't disable on network errors - might be offline
256
+ console.warn('[Service Worker] Network error when checking file existence - not disabling');
257
+ return false;
258
+ }
259
+ // Other errors - log but don't disable, could be transient
260
+ console.warn('[Service Worker] Error checking file existence - not disabling:', error);
261
+ logger.warn(
262
+ 'Network error checking service worker file - not disabling to avoid false positives',
263
+ { error },
264
+ );
265
+ return false;
266
+ }
267
+ })();
268
+
269
+ swFileExistsCacheTime = now;
270
+ return swFileExistsCache;
271
+ }
272
+
273
+ /**
274
+ * Register the service worker and handle updates
275
+ */
276
+ export async function registerServiceWorker(
277
+ options: ServiceWorkerRegistrationOptions = { enabled: true },
278
+ ): Promise<void> {
279
+ if (!('serviceWorker' in navigator)) {
280
+ return;
281
+ }
282
+
283
+ if (isTauri()) {
284
+ await unregisterServiceWorker();
285
+ wb = null;
286
+ return;
287
+ }
288
+
289
+ if (!options.enabled) {
290
+ // If disabled, unregister existing service worker
291
+ await unregisterServiceWorker();
292
+ wb = null;
293
+ return;
294
+ }
295
+
296
+ // Check if service worker file exists (works in both dev and production)
297
+ const swExists = await serviceWorkerFileExists();
298
+ if (!swExists) {
299
+ // In dev mode, the service worker might not be ready yet, try again after a short delay
300
+ // Only retry once to avoid infinite loops
301
+ if (!registrationPromise) {
302
+ setTimeout(async () => {
303
+ const retryExists = await serviceWorkerFileExists();
304
+ if (retryExists && !registrationPromise) {
305
+ // Retry registration if file becomes available
306
+ registerServiceWorker(options);
307
+ } else if (!retryExists) {
308
+ logger.warn('Service worker file not found. Service workers may not be available.');
309
+ }
310
+ }, 1000);
311
+ }
312
+ return;
313
+ }
314
+
315
+ // If already registering, wait for that to complete
316
+ if (registrationPromise) {
317
+ return registrationPromise;
318
+ }
319
+
320
+ registrationPromise = (async () => {
321
+ // CRITICAL: Mark that registration is starting to prevent auto-disable during registration
322
+ isRegistering = true;
323
+ registrationStartTime = Date.now();
324
+
325
+ try {
326
+ // Check if service worker is already registered
327
+ const existingRegistration = await navigator.serviceWorker.getRegistration();
328
+
329
+ // CRITICAL: If there's a waiting service worker on page load, automatically activate it
330
+ // The user refreshed the page, so they want the new version - activate it automatically
331
+ // This ensures the new version is used without requiring manual "Install Now" click
332
+ if (existingRegistration?.waiting && !existingRegistration.installing) {
333
+ // There's a waiting service worker from before the refresh
334
+ // Since the user refreshed, they want the new version - activate it automatically
335
+ console.info(
336
+ '[Service Worker] Waiting service worker found on page load - automatically activating',
337
+ );
338
+ logger.info(
339
+ 'Waiting service worker found on page load - automatically activating since user refreshed',
340
+ );
341
+
342
+ // Store reference to waiting service worker
343
+ const waitingSW = existingRegistration.waiting;
344
+ waitingServiceWorker = waitingSW;
345
+
346
+ // CRITICAL: Mark that we're auto-activating so the controlling handler knows to reload
347
+ // Store in sessionStorage so it survives if there's a reload
348
+ if (typeof window !== 'undefined') {
349
+ sessionStorage.setItem('shellui:service-worker:auto-activated', 'true');
350
+ }
351
+
352
+ // Automatically activate the waiting service worker
353
+ // This is safe because the user already refreshed, so they want the new version
354
+ // The controlling event handler will detect the auto-activation and reload the page
355
+ try {
356
+ waitingSW.postMessage({ type: 'SKIP_WAITING' });
357
+ console.info(
358
+ '[Service Worker] Sent SKIP_WAITING to waiting service worker - will reload when it takes control',
359
+ );
360
+ } catch (error) {
361
+ logger.error('Failed to activate waiting service worker:', { error });
362
+ // Clear the flag on error
363
+ if (typeof window !== 'undefined') {
364
+ sessionStorage.removeItem('shellui:service-worker:auto-activated');
365
+ }
366
+ }
367
+ }
368
+
369
+ if (existingRegistration && wb) {
370
+ // Already registered, just update
371
+ isInitialRegistration = false; // This is an update check
372
+ wb.update();
373
+ return;
374
+ }
375
+
376
+ // Check if there's already a service worker controlling the page
377
+ // If not, this is an initial registration (don't reload)
378
+ isInitialRegistration = !navigator.serviceWorker.controller;
379
+
380
+ // Register the service worker
381
+ // Only create new Workbox instance if one doesn't exist
382
+ const isNewWorkbox = !wb;
383
+ if (!wb) {
384
+ // Use updateViaCache: 'none' to ensure service worker file changes are always detected
385
+ // This bypasses the browser cache when checking for updates to sw.js/sw-dev.js
386
+ wb = new Workbox('/sw.js', {
387
+ type: 'classic',
388
+ updateViaCache: 'none', // Always check network for service worker updates
389
+ });
390
+ }
391
+
392
+ // Remove old listeners if they exist (handles React Strict Mode double-mounting)
393
+ // Always remove listeners if wb exists and we have handler references, regardless of flag
394
+ // This ensures we clean up properly even if the flag was reset
395
+ if (wb) {
396
+ if (waitingHandler) {
397
+ wb.removeEventListener('waiting', waitingHandler);
398
+ waitingHandler = null;
399
+ }
400
+ if (activatedHandler) {
401
+ wb.removeEventListener('activated', activatedHandler);
402
+ activatedHandler = null;
403
+ }
404
+ if (controllingHandler) {
405
+ wb.removeEventListener('controlling', controllingHandler);
406
+ controllingHandler = null;
407
+ }
408
+ if (registeredHandler) {
409
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
410
+ (wb as any).removeEventListener('registered', registeredHandler);
411
+ registeredHandler = null;
412
+ }
413
+ if (redundantHandler) {
414
+ wb.removeEventListener('redundant', redundantHandler);
415
+ redundantHandler = null;
416
+ }
417
+ if (serviceWorkerErrorHandler) {
418
+ navigator.serviceWorker.removeEventListener('error', serviceWorkerErrorHandler);
419
+ serviceWorkerErrorHandler = null;
420
+ }
421
+ if (messageErrorHandler) {
422
+ navigator.serviceWorker.removeEventListener('messageerror', messageErrorHandler);
423
+ messageErrorHandler = null;
424
+ }
425
+ // Reset flag so we can add listeners again
426
+ eventListenersAdded = false;
427
+ }
428
+
429
+ // Only add event listeners once (when creating a new Workbox instance or if they were removed)
430
+ if (isNewWorkbox && !eventListenersAdded) {
431
+ // Set flag IMMEDIATELY to prevent duplicate listener registration
432
+ eventListenersAdded = true;
433
+
434
+ // Handle service worker updates
435
+ // CRITICAL: Use a consistent toast ID to prevent duplicate toasts
436
+ // If the handler is called multiple times (event listener + manual call),
437
+ // using the same ID will update the existing toast instead of creating a new one
438
+ const UPDATE_AVAILABLE_TOAST_ID = 'shellui:update-available';
439
+
440
+ waitingHandler = async () => {
441
+ try {
442
+ // CRITICAL: If we're auto-activating (user refreshed), don't show toast
443
+ // The service worker will activate automatically and page will reload
444
+ const isAutoActivating =
445
+ typeof window !== 'undefined' &&
446
+ sessionStorage.getItem('shellui:service-worker:auto-activated') === 'true';
447
+
448
+ // Get the waiting service worker
449
+ const registration = await navigator.serviceWorker.getRegistration();
450
+ if (!registration || !registration.waiting) {
451
+ return;
452
+ }
453
+
454
+ const currentWaitingSW = registration.waiting;
455
+ const currentWaitingSWId = currentWaitingSW.scriptURL;
456
+
457
+ // CRITICAL: If auto-activating, update state but skip toast
458
+ if (isAutoActivating) {
459
+ console.info(
460
+ '[Service Worker] Waiting event fired during auto-activation - skipping toast',
461
+ );
462
+ updateAvailable = true;
463
+ waitingServiceWorker = currentWaitingSW;
464
+ notifyStatusListeners();
465
+ return;
466
+ }
467
+
468
+ // CRITICAL: Check flag BEFORE updating state to prevent race conditions
469
+ // If we've already shown toast for this service worker in this page load, don't show again
470
+ // This prevents duplicate toasts within the same page load, but allows showing after refresh
471
+ if (toastShownForServiceWorkerId === currentWaitingSWId) {
472
+ // Already shown, but ensure state is correct
473
+ updateAvailable = true;
474
+ waitingServiceWorker = currentWaitingSW;
475
+ notifyStatusListeners();
476
+ return;
477
+ }
478
+
479
+ // CRITICAL: Mark that we've shown toast for this service worker IMMEDIATELY
480
+ // This must happen BEFORE showing the toast to prevent race conditions
481
+ // If the handler is called twice quickly, both might pass the check before the flag is set
482
+ toastShownForServiceWorkerId = currentWaitingSWId;
483
+
484
+ // Update state
485
+ updateAvailable = true;
486
+ waitingServiceWorker = currentWaitingSW;
487
+ notifyStatusListeners();
488
+
489
+ // Show toast notification about update
490
+ if (options.onUpdateAvailable) {
491
+ options.onUpdateAvailable();
492
+ } else {
493
+ // CRITICAL: Use consistent toast ID so duplicate calls update the same toast
494
+ // This prevents multiple toasts from appearing if the handler is called multiple times
495
+ // CRITICAL: Always create fresh action handlers that reference the current waitingServiceWorker
496
+ // This ensures the action handler always has the correct service worker reference
497
+ // even if the toast is updated later
498
+ const actionHandler = () => {
499
+ logger.info('Install Now clicked, updating service worker...');
500
+ // CRITICAL: Get the current waitingServiceWorker at click time, not at toast creation time
501
+ // This ensures it works even if the toast was created earlier and then updated
502
+ if (waitingServiceWorker) {
503
+ updateServiceWorker().catch((error) => {
504
+ logger.error('Failed to update service worker:', { error });
505
+ });
506
+ } else {
507
+ logger.warn('Install Now clicked but no waiting service worker found');
508
+ // Try to get it from registration as fallback
509
+ navigator.serviceWorker.getRegistration().then((swRegistration) => {
510
+ if (swRegistration?.waiting) {
511
+ waitingServiceWorker = swRegistration.waiting;
512
+ updateServiceWorker().catch((error) => {
513
+ logger.error('Failed to update service worker:', { error });
514
+ });
515
+ }
516
+ });
517
+ }
518
+ };
519
+
520
+ shellui.toast({
521
+ id: UPDATE_AVAILABLE_TOAST_ID, // Use consistent ID to prevent duplicates
522
+ title: 'New version available',
523
+ description: 'A new version of the app is available. Install now or later?',
524
+ type: 'info',
525
+ duration: 0, // Don't auto-dismiss
526
+ position: 'bottom-left',
527
+ action: {
528
+ label: 'Install Now',
529
+ onClick: actionHandler, // Use the handler function
530
+ },
531
+ cancel: {
532
+ label: 'Later',
533
+ onClick: () => {
534
+ // User chose to install later, toast will be dismissed
535
+ },
536
+ },
537
+ });
538
+ }
539
+ } catch (error) {
540
+ logger.error('Error in waiting handler:', { error });
541
+ // On error, reset the flag so we can try again for this service worker
542
+ toastShownForServiceWorkerId = null;
543
+ }
544
+ };
545
+ wb.addEventListener('waiting', waitingHandler);
546
+
547
+ // Handle service worker activated
548
+ activatedHandler = (event?: unknown) => {
549
+ const evt = (event ?? {}) as Record<string, unknown>;
550
+ console.info('[Service Worker] Service worker activated:', {
551
+ isUpdate: evt.isUpdate,
552
+ isInitialRegistration,
553
+ });
554
+ notifyStatusListeners();
555
+ // Reset flags when service worker is activated (update installed or new registration)
556
+ updateAvailable = false;
557
+ waitingServiceWorker = null;
558
+ toastShownForServiceWorkerId = null; // Reset so we can show toast for the next update
559
+
560
+ // CRITICAL: Only reload if this is an intentional update (user clicked "Install Now")
561
+ // Check both in-memory flag and sessionStorage to ensure we only reload when user explicitly requested it
562
+ const isIntentionalUpdatePersisted =
563
+ typeof window !== 'undefined' &&
564
+ sessionStorage.getItem('shellui:service-worker:intentional-update') === 'true';
565
+ const shouldReload = isIntentionalUpdate || isIntentionalUpdatePersisted;
566
+
567
+ // Clear intentional update flag after activation (update is complete)
568
+ if (typeof window !== 'undefined') {
569
+ sessionStorage.removeItem('shellui:service-worker:intentional-update');
570
+ }
571
+
572
+ // CRITICAL: Only reload if this is an intentional update (user clicked "Install Now")
573
+ // Do NOT reload automatically when a new service worker is installed - wait for user action
574
+ // Exception: If we auto-activated on page load, the new version is already active, no reload needed
575
+ if (evt.isUpdate && !isInitialRegistration && shouldReload) {
576
+ // User explicitly clicked "Install Now", so reload to use the new version
577
+ console.info('[Service Worker] Reloading page after intentional update');
578
+ window.location.reload();
579
+ } else if (evt.isUpdate && !isInitialRegistration && !shouldReload) {
580
+ // Auto-activated on page load - new version is now active, UI will update via notifyStatusListeners
581
+ console.info(
582
+ '[Service Worker] Service worker auto-activated on page load - new version is now active',
583
+ );
584
+ }
585
+ // Reset flag after activation
586
+ isInitialRegistration = false;
587
+ };
588
+ wb.addEventListener('activated', activatedHandler as (event: unknown) => void);
589
+
590
+ // Handle service worker controlling
591
+ controllingHandler = () => {
592
+ notifyStatusListeners();
593
+
594
+ // CRITICAL: Check if this is an auto-activation from page load refresh
595
+ // If user refreshed and we auto-activated, we need to reload to get the new JavaScript
596
+ const wasAutoActivated =
597
+ typeof window !== 'undefined' &&
598
+ sessionStorage.getItem('shellui:service-worker:auto-activated') === 'true';
599
+
600
+ // CRITICAL: Only reload if this is an intentional update (user clicked "Install Now") OR auto-activation
601
+ // The controlling event fires when a service worker takes control
602
+ // Check both in-memory flag and sessionStorage to ensure we only reload when appropriate
603
+ const isIntentionalUpdatePersisted =
604
+ typeof window !== 'undefined' &&
605
+ sessionStorage.getItem('shellui:service-worker:intentional-update') === 'true';
606
+ const shouldReload =
607
+ isIntentionalUpdate || isIntentionalUpdatePersisted || wasAutoActivated;
608
+
609
+ // Clear auto-activation flag if it was set
610
+ if (wasAutoActivated && typeof window !== 'undefined') {
611
+ sessionStorage.removeItem('shellui:service-worker:auto-activated');
612
+ }
613
+
614
+ // CRITICAL: Reload if this is an intentional update OR auto-activation from page refresh
615
+ // After reload, the new version will be active and UI will show correct state
616
+ if (!isInitialRegistration && shouldReload) {
617
+ if (wasAutoActivated) {
618
+ console.info(
619
+ '[Service Worker] Auto-activated service worker took control - reloading to use new version',
620
+ );
621
+ } else {
622
+ console.info(
623
+ '[Service Worker] User clicked Install Now - reloading to use new version',
624
+ );
625
+ }
626
+ // Reload to ensure new JavaScript is loaded
627
+ window.location.reload();
628
+ }
629
+ // Reset flag after controlling
630
+ isInitialRegistration = false;
631
+ };
632
+ wb.addEventListener('controlling', controllingHandler);
633
+
634
+ // Handle service worker registered
635
+ registeredHandler = () => {
636
+ notifyStatusListeners();
637
+ };
638
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
639
+ (wb as any).addEventListener('registered', registeredHandler);
640
+
641
+ // CRITICAL: Handle service worker 'redundant' event
642
+ // This event fires when a service worker is replaced, which is NORMAL during updates
643
+ // The 'redundant' event fires for the OLD service worker when a NEW one takes control
644
+ // During intentional updates (user clicked "Install Now"), this is EXPECTED behavior
645
+ // We MUST check for intentional updates BEFORE disabling, otherwise we'll disable the
646
+ // service worker right after the user explicitly asked to install an update!
647
+ redundantHandler = (event?: unknown) => {
648
+ logger.info('Service worker became redundant:', event as Record<string, unknown>);
649
+
650
+ // CRITICAL CHECK: Verify this is an intentional update BEFORE doing anything
651
+ // Check sessionStorage FIRST (survives page reloads) - this is set BEFORE skip waiting
652
+ // Then check in-memory flag as backup
653
+ // This prevents race conditions where the flag might not be set yet
654
+ const isIntentionalUpdatePersisted =
655
+ typeof window !== 'undefined' &&
656
+ sessionStorage.getItem('shellui:service-worker:intentional-update') === 'true';
657
+
658
+ // CRITICAL: Also check if there's a waiting service worker - if there is, we're in update flow
659
+ // This provides an additional safety check in case sessionStorage check fails
660
+ // A waiting service worker means an update is in progress, so redundant is expected
661
+ // We check synchronously first (waitingServiceWorker variable), then async if needed
662
+ const hasWaitingServiceWorkerSync = !!waitingServiceWorker;
663
+
664
+ // CRITICAL: If ANY of these indicate an intentional update, DO NOT disable
665
+ // The combination of checks prevents false positives from disabling during updates
666
+ // This is the KEY fix - we check multiple signals to be absolutely sure it's an update
667
+ const isUpdateFlow =
668
+ isIntentionalUpdate || isIntentionalUpdatePersisted || hasWaitingServiceWorkerSync;
669
+
670
+ // CRITICAL: Only disable if this is NOT part of a normal update flow
671
+ // Disabling during an update would break the user's explicit "Install Now" action
672
+ // This bug has been seen many times - the redundant event fires during normal updates
673
+ // and without these checks, it would disable the service worker right after install
674
+ if (!isUpdateFlow) {
675
+ // Double-check asynchronously in case the sync check missed it
676
+ // CRITICAL: Be very defensive here - only disable if we're absolutely sure it's an error
677
+ navigator.serviceWorker
678
+ .getRegistration()
679
+ .then((registration) => {
680
+ const hasWaitingAsync = !!registration?.waiting;
681
+ const hasInstallingAsync = !!registration?.installing;
682
+ const hasActiveAsync = !!registration?.active;
683
+
684
+ // CRITICAL: Only disable if there's NO waiting, NO installing, and NO active service worker
685
+ // If any of these exist, it means an update is in progress and redundant is expected
686
+ if (!hasWaitingAsync && !hasInstallingAsync && !hasActiveAsync) {
687
+ // No service worker at all - this is truly unexpected and likely an error
688
+ console.warn(
689
+ '[Service Worker] Redundant event: No service workers found, disabling',
690
+ );
691
+ logger.warn(
692
+ 'Service worker became redundant unexpectedly (no service workers found)',
693
+ );
694
+ disableCachingAutomatically(
695
+ 'Service worker became redundant (no service workers found)',
696
+ );
697
+ } else {
698
+ // Service workers exist - this is likely part of an update flow, don't disable
699
+ console.info(
700
+ '[Service Worker] Redundant event: Service workers exist, likely update in progress - ignoring',
701
+ );
702
+ logger.info(
703
+ 'Service worker became redundant but other service workers exist - likely update in progress, ignoring',
704
+ );
705
+ }
706
+ })
707
+ .catch((error) => {
708
+ // CRITICAL: If async check fails, DON'T disable - this could be a transient error
709
+ // Log the error but don't disable the service worker
710
+ console.warn(
711
+ '[Service Worker] Redundant event: Async check failed, but NOT disabling:',
712
+ error,
713
+ );
714
+ logger.warn(
715
+ 'Service worker redundant event: Async check failed, but NOT disabling to avoid false positives',
716
+ { error },
717
+ );
718
+ });
719
+ } else {
720
+ console.info(
721
+ '[Service Worker] Redundant event: Intentional update detected - ignoring',
722
+ );
723
+ logger.info(
724
+ 'Service worker became redundant as part of normal update - this is expected and safe',
725
+ );
726
+ // CRITICAL: Don't clear the sessionStorage flag immediately - wait until activation
727
+ // Clearing it too early could cause issues if redundant fires multiple times
728
+ // The activated handler will clear it when the update is complete
729
+ }
730
+ };
731
+ wb.addEventListener('redundant', redundantHandler);
732
+
733
+ // Handle external service worker errors
734
+ // Only disable caching on critical errors, not during normal update operations
735
+ serviceWorkerErrorHandler = (event?: unknown) => {
736
+ logger.error('Service worker error event:', event as Record<string, unknown>);
737
+
738
+ // Check if this is an intentional update (check both in-memory flag and sessionStorage)
739
+ const isIntentionalUpdatePersisted =
740
+ typeof window !== 'undefined' &&
741
+ sessionStorage.getItem('shellui:service-worker:intentional-update') === 'true';
742
+ const isUpdateFlow = isIntentionalUpdate || isIntentionalUpdatePersisted;
743
+
744
+ // CRITICAL: Be very defensive - only disable on truly critical errors
745
+ // Many service worker errors are non-fatal and don't require disabling
746
+ if (!isUpdateFlow) {
747
+ // Only disable on actual errors, not warnings or non-critical issues
748
+ const evt = (event ?? {}) as Record<string, unknown>;
749
+ const evtError = evt.error as Record<string, unknown> | undefined;
750
+ const errorMessage = (evt.message as string) || (evtError?.message as string) || 'Unknown error';
751
+ const errorName = (evtError?.name as string) || '';
752
+
753
+ // CRITICAL: Only disable on critical errors, ignore common non-fatal errors
754
+ // Many errors during service worker lifecycle are expected and don't require disabling
755
+ const isCriticalError =
756
+ !errorMessage.includes('update') &&
757
+ !errorMessage.includes('activate') &&
758
+ !errorMessage.includes('install') &&
759
+ !errorName.includes('AbortError') &&
760
+ !errorName.includes('NetworkError');
761
+
762
+ if (isCriticalError) {
763
+ disableCachingAutomatically(`Service worker error: ${errorMessage}`);
764
+ } else {
765
+ console.warn('[Service Worker] Non-critical error, ignoring:', errorMessage);
766
+ logger.warn('Service worker non-critical error, not disabling:', {
767
+ errorMessage,
768
+ errorName,
769
+ });
770
+ }
771
+ } else {
772
+ console.info('[Service Worker] Error during update flow, ignoring');
773
+ logger.info('Service worker error during update flow, ignoring');
774
+ }
775
+ };
776
+ navigator.serviceWorker.addEventListener('error', serviceWorkerErrorHandler);
777
+
778
+ // Handle message errors from service worker
779
+ messageErrorHandler = (event?: unknown) => {
780
+ logger.error('Service worker message error:', event as Record<string, unknown>);
781
+ // Don't disable on message errors - they're usually not critical
782
+ };
783
+ navigator.serviceWorker.addEventListener('messageerror', messageErrorHandler);
784
+ } // End of event listeners block
785
+
786
+ // Register the service worker
787
+ await wb.register();
788
+
789
+ // Get the underlying registration to set updateViaCache
790
+ // This ensures changes to sw.js/sw-dev.js are always detected (bypasses cache)
791
+ const registration = await navigator.serviceWorker.getRegistration();
792
+ if (registration) {
793
+ // Set updateViaCache to 'none' to ensure service worker file changes are always detected
794
+ // This tells the browser to always check the network for sw.js/sw-dev.js updates
795
+ // Note: This property is read-only in some browsers, but setting it helps where supported
796
+ try {
797
+ // Access the registration's update method to ensure cache-busting
798
+ // The browser will check the service worker file with cache: 'reload' when update() is called
799
+ } catch (_e) {
800
+ // Ignore if updateViaCache can't be set (some browsers don't support it)
801
+ }
802
+
803
+ // Check if there's a waiting service worker after registration
804
+ // If we already handled it above (auto-activation on page load), skip showing toast
805
+ // Otherwise, show toast to let user know an update is available
806
+ if (registration.waiting && waitingHandler) {
807
+ const waitingSWId = registration.waiting.scriptURL;
808
+
809
+ // Check if we already auto-activated this waiting service worker above
810
+ // If so, don't show toast - it will activate automatically
811
+ const wasAutoActivated =
812
+ waitingServiceWorker === registration.waiting && updateAvailable === true;
813
+
814
+ if (!wasAutoActivated) {
815
+ // This is a new waiting service worker that appeared after registration
816
+ // Show toast to notify user
817
+ // CRITICAL: Check flag BEFORE calling handler to prevent duplicate toasts
818
+ // The waiting event might have already fired and shown the toast
819
+ if (toastShownForServiceWorkerId !== waitingSWId) {
820
+ // Update state first
821
+ updateAvailable = true;
822
+ waitingServiceWorker = registration.waiting;
823
+ // Trigger the waiting handler to show toast
824
+ // The handler will check the flag again and show toast if needed
825
+ waitingHandler();
826
+ } else {
827
+ // Toast already shown, just update state
828
+ updateAvailable = true;
829
+ waitingServiceWorker = registration.waiting;
830
+ notifyStatusListeners();
831
+ }
832
+ } else {
833
+ // Already auto-activated, just ensure state is correct
834
+ updateAvailable = true;
835
+ waitingServiceWorker = registration.waiting;
836
+ notifyStatusListeners();
837
+ }
838
+ }
839
+ }
840
+
841
+ notifyStatusListeners();
842
+
843
+ // Check for updates periodically (including service worker file changes)
844
+ // This ensures changes to sw.js/sw-dev.js are detected
845
+ /* const _updateInterval = */ setInterval(
846
+ () => {
847
+ if (wb && options.enabled) {
848
+ // wb.update() checks for updates to the service worker file itself
849
+ // The browser will compare the byte-by-byte content of sw.js/sw-dev.js
850
+ wb.update();
851
+ }
852
+ },
853
+ 60 * 60 * 1000,
854
+ ); // Check every hour
855
+
856
+ // Also check for updates when the page becomes visible (user returns to tab)
857
+ // This helps detect service worker file changes more quickly
858
+ let visibilityHandler: (() => void) | null = null;
859
+ if (typeof document !== 'undefined') {
860
+ visibilityHandler = () => {
861
+ if (!document.hidden && wb && options.enabled) {
862
+ wb.update();
863
+ }
864
+ };
865
+ document.addEventListener('visibilitychange', visibilityHandler);
866
+ }
867
+
868
+ // CRITICAL: Mark registration as complete only after everything is set up
869
+ // This prevents error handlers from disabling during the registration process
870
+ isRegistering = false;
871
+
872
+ // Store interval and handler for cleanup (though cleanup is best-effort)
873
+ // The interval will continue until page reload, which is acceptable
874
+ } catch (error) {
875
+ // Handle registration errors - be very selective about disabling
876
+ const errorMessage = error instanceof Error ? error.message : String(error);
877
+ const errorName = error instanceof Error ? error.name : '';
878
+ logger.error('Registration failed:', { error });
879
+
880
+ // CRITICAL: Only disable on truly critical errors that indicate the service worker is broken
881
+ // Many registration errors are transient or non-fatal
882
+ const isCriticalError =
883
+ (errorMessage.includes('Failed to register') &&
884
+ !errorMessage.includes('already registered')) ||
885
+ (errorMessage.includes('script error') && !errorMessage.includes('network')) ||
886
+ errorMessage.includes('SyntaxError') ||
887
+ (errorMessage.includes('TypeError') && !errorMessage.includes('fetch')) ||
888
+ (error instanceof DOMException &&
889
+ error.name !== 'SecurityError' &&
890
+ error.name !== 'AbortError');
891
+
892
+ if (isCriticalError) {
893
+ await disableCachingAutomatically(`Registration failed: ${errorMessage}`);
894
+ } else {
895
+ console.warn(
896
+ '[Service Worker] Non-critical registration error, NOT disabling:',
897
+ errorMessage,
898
+ );
899
+ logger.warn('Non-critical registration error, not disabling:', { errorMessage, errorName });
900
+ }
901
+ } finally {
902
+ // CRITICAL: Reset registration flag in finally block to ensure it's always reset
903
+ // But keep a grace period to prevent immediate disable after registration completes
904
+ // The grace period is handled in disableCachingAutomatically
905
+ isRegistering = false;
906
+ registrationPromise = null;
907
+ }
908
+ })();
909
+
910
+ return registrationPromise;
911
+ }
912
+
913
+ /**
914
+ * Update the service worker immediately
915
+ * This will reload the page when the update is installed
916
+ */
917
+ export async function updateServiceWorker(): Promise<void> {
918
+ if (!wb || !waitingServiceWorker) {
919
+ return;
920
+ }
921
+
922
+ try {
923
+ // CRITICAL: Set intentional update flag FIRST, before any other operations
924
+ // This flag MUST be set in sessionStorage BEFORE skip waiting is called
925
+ // The 'redundant' event can fire very quickly after skip waiting, and if this flag
926
+ // isn't set yet, the redundant handler will think it's an error and disable the SW
927
+ // This is the ROOT CAUSE of the bug where service worker gets disabled on "Install Now"
928
+ const INTENTIONAL_UPDATE_KEY = 'shellui:service-worker:intentional-update';
929
+ if (typeof window !== 'undefined') {
930
+ // CRITICAL: Set this IMMEDIATELY - don't wait for anything else
931
+ sessionStorage.setItem(INTENTIONAL_UPDATE_KEY, 'true');
932
+ }
933
+
934
+ // CRITICAL: Also set in-memory flag immediately
935
+ // This provides backup protection in case sessionStorage has issues
936
+ isIntentionalUpdate = true;
937
+
938
+ // CRITICAL: Ensure service worker setting is preserved and enabled before reload
939
+ // This prevents the service worker from being disabled after refresh
940
+ // The user explicitly clicked "Install Now", so we must keep the service worker enabled
941
+ if (typeof window !== 'undefined') {
942
+ const STORAGE_KEY = 'shellui:settings';
943
+ try {
944
+ const stored = localStorage.getItem(STORAGE_KEY);
945
+ if (stored) {
946
+ const settings = JSON.parse(stored);
947
+ // CRITICAL: Always ensure service worker is enabled when user clicks Install Now
948
+ // This ensures it stays enabled after the page reloads
949
+ // Without this, the service worker could be disabled after the reload
950
+ settings.serviceWorker = { enabled: true };
951
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
952
+
953
+ // Notify the app about the settings update (in case it's listening)
954
+ shellui.sendMessageToParent({
955
+ type: 'SHELLUI_SETTINGS_UPDATED',
956
+ payload: { settings },
957
+ });
958
+ } else {
959
+ // No settings stored, create default with service worker enabled
960
+ const defaultSettings = {
961
+ serviceWorker: { enabled: true },
962
+ };
963
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(defaultSettings));
964
+ }
965
+ } catch (error) {
966
+ logger.warn('Failed to preserve settings before update:', { error });
967
+ // CRITICAL: Even if settings update fails, the intentional update flag is already set
968
+ // This prevents the redundant handler from disabling the service worker
969
+ // The app.tsx will default to enabled anyway, so continue with the update
970
+ }
971
+ }
972
+
973
+ // Mark that this is an update (not initial registration)
974
+ isInitialRegistration = false;
975
+
976
+ // Set up reload handler before sending skip waiting
977
+ const reloadApp = () => {
978
+ // Use shellUI refresh message if available, otherwise fallback to window.location.reload
979
+ const sent = shellui.sendMessageToParent({
980
+ type: 'SHELLUI_REFRESH_PAGE',
981
+ payload: {},
982
+ });
983
+ if (!sent) {
984
+ window.location.reload();
985
+ }
986
+ };
987
+
988
+ // Add one-time listener for controlling event
989
+ const updateControllingHandler = () => {
990
+ reloadApp();
991
+ wb?.removeEventListener('controlling', updateControllingHandler);
992
+ // Reset flag after reload is triggered
993
+ setTimeout(() => {
994
+ isIntentionalUpdate = false;
995
+ }, 1000);
996
+ };
997
+ wb.addEventListener('controlling', updateControllingHandler);
998
+
999
+ // Send skip waiting message to the waiting service worker
1000
+ waitingServiceWorker.postMessage({ type: 'SKIP_WAITING' });
1001
+
1002
+ // Fallback: if controlling event doesn't fire within 2 seconds, reload anyway
1003
+ setTimeout(() => {
1004
+ if (controllingHandler) wb?.removeEventListener('controlling', controllingHandler);
1005
+ // Check if service worker is now controlling
1006
+ if (navigator.serviceWorker.controller) {
1007
+ reloadApp();
1008
+ }
1009
+ // Reset flag if fallback is used
1010
+ setTimeout(() => {
1011
+ isIntentionalUpdate = false;
1012
+ }, 1000);
1013
+ }, 2000);
1014
+ } catch (error) {
1015
+ logger.error('Failed to update service worker:', { error });
1016
+ // Reset flag on error
1017
+ isIntentionalUpdate = false;
1018
+ }
1019
+ }
1020
+
1021
+ /**
1022
+ * Unregister the service worker silently (no page reload)
1023
+ */
1024
+ export async function unregisterServiceWorker(): Promise<void> {
1025
+ if (!('serviceWorker' in navigator)) {
1026
+ return;
1027
+ }
1028
+
1029
+ try {
1030
+ // Set flag to prevent any reloads during unregistration
1031
+ isInitialRegistration = true;
1032
+
1033
+ const registration = await navigator.serviceWorker.getRegistration();
1034
+ if (registration) {
1035
+ await registration.unregister();
1036
+
1037
+ // Clear all caches silently
1038
+ if ('caches' in window) {
1039
+ const cacheNames = await caches.keys();
1040
+ await Promise.all(cacheNames.map((name) => caches.delete(name)));
1041
+ }
1042
+ }
1043
+
1044
+ // Clean up workbox instance and remove all event listeners
1045
+ if (wb) {
1046
+ // Remove all event listeners before cleaning up
1047
+ if (waitingHandler) wb.removeEventListener('waiting', waitingHandler);
1048
+ if (activatedHandler) wb.removeEventListener('activated', activatedHandler);
1049
+ if (controllingHandler) wb.removeEventListener('controlling', controllingHandler);
1050
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1051
+ if (registeredHandler) (wb as any).removeEventListener('registered', registeredHandler);
1052
+ if (redundantHandler) wb.removeEventListener('redundant', redundantHandler);
1053
+ if (serviceWorkerErrorHandler) {
1054
+ navigator.serviceWorker.removeEventListener('error', serviceWorkerErrorHandler);
1055
+ }
1056
+ if (messageErrorHandler) {
1057
+ navigator.serviceWorker.removeEventListener('messageerror', messageErrorHandler);
1058
+ }
1059
+ // Clear handler references
1060
+ waitingHandler = null;
1061
+ activatedHandler = null;
1062
+ controllingHandler = null;
1063
+ registeredHandler = null;
1064
+ redundantHandler = null;
1065
+ serviceWorkerErrorHandler = null;
1066
+ messageErrorHandler = null;
1067
+ // Remove workbox instance
1068
+ wb = null;
1069
+ }
1070
+
1071
+ updateAvailable = false;
1072
+ waitingServiceWorker = null;
1073
+ isInitialRegistration = false;
1074
+ toastShownForServiceWorkerId = null; // Reset toast flag on unregister
1075
+ eventListenersAdded = false; // Reset event listeners flag on unregister
1076
+ isIntentionalUpdate = false; // Reset intentional update flag on unregister
1077
+ notifyStatusListeners();
1078
+ } catch (error) {
1079
+ logger.error('Failed to unregister service worker:', { error });
1080
+ isInitialRegistration = false;
1081
+ }
1082
+ }
1083
+
1084
+ /**
1085
+ * Check if service worker is registered and active
1086
+ */
1087
+ export async function isServiceWorkerRegistered(): Promise<boolean> {
1088
+ if (!('serviceWorker' in navigator)) {
1089
+ return false;
1090
+ }
1091
+ if (isTauri()) {
1092
+ return false;
1093
+ }
1094
+
1095
+ // First check if service worker file exists (only in production)
1096
+ const swExists = await serviceWorkerFileExists();
1097
+ if (!swExists) {
1098
+ return false;
1099
+ }
1100
+
1101
+ try {
1102
+ const registration = await navigator.serviceWorker.getRegistration();
1103
+ if (!registration) {
1104
+ return false;
1105
+ }
1106
+
1107
+ // Check if service worker is active (either controlling or installed)
1108
+ // A service worker can be registered but not yet active
1109
+ if (registration.active) {
1110
+ return true;
1111
+ }
1112
+
1113
+ // Also check if there's a waiting or installing service worker
1114
+ // This means registration is in progress or update is available
1115
+ if (registration.waiting || registration.installing) {
1116
+ return true;
1117
+ }
1118
+
1119
+ // If navigator.serviceWorker.controller exists, the SW is controlling the page
1120
+ if (navigator.serviceWorker.controller) {
1121
+ return true;
1122
+ }
1123
+
1124
+ return false;
1125
+ } catch (_error) {
1126
+ // Silently return false if there's an error checking registration
1127
+ return false;
1128
+ }
1129
+ }
1130
+
1131
+ /**
1132
+ * Get service worker status
1133
+ */
1134
+ export async function getServiceWorkerStatus(): Promise<{
1135
+ registered: boolean;
1136
+ updateAvailable: boolean;
1137
+ }> {
1138
+ const registered = await isServiceWorkerRegistered();
1139
+
1140
+ // Actually check if there's a waiting service worker (more reliable than in-memory flag)
1141
+ // This ensures we get the correct state even after page reloads
1142
+ let actuallyUpdateAvailable = false;
1143
+ if (registered && !isTauri()) {
1144
+ try {
1145
+ const registration = await navigator.serviceWorker.getRegistration();
1146
+ if (registration?.waiting) {
1147
+ // There's a waiting service worker, so an update is available
1148
+ actuallyUpdateAvailable = true;
1149
+ // Sync the in-memory flag with the actual state
1150
+ updateAvailable = true;
1151
+ waitingServiceWorker = registration.waiting;
1152
+ } else {
1153
+ // No waiting service worker, so no update is available
1154
+ actuallyUpdateAvailable = false;
1155
+ // Sync the in-memory flag with the actual state
1156
+ updateAvailable = false;
1157
+ waitingServiceWorker = null;
1158
+ }
1159
+ } catch (error) {
1160
+ logger.error('Failed to check service worker registration:', { error });
1161
+ // Fall back to in-memory flag if check fails
1162
+ actuallyUpdateAvailable = updateAvailable;
1163
+ }
1164
+ } else {
1165
+ // Not registered or Tauri, so no update available
1166
+ actuallyUpdateAvailable = false;
1167
+ updateAvailable = false;
1168
+ waitingServiceWorker = null;
1169
+ }
1170
+
1171
+ return {
1172
+ registered,
1173
+ updateAvailable: actuallyUpdateAvailable,
1174
+ };
1175
+ }
1176
+
1177
+ /**
1178
+ * Manually trigger a check for service worker updates (browser only).
1179
+ * Resolves when the check is complete. Listen to addStatusListener for updateAvailable changes.
1180
+ */
1181
+ export async function checkForServiceWorkerUpdate(): Promise<void> {
1182
+ if (isTauri() || !wb) return;
1183
+ await wb.update();
1184
+ }
1185
+
1186
+ /**
1187
+ * Add a listener for service worker status changes
1188
+ */
1189
+ export function addStatusListener(
1190
+ listener: (status: { registered: boolean; updateAvailable: boolean }) => void,
1191
+ ): () => void {
1192
+ statusListeners.push(listener);
1193
+ // Don't call immediately - let the component do the initial check to avoid duplicate fetches
1194
+
1195
+ // Return unsubscribe function
1196
+ return () => {
1197
+ statusListeners = statusListeners.filter((l) => l !== listener);
1198
+ };
1199
+ }