diff --git a/src/app/features/settings/developer-tools/DebugLogViewer.tsx b/src/app/features/settings/developer-tools/DebugLogViewer.tsx index d9846d6aa..deaca59c3 100644 --- a/src/app/features/settings/developer-tools/DebugLogViewer.tsx +++ b/src/app/features/settings/developer-tools/DebugLogViewer.tsx @@ -9,6 +9,7 @@ import { debugLoggerEnabledAtom, debugLogsAtom, clearDebugLogsAtom } from '$stat import type { LogEntry, LogLevel, LogCategory } from '$utils/debugLogger'; import { getDebugLogger } from '$utils/debugLogger'; import { copyToClipboard } from '$utils/dom'; +import { saveFileToDevice } from '$utils/download'; const formatTimestamp = (timestamp: number): string => { const date = new Date(timestamp); @@ -219,19 +220,15 @@ export function DebugLogViewer() { jsonData = debugLogger.exportLogs(); } - const blob = new Blob([jsonData], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; const filterSuffix = filtered && (filterLevel !== 'all' || filterCategory !== 'all') ? `-${filterCategory !== 'all' ? filterCategory : 'all'}-${filterLevel !== 'all' ? filterLevel : 'all'}` : ''; - a.download = `sable-debug-logs${filterSuffix}-${new Date().toISOString()}.json`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); + void saveFileToDevice( + new Blob([jsonData], { type: 'application/json' }), + `sable-debug-logs${filterSuffix}-${Date.now()}.json`, + 'application/json' + ); }, [filterLevel, filterCategory] ); diff --git a/src/app/features/settings/developer-tools/SentrySettings.tsx b/src/app/features/settings/developer-tools/SentrySettings.tsx index 03a213ac7..4848c6809 100644 --- a/src/app/features/settings/developer-tools/SentrySettings.tsx +++ b/src/app/features/settings/developer-tools/SentrySettings.tsx @@ -5,6 +5,7 @@ import { SettingTile } from '$components/setting-tile'; import { toSettingsFocusIdPart } from '$features/settings/settingsLink'; import type { LogCategory } from '$utils/debugLogger'; import { getDebugLogger } from '$utils/debugLogger'; +import { saveFileToDevice } from '$utils/download'; const ALL_CATEGORIES: LogCategory[] = [ 'sync', @@ -18,10 +19,12 @@ const ALL_CATEGORIES: LogCategory[] = [ 'general', ]; -import { downloadJsonFile } from '$utils/common'; - const handleExportLogs = () => { - downloadJsonFile(getDebugLogger().exportLogs(), 'sable-debug-logs'); + void saveFileToDevice( + new Blob([getDebugLogger().exportLogs()], { type: 'application/json' }), + `sable-debug-logs-${Date.now()}.json`, + 'application/json' + ); }; export function SentrySettings() { diff --git a/src/app/utils/settingsSync.test.ts b/src/app/utils/settingsSync.test.ts index 6a96b7a76..965873ad4 100644 --- a/src/app/utils/settingsSync.test.ts +++ b/src/app/utils/settingsSync.test.ts @@ -1,4 +1,18 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const { saveFileToDevice } = vi.hoisted(() => ({ + saveFileToDevice: + vi.fn< + ( + input: Blob | string, + filename: string, + mimeType?: string + ) => Promise<'saved' | 'cancelled' | 'failed'> + >(), +})); + +vi.mock('$utils/download', () => ({ saveFileToDevice })); + import { getSettings, resetRuntimeSettingsDefaults } from '$state/settings'; import { NON_SYNCABLE_KEYS, @@ -384,46 +398,28 @@ describe('deserializeFromSync', () => { // exportSettingsAsJson describe('exportSettingsAsJson', () => { - let fakeUrl: string; - let anchorClick: ReturnType; - beforeEach(() => { - fakeUrl = 'blob:fake-url'; - anchorClick = vi.fn<() => void>(); - vi.stubGlobal('URL', { - createObjectURL: vi.fn<() => string>().mockReturnValue(fakeUrl), - revokeObjectURL: vi.fn<() => void>(), - }); - - // Intercept anchor element creation to capture click calls. - const realCreate = document.createElement.bind(document); - vi.spyOn(document, 'createElement').mockImplementation((tag: string, ...args) => { - const el = realCreate(tag, ...args); - if (tag === 'a') { - vi.spyOn(el, 'click').mockImplementation(anchorClick as () => void); - } - return el; - }); + saveFileToDevice.mockResolvedValue('saved'); }); afterEach(() => { - vi.unstubAllGlobals(); - vi.restoreAllMocks(); + vi.clearAllMocks(); }); - it('calls URL.createObjectURL with a JSON Blob', () => { + it('saves a JSON file through the cross-platform downloader', () => { exportSettingsAsJson(base); - expect(URL.createObjectURL).toHaveBeenCalledOnce(); - const blob: Blob | undefined = (URL.createObjectURL as ReturnType).mock - .calls[0]?.[0]; - expect(blob).toBeInstanceOf(Blob); - expect(blob!.type).toBe('application/json'); + expect(saveFileToDevice).toHaveBeenCalledWith( + expect.any(Blob), + expect.stringMatching(/^sable-settings-\d+\.json$/), + 'application/json' + ); }); - it('Blob content is valid JSON with the correct schema version and all settings', async () => { + it('saves valid JSON with the correct schema version and all settings', async () => { exportSettingsAsJson(base); - const blob: Blob | undefined = (URL.createObjectURL as ReturnType).mock - .calls[0]?.[0]; + const blob = saveFileToDevice.mock.calls[0]?.[0] as Blob; + expect(blob).toBeInstanceOf(Blob); + expect(blob!.type).toBe('application/json'); const text = await blob!.text(); const parsed = JSON.parse(text); expect(parsed.v).toBe(SETTINGS_SYNC_VERSION); @@ -431,16 +427,6 @@ describe('exportSettingsAsJson', () => { // non-syncable keys ARE present in the export (full snapshot, not filtered) expect(parsed.settings.pageZoom).toBeDefined(); }); - - it('creates an anchor with a .json download attribute and clicks it', () => { - exportSettingsAsJson(base); - expect(anchorClick).toHaveBeenCalledOnce(); - }); - - it('revokes the object URL after triggering the download', () => { - exportSettingsAsJson(base); - expect(URL.revokeObjectURL).toHaveBeenCalledWith(fakeUrl); - }); }); // importSettingsFromJson diff --git a/src/app/utils/settingsSync.ts b/src/app/utils/settingsSync.ts index f09cc405d..135f31ea1 100644 --- a/src/app/utils/settingsSync.ts +++ b/src/app/utils/settingsSync.ts @@ -1,7 +1,7 @@ import { sanitizeThemeRemoteTweakFavorites, type Settings } from '$state/settings'; import { isLocalImportTweakUrl } from '../theme/localImportUrls'; import { sanitizeShortcutOverrides } from '../keyboard/shortcuts'; -import { downloadJsonFile } from './common'; +import { saveFileToDevice } from './download'; /** * Keys excluded from cross-device sync. @@ -152,11 +152,14 @@ export const deserializeFromSync = (data: unknown, currentSettings: Settings): S return merged; }; -/** Trigger a browser download of the current settings as a JSON file. */ +/** Save the current settings as a JSON file. */ export const exportSettingsAsJson = (settings: Settings): void => { - downloadJsonFile( - JSON.stringify({ v: SETTINGS_SYNC_VERSION, settings }, null, 2), - 'sable-settings' + void saveFileToDevice( + new Blob([JSON.stringify({ v: SETTINGS_SYNC_VERSION, settings }, null, 2)], { + type: 'application/json', + }), + `sable-settings-${Date.now()}.json`, + 'application/json' ); };