mirror of
https://github.com/aleksey-hoffman/sigma-file-manager.git
synced 2026-10-18 17:20:21 -05:00
Fix: stuck app loading state on device wake up
This commit is contained in:
1 parent
40c9785778
commit
77464457c0
9 files changed
+406
No files matched your search
@@ -60,6 +60,7 @@
|
||||
<div id="app-splash__spinner"></div>
|
||||
</div>
|
||||
<div id="app"></div>
|
||||
<script src="/startup-recovery.js"></script>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
(function () {
|
||||
const recoveryStorageKey = 'sigma-file-manager:module-load-recovery-at';
|
||||
const recoveryWindowMs = 30000;
|
||||
const reloadDelayMs = 1500;
|
||||
const recoveryMessage = 'Failed to load Sigma File Manager. Please restart the app.';
|
||||
|
||||
function getLastRecoveryAt() {
|
||||
try {
|
||||
const storedValue = sessionStorage.getItem(recoveryStorageKey);
|
||||
const recoveryTime = Number(storedValue || '0');
|
||||
|
||||
return Number.isFinite(recoveryTime) ? recoveryTime : 0;
|
||||
}
|
||||
catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function setLastRecoveryAt(recoveryTime) {
|
||||
try {
|
||||
sessionStorage.setItem(recoveryStorageKey, String(recoveryTime));
|
||||
return true;
|
||||
}
|
||||
catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function showRecoveryMessage() {
|
||||
const splashElement = document.getElementById('app-splash');
|
||||
|
||||
if (!splashElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
splashElement.textContent = '';
|
||||
const messageElement = document.createElement('div');
|
||||
messageElement.textContent = recoveryMessage;
|
||||
messageElement.style.maxWidth = '320px';
|
||||
messageElement.style.padding = '0 24px';
|
||||
messageElement.style.color = 'rgba(255, 255, 255, 0.78)';
|
||||
messageElement.style.font = '14px system-ui, sans-serif';
|
||||
messageElement.style.lineHeight = '1.45';
|
||||
messageElement.style.textAlign = 'center';
|
||||
splashElement.appendChild(messageElement);
|
||||
}
|
||||
|
||||
function recoverFromModuleLoadFailure() {
|
||||
const currentTime = Date.now();
|
||||
|
||||
if (currentTime - getLastRecoveryAt() < recoveryWindowMs) {
|
||||
showRecoveryMessage();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!setLastRecoveryAt(currentTime)) {
|
||||
showRecoveryMessage();
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(function () {
|
||||
window.location.reload();
|
||||
}, reloadDelayMs);
|
||||
}
|
||||
|
||||
window.addEventListener('error', function (event) {
|
||||
const target = event.target;
|
||||
|
||||
if (target instanceof HTMLScriptElement && target.type === 'module') {
|
||||
recoverFromModuleLoadFailure();
|
||||
}
|
||||
}, true);
|
||||
}());
|
||||
@@ -592,6 +592,21 @@ describe('useInit startup restoration', () => {
|
||||
expect(removeAppSplashMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('removes the app splash when init fails', async () => {
|
||||
platformInitMock.mockRejectedValue(new Error('Platform init failed'));
|
||||
|
||||
const { useInit } = await import('@/composables/use-init');
|
||||
const { init } = useInit();
|
||||
const initPromise = init();
|
||||
|
||||
await flushAsyncWork();
|
||||
await initPromise;
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(removeAppSplashMock).toHaveBeenCalledTimes(1);
|
||||
expect(showWindowMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('schedules background startup work after init resolves', async () => {
|
||||
invokeMock.mockImplementation(async (command: string) => {
|
||||
if (command === 'get_launch_context') {
|
||||
|
||||
@@ -263,6 +263,16 @@ export function useInit() {
|
||||
}
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
await runInit();
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to initialize app:', error);
|
||||
removeAppSplash();
|
||||
}
|
||||
}
|
||||
|
||||
async function runInit() {
|
||||
const isMainWindow = isMainWebviewWindow();
|
||||
|
||||
logInitTrace(`init started (mainWindow=${isMainWindow})`);
|
||||
|
||||
@@ -8,9 +8,12 @@ import App from './app.vue';
|
||||
import router from './router';
|
||||
import { i18n } from '@/localization';
|
||||
import VWave from 'v-wave';
|
||||
import { installModuleLoadRecovery } from '@/utils/module-load-recovery';
|
||||
|
||||
import './styles/index.css';
|
||||
|
||||
installModuleLoadRecovery({ router });
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
app.use(createPinia());
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// License: GNU GPLv3 or later. See the license file in the project root for more information.
|
||||
// Copyright © 2021 - present Aleksey Hoffman. All rights reserved.
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import {
|
||||
MODULE_LOAD_RECOVERY_MESSAGE,
|
||||
MODULE_LOAD_RECOVERY_RELOAD_DELAY_MS,
|
||||
MODULE_LOAD_RECOVERY_STORAGE_KEY,
|
||||
MODULE_LOAD_RECOVERY_WINDOW_MS,
|
||||
} from '@/utils/module-load-recovery.constants';
|
||||
import {
|
||||
installModuleLoadRecovery,
|
||||
isModuleLoadFailure,
|
||||
recoverFromModuleLoadFailure,
|
||||
} from '@/utils/module-load-recovery';
|
||||
import type { Router } from 'vue-router';
|
||||
|
||||
const startupRecoveryScript = readFileSync(
|
||||
resolve(process.cwd(), 'public/startup-recovery.js'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
describe('module load recovery', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
sessionStorage.clear();
|
||||
document.body.innerHTML = '<div id="app-splash"><div id="app-splash__spinner"></div></div>';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
it('keeps startup-recovery.js aligned with shared constants', () => {
|
||||
expect(startupRecoveryScript).toContain(MODULE_LOAD_RECOVERY_STORAGE_KEY);
|
||||
expect(startupRecoveryScript).toContain(String(MODULE_LOAD_RECOVERY_WINDOW_MS));
|
||||
expect(startupRecoveryScript).toContain(String(MODULE_LOAD_RECOVERY_RELOAD_DELAY_MS));
|
||||
expect(startupRecoveryScript).toContain(MODULE_LOAD_RECOVERY_MESSAGE);
|
||||
});
|
||||
|
||||
it('detects failed dynamic import errors', () => {
|
||||
expect(isModuleLoadFailure(new TypeError(
|
||||
'Failed to fetch dynamically imported module: app://asset/assets/navigator.js',
|
||||
))).toBe(true);
|
||||
expect(isModuleLoadFailure('error loading dynamically imported module')).toBe(true);
|
||||
expect(isModuleLoadFailure('Importing a module script failed.')).toBe(true);
|
||||
});
|
||||
|
||||
it('detects failed Vite CSS preload errors', () => {
|
||||
expect(isModuleLoadFailure(new Error(
|
||||
'Unable to preload CSS for /assets/settings.css',
|
||||
))).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores unrelated errors', () => {
|
||||
expect(isModuleLoadFailure(new Error('Failed to read directory'))).toBe(false);
|
||||
expect(isModuleLoadFailure(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('reloads after the delay on the first recovery attempt', () => {
|
||||
const reloadMock = vi.fn();
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: { reload: reloadMock },
|
||||
});
|
||||
|
||||
recoverFromModuleLoadFailure();
|
||||
|
||||
expect(reloadMock).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(MODULE_LOAD_RECOVERY_RELOAD_DELAY_MS);
|
||||
expect(reloadMock).toHaveBeenCalledTimes(1);
|
||||
expect(document.getElementById('app-splash')?.textContent).not.toContain(
|
||||
MODULE_LOAD_RECOVERY_MESSAGE,
|
||||
);
|
||||
});
|
||||
|
||||
it('shows a recovery message when throttled', () => {
|
||||
const reloadMock = vi.fn();
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: { reload: reloadMock },
|
||||
});
|
||||
sessionStorage.setItem(
|
||||
MODULE_LOAD_RECOVERY_STORAGE_KEY,
|
||||
String(Date.now()),
|
||||
);
|
||||
|
||||
recoverFromModuleLoadFailure();
|
||||
|
||||
expect(reloadMock).not.toHaveBeenCalled();
|
||||
expect(document.getElementById('app-splash')?.textContent).toContain(
|
||||
MODULE_LOAD_RECOVERY_MESSAGE,
|
||||
);
|
||||
});
|
||||
|
||||
it('shows a recovery message when session storage is unavailable', () => {
|
||||
const reloadMock = vi.fn();
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: { reload: reloadMock },
|
||||
});
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new Error('Storage unavailable');
|
||||
});
|
||||
|
||||
recoverFromModuleLoadFailure();
|
||||
|
||||
expect(reloadMock).not.toHaveBeenCalled();
|
||||
expect(document.getElementById('app-splash')?.textContent).toContain(
|
||||
MODULE_LOAD_RECOVERY_MESSAGE,
|
||||
);
|
||||
});
|
||||
|
||||
it('installs recovery handlers once and routes module load failures', () => {
|
||||
const reloadMock = vi.fn();
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: { reload: reloadMock },
|
||||
});
|
||||
const router = {
|
||||
onError: vi.fn((handler: (error: unknown) => void) => {
|
||||
handler(new TypeError('Failed to fetch dynamically imported module: /assets/navigator.js'));
|
||||
}),
|
||||
} as unknown as Router;
|
||||
|
||||
installModuleLoadRecovery({ router });
|
||||
installModuleLoadRecovery({ router });
|
||||
|
||||
expect(router.onError).toHaveBeenCalledTimes(1);
|
||||
vi.advanceTimersByTime(MODULE_LOAD_RECOVERY_RELOAD_DELAY_MS);
|
||||
expect(reloadMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -2,10 +2,35 @@
|
||||
// License: GNU GPLv3 or later. See the license file in the project root for more information.
|
||||
// Copyright © 2021 - present Aleksey Hoffman. All rights reserved.
|
||||
|
||||
import { MODULE_LOAD_RECOVERY_MESSAGE } from '@/utils/module-load-recovery.constants';
|
||||
|
||||
const SPLASH_ELEMENT_ID = 'app-splash';
|
||||
const SPLASH_HIDDEN_CLASS = 'app-splash--hidden';
|
||||
const SPLASH_REMOVAL_FALLBACK_MS = 500;
|
||||
|
||||
export function showModuleLoadRecoveryMessage(): void {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const splashElement = document.getElementById(SPLASH_ELEMENT_ID);
|
||||
|
||||
if (!splashElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
splashElement.textContent = '';
|
||||
const messageElement = document.createElement('div');
|
||||
messageElement.textContent = MODULE_LOAD_RECOVERY_MESSAGE;
|
||||
messageElement.style.maxWidth = '320px';
|
||||
messageElement.style.padding = '0 24px';
|
||||
messageElement.style.color = 'rgba(255, 255, 255, 0.78)';
|
||||
messageElement.style.font = '14px system-ui, sans-serif';
|
||||
messageElement.style.lineHeight = '1.45';
|
||||
messageElement.style.textAlign = 'center';
|
||||
splashElement.appendChild(messageElement);
|
||||
}
|
||||
|
||||
export function removeAppSplash(): void {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// License: GNU GPLv3 or later. See the license file in the project root for more information.
|
||||
// Copyright © 2021 - present Aleksey Hoffman. All rights reserved.
|
||||
|
||||
export const MODULE_LOAD_RECOVERY_STORAGE_KEY = 'sigma-file-manager:module-load-recovery-at';
|
||||
export const MODULE_LOAD_RECOVERY_WINDOW_MS = 30000;
|
||||
export const MODULE_LOAD_RECOVERY_RELOAD_DELAY_MS = 1500;
|
||||
export const MODULE_LOAD_RECOVERY_MESSAGE = 'Failed to load Sigma File Manager. Please restart the app.';
|
||||
@@ -0,0 +1,126 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// License: GNU GPLv3 or later. See the license file in the project root for more information.
|
||||
// Copyright © 2021 - present Aleksey Hoffman. All rights reserved.
|
||||
|
||||
import type { Router } from 'vue-router';
|
||||
import { showModuleLoadRecoveryMessage } from '@/utils/app-splash';
|
||||
import {
|
||||
MODULE_LOAD_RECOVERY_RELOAD_DELAY_MS,
|
||||
MODULE_LOAD_RECOVERY_STORAGE_KEY,
|
||||
MODULE_LOAD_RECOVERY_WINDOW_MS,
|
||||
} from '@/utils/module-load-recovery.constants';
|
||||
|
||||
const MODULE_LOAD_FAILURE_SNIPPETS = [
|
||||
'Failed to fetch dynamically imported module',
|
||||
'error loading dynamically imported module',
|
||||
'Importing a module script failed',
|
||||
'Unable to preload CSS',
|
||||
];
|
||||
|
||||
let isModuleLoadRecoveryInstalled = false;
|
||||
|
||||
export type ModuleLoadRecoveryOptions = {
|
||||
router?: Router;
|
||||
};
|
||||
|
||||
export function installModuleLoadRecovery(options: ModuleLoadRecoveryOptions = {}): void {
|
||||
if (isModuleLoadRecoveryInstalled || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
isModuleLoadRecoveryInstalled = true;
|
||||
window.addEventListener('vite:preloadError', handleVitePreloadError);
|
||||
window.addEventListener('unhandledrejection', handleUnhandledRejection);
|
||||
|
||||
if (options.router) {
|
||||
options.router.onError((error) => {
|
||||
if (isModuleLoadFailure(error)) {
|
||||
recoverFromModuleLoadFailure();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function isModuleLoadFailure(error: unknown): boolean {
|
||||
const message = getErrorMessage(error);
|
||||
|
||||
return MODULE_LOAD_FAILURE_SNIPPETS.some(snippet => message.includes(snippet));
|
||||
}
|
||||
|
||||
export function recoverFromModuleLoadFailure(): void {
|
||||
const lastRecoveryAt = getLastRecoveryAt();
|
||||
const currentTime = Date.now();
|
||||
|
||||
if (currentTime - lastRecoveryAt < MODULE_LOAD_RECOVERY_WINDOW_MS) {
|
||||
showModuleLoadRecoveryMessage();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!setLastRecoveryAt(currentTime)) {
|
||||
showModuleLoadRecoveryMessage();
|
||||
return;
|
||||
}
|
||||
|
||||
window.setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, MODULE_LOAD_RECOVERY_RELOAD_DELAY_MS);
|
||||
}
|
||||
|
||||
function handleVitePreloadError(event: Event): void {
|
||||
event.preventDefault();
|
||||
recoverFromModuleLoadFailure();
|
||||
}
|
||||
|
||||
function handleUnhandledRejection(event: PromiseRejectionEvent): void {
|
||||
if (!isModuleLoadFailure(event.reason)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
recoverFromModuleLoadFailure();
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
if (typeof error === 'string') {
|
||||
return error;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function getLastRecoveryAt(): number {
|
||||
let storedValue: string | null = null;
|
||||
|
||||
try {
|
||||
storedValue = sessionStorage.getItem(MODULE_LOAD_RECOVERY_STORAGE_KEY);
|
||||
}
|
||||
catch {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!storedValue) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const recoveryTime = Number(storedValue);
|
||||
|
||||
if (!Number.isFinite(recoveryTime)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return recoveryTime;
|
||||
}
|
||||
|
||||
function setLastRecoveryAt(recoveryTime: number): boolean {
|
||||
try {
|
||||
sessionStorage.setItem(MODULE_LOAD_RECOVERY_STORAGE_KEY, String(recoveryTime));
|
||||
return true;
|
||||
}
|
||||
catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user