From 8d2c0247949dfd0a3e1df17ac2c273c8adc645c0 Mon Sep 17 00:00:00 2001
From: linshen <32978552+linshenkx@users.noreply.github.com>
Date: Sat, 9 May 2026 22:16:11 +0800
Subject: [PATCH] fix(ui): harden resource restore and links
- Restore image resources when metadata exists but bytes are missing
- Limit remote restore scope and sanitize external link handling
- Reuse image resource backup helpers across restore packages
- Extend restore safety coverage for data manager and snapshots
---
packages/ui/src/components/DataManager.vue | 117 +++++++++++++++---
packages/ui/src/i18n/locales/en-US/context.ts | 6 +-
packages/ui/src/i18n/locales/zh-CN/context.ts | 6 +-
packages/ui/src/i18n/locales/zh-TW/context.ts | 6 +-
.../utils/data-manager-resource-package.ts | 6 +-
.../ui/src/utils/favorite-resource-package.ts | 2 +-
.../ui/src/utils/image-resource-backup.ts | 6 +-
packages/ui/src/utils/open-external-url.ts | 18 ++-
.../ui/src/utils/remote-snapshot-backup.ts | 10 +-
.../tests/unit/components/DataManager.spec.ts | 63 ++++++++++
.../components/FavoriteImportPanel.spec.ts | 2 +-
.../data-manager-resource-package.spec.ts | 30 +++++
.../utils/favorite-resource-package.spec.ts | 26 ++++
.../unit/utils/image-resource-backup.spec.ts | 40 +++++-
.../unit/utils/open-external-url.spec.ts | 30 +++--
.../unit/utils/remote-snapshot-backup.spec.ts | 36 ++++++
16 files changed, 362 insertions(+), 42 deletions(-)
diff --git a/packages/ui/src/components/DataManager.vue b/packages/ui/src/components/DataManager.vue
index 04e725d2..a015b5a8 100644
--- a/packages/ui/src/components/DataManager.vue
+++ b/packages/ui/src/components/DataManager.vue
@@ -653,7 +653,54 @@
/>
-
+
+
+ {{ $t('dataManager.remote.restoreScope') }}
+
+
+ {{ $t('dataManager.remote.restoreMissingAssetsHint', { count: selectedRemoteBackupMissingAssetsCount }) }}
+
+
+
+
+
+
+
+
+
+
{{ $t('dataManager.import.favoriteMergeStrategy') }}
@@ -676,7 +723,7 @@
type="success"
block
:loading="isRestoringRemote"
- :disabled="!selectedRemoteBackup || isRemoteDataOperationActive"
+ :disabled="!selectedRemoteBackup || isRemoteDataOperationActive || !hasSelectedRemoteRestoreSection"
@click="handleRemoteRestore"
>
@@ -776,6 +823,7 @@ import {
type RemoteSnapshotEntry,
} from '../utils/remote-snapshot-backup'
import { recordDataBackupCompleted } from '../utils/data-backup-reminder'
+import { openExternalUrl } from '../utils/open-external-url'
interface Props {
show: boolean
@@ -833,6 +881,10 @@ const exportFavorites = ref(true)
const importAppData = ref(true)
const importAppDataImages = ref(true)
const importFavorites = ref(true)
+const remoteRestoreAppData = ref(true)
+const remoteRestoreAppDataImages = ref(true)
+const remoteRestoreFavorites = ref(true)
+const remoteRestoreFavoriteImages = ref(true)
const favoriteMergeStrategy = ref('overwrite')
const storageSummary = ref(null)
@@ -942,6 +994,41 @@ const googleDriveAuthorizationLabel = computed(() =>
const selectedRemoteBackup = computed(() =>
remoteBackups.value.find((entry) => entry.id === selectedRemoteBackupId.value) ?? null
)
+const getRemoteBackupIncludedSectionSet = (entry: RemoteSnapshotEntry | null): Set => {
+ const included = entry?.manifest?.includedSections
+ if (!Array.isArray(included) || included.length === 0) {
+ return new Set(allPackageSections)
+ }
+
+ const filtered = included.filter((section): section is DataManagerPackageSection =>
+ allPackageSections.includes(section as DataManagerPackageSection),
+ )
+ return new Set(filtered.length > 0 ? filtered : allPackageSections)
+}
+const availableRemoteRestoreSections = computed(() =>
+ getRemoteBackupIncludedSectionSet(selectedRemoteBackup.value)
+)
+const selectedRemoteBackupMissingAssetsCount = computed(() => {
+ const missingAssets = selectedRemoteBackup.value?.manifest?.missingAssets
+ return Array.isArray(missingAssets) ? missingAssets.length : 0
+})
+const resetRemoteRestoreSelection = () => {
+ const available = availableRemoteRestoreSections.value
+ remoteRestoreAppData.value = available.has('appData')
+ remoteRestoreAppDataImages.value = available.has('imageCache')
+ remoteRestoreFavorites.value = available.has('favorites')
+ remoteRestoreFavoriteImages.value = available.has('favoriteImages')
+}
+const hasSelectedRemoteRestoreSection = computed(() =>
+ (remoteRestoreAppData.value && availableRemoteRestoreSections.value.has('appData')) ||
+ (remoteRestoreAppData.value && remoteRestoreAppDataImages.value && availableRemoteRestoreSections.value.has('imageCache')) ||
+ (remoteRestoreFavorites.value && availableRemoteRestoreSections.value.has('favorites')) ||
+ (remoteRestoreFavorites.value && remoteRestoreFavoriteImages.value && availableRemoteRestoreSections.value.has('favoriteImages'))
+)
+
+watch(selectedRemoteBackup, () => {
+ resetRemoteRestoreSelection()
+})
const cloudflareR2Provider = computed(() =>
remoteSettings.value.provider.kind === 'cloudflare-r2'
@@ -1273,23 +1360,10 @@ const finishRemoteProgress = (messageKey?: string) => {
}
}
-const openExternalUrl = async (url: string) => {
- if (typeof window !== 'undefined' && window.electronAPI?.shell) {
- try {
- await window.electronAPI.shell.openExternal(url)
- return
- } catch (error) {
- console.error('[DataManager] Failed to open external URL in Electron:', error)
- }
- }
-
- window.open(url, '_blank', 'noopener,noreferrer')
-}
-
const openCloudflareR2Link = (
key: 'dashboard' | 'buckets' | 'apiTokens' | 'docs',
) => {
- void openExternalUrl(cloudflareR2Links.value[key])
+ void openExternalUrl(cloudflareR2Links.value[key], { logPrefix: 'DataManager' })
}
const writeClipboardText = async (text: string): Promise => {
@@ -1606,7 +1680,16 @@ const toImportSectionSelection = (): DataManagerPackageSectionSelection => ({
})
const toRemoteRestoreSectionSelection = (): DataManagerPackageSectionSelection => ({
- ...DEFAULT_DATA_MANAGER_PACKAGE_SECTIONS,
+ appData: remoteRestoreAppData.value && availableRemoteRestoreSections.value.has('appData'),
+ favorites: remoteRestoreFavorites.value && availableRemoteRestoreSections.value.has('favorites'),
+ imageCache:
+ remoteRestoreAppData.value &&
+ remoteRestoreAppDataImages.value &&
+ availableRemoteRestoreSections.value.has('imageCache'),
+ favoriteImages:
+ remoteRestoreFavorites.value &&
+ remoteRestoreFavoriteImages.value &&
+ availableRemoteRestoreSections.value.has('favoriteImages'),
})
const resetImportSelection = () => {
diff --git a/packages/ui/src/i18n/locales/en-US/context.ts b/packages/ui/src/i18n/locales/en-US/context.ts
index 267ca785..650e791e 100644
--- a/packages/ui/src/i18n/locales/en-US/context.ts
+++ b/packages/ui/src/i18n/locales/en-US/context.ts
@@ -283,7 +283,9 @@ const messages = {
"appDataImages": "Related images",
"appDataImagesHint": "Images referenced by history and results; current workspace sessions are not included",
"favoritesBundle": "Favorites",
- "favoritesBundleHint": "Favorites, categories, tags, prompt assets, examples, versions, and favorite images"
+ "favoritesBundleHint": "Favorites, categories, tags, prompt assets, examples, versions, and favorite images",
+ "favoriteImages": "Favorite images",
+ "favoriteImagesHint": "Images referenced by favorite prompt assets, examples, and versions"
},
"contexts": {
"title": "Context Collections Management",
@@ -421,6 +423,8 @@ const messages = {
"refreshList": "Refresh Remote Backup List",
"listFailed": "Failed to load remote backup list: {message}",
"restoreFrom": "Restore from remote backup",
+ "restoreScope": "Restore contents",
+ "restoreMissingAssetsHint": "This backup was created with {count} missing image resources. Clear image sections to restore only JSON data.",
"noBackups": "No remote backups",
"restoreSelected": "Restore Selected Backup",
"restoreFailed": "Failed to restore remote backup: {message}",
diff --git a/packages/ui/src/i18n/locales/zh-CN/context.ts b/packages/ui/src/i18n/locales/zh-CN/context.ts
index babe6bde..c6cb090a 100644
--- a/packages/ui/src/i18n/locales/zh-CN/context.ts
+++ b/packages/ui/src/i18n/locales/zh-CN/context.ts
@@ -283,7 +283,9 @@ const messages = {
"appDataImages": "相关图片",
"appDataImagesHint": "历史和结果中引用的图片,不包含当前工作区会话",
"favoritesBundle": "收藏夹",
- "favoritesBundleHint": "收藏、分类、标签、提示词资产、示例、版本链和收藏图片"
+ "favoritesBundleHint": "收藏、分类、标签、提示词资产、示例、版本链和收藏图片",
+ "favoriteImages": "收藏图片",
+ "favoriteImagesHint": "收藏提示词资产、示例和版本链引用的图片"
},
"contexts": {
"title": "上下文集合管理",
@@ -421,6 +423,8 @@ const messages = {
"refreshList": "刷新远程备份列表",
"listFailed": "获取远程备份列表失败:{message}",
"restoreFrom": "从远程备份还原",
+ "restoreScope": "还原内容",
+ "restoreMissingAssetsHint": "此备份创建时有 {count} 个图片资源缺失,可取消图片内容后只还原 JSON 数据。",
"noBackups": "暂无远程备份",
"restoreSelected": "还原选中备份",
"restoreFailed": "还原远程备份失败:{message}",
diff --git a/packages/ui/src/i18n/locales/zh-TW/context.ts b/packages/ui/src/i18n/locales/zh-TW/context.ts
index 6a834d2e..110e68e7 100644
--- a/packages/ui/src/i18n/locales/zh-TW/context.ts
+++ b/packages/ui/src/i18n/locales/zh-TW/context.ts
@@ -283,7 +283,9 @@ const messages = {
"appDataImages": "相關圖片",
"appDataImagesHint": "歷史和結果中引用的圖片,不包含目前工作區會話",
"favoritesBundle": "收藏",
- "favoritesBundleHint": "收藏、分類、標籤、提示詞資產、範例、版本鏈和收藏圖片"
+ "favoritesBundleHint": "收藏、分類、標籤、提示詞資產、範例、版本鏈和收藏圖片",
+ "favoriteImages": "收藏圖片",
+ "favoriteImagesHint": "收藏提示詞資產、範例和版本鏈引用的圖片"
},
"contexts": {
"title": "情境集合管理",
@@ -421,6 +423,8 @@ const messages = {
"refreshList": "重新整理遠端備份列表",
"listFailed": "取得遠端備份列表失敗:{message}",
"restoreFrom": "從遠端備份還原",
+ "restoreScope": "還原內容",
+ "restoreMissingAssetsHint": "此備份建立時有 {count} 個圖片資源缺失,可取消圖片內容後只還原 JSON 資料。",
"noBackups": "暫無遠端備份",
"restoreSelected": "還原選中備份",
"restoreFailed": "還原遠端備份失敗:{message}",
diff --git a/packages/ui/src/utils/data-manager-resource-package.ts b/packages/ui/src/utils/data-manager-resource-package.ts
index 2ad193c8..df6c52ae 100644
--- a/packages/ui/src/utils/data-manager-resource-package.ts
+++ b/packages/ui/src/utils/data-manager-resource-package.ts
@@ -85,8 +85,8 @@ type ExportDataManagerResourcePackageOptions = {
type ImportDataManagerResourcePackageOptions = {
dataManager: Pick
favoriteManager: Pick | null | undefined
- imageStorageService?: Pick | null
- favoriteImageStorageService?: Pick | null
+ imageStorageService?: Pick | null
+ favoriteImageStorageService?: Pick | null
sections?: Partial
favoriteMergeStrategy?: DataManagerFavoritesMergeStrategy
}
@@ -307,7 +307,7 @@ export const readDataManagerResourcePackage = (
const getImportStorageService = (
store: DataManagerImageStoreKey,
options: ImportDataManagerResourcePackageOptions,
-): Pick | null | undefined =>
+): Pick | null | undefined =>
store === 'favoriteImages'
? options.favoriteImageStorageService
: options.imageStorageService
diff --git a/packages/ui/src/utils/favorite-resource-package.ts b/packages/ui/src/utils/favorite-resource-package.ts
index b047cdc2..a375fdb5 100644
--- a/packages/ui/src/utils/favorite-resource-package.ts
+++ b/packages/ui/src/utils/favorite-resource-package.ts
@@ -78,7 +78,7 @@ type ExportFavoriteResourcePackageOptions = {
type ImportFavoriteResourcePackageOptions = {
favoriteManager: Pick
- imageStorageService?: Pick | null
+ imageStorageService?: Pick | null
mergeStrategy?: 'skip' | 'overwrite' | 'merge'
}
diff --git a/packages/ui/src/utils/image-resource-backup.ts b/packages/ui/src/utils/image-resource-backup.ts
index 51b3c411..7266f80d 100644
--- a/packages/ui/src/utils/image-resource-backup.ts
+++ b/packages/ui/src/utils/image-resource-backup.ts
@@ -25,7 +25,7 @@ export type ImageResourceRestoreReport = {
errors: string[]
}
-export type ImageResourceStorage = Pick
+export type ImageResourceStorage = Pick
export const copyBytes = (bytes: Uint8Array): Uint8Array => {
const out = new globalThis.Uint8Array(bytes.byteLength)
@@ -203,8 +203,8 @@ export const restoreImageResource = async (
bytes: Uint8Array,
storageService: ImageResourceStorage,
): Promise<'restored' | 'skipped'> => {
- const existing = await storageService.getMetadata(entry.id)
- if (existing) {
+ const existing = await storageService.getImage(entry.id)
+ if (existing?.data) {
return 'skipped'
}
diff --git a/packages/ui/src/utils/open-external-url.ts b/packages/ui/src/utils/open-external-url.ts
index af9a6bf8..3e1f00ad 100644
--- a/packages/ui/src/utils/open-external-url.ts
+++ b/packages/ui/src/utils/open-external-url.ts
@@ -4,12 +4,28 @@ export interface OpenExternalUrlOptions {
logPrefix?: string
}
+const SUPPORTED_EXTERNAL_URL_PROTOCOLS = new Set(['http:', 'https:'])
+
+const isSupportedExternalUrl = (url: string): boolean => {
+ try {
+ return SUPPORTED_EXTERNAL_URL_PROTOCOLS.has(new URL(url).protocol)
+ } catch {
+ return false
+ }
+}
+
export const openExternalUrl = async (
url: string,
options: OpenExternalUrlOptions = {},
): Promise => {
if (!url || typeof window === 'undefined') return false
+ const prefix = options.logPrefix ?? 'openExternalUrl'
+ if (!isSupportedExternalUrl(url)) {
+ console.error(`[${prefix}] Refused to open unsupported external URL:`, url)
+ return false
+ }
+
const target = options.target ?? '_blank'
const features = options.features ?? 'noopener,noreferrer'
@@ -18,8 +34,8 @@ export const openExternalUrl = async (
await window.electronAPI.shell.openExternal(url)
return true
} catch (error) {
- const prefix = options.logPrefix ?? 'openExternalUrl'
console.error(`[${prefix}] Failed to open external URL in Electron:`, error)
+ return false
}
}
diff --git a/packages/ui/src/utils/remote-snapshot-backup.ts b/packages/ui/src/utils/remote-snapshot-backup.ts
index a6c1af7e..24a8abf6 100644
--- a/packages/ui/src/utils/remote-snapshot-backup.ts
+++ b/packages/ui/src/utils/remote-snapshot-backup.ts
@@ -143,8 +143,8 @@ type RestoreRemoteSnapshotOptions = {
snapshotId: string
dataManager: Pick
favoriteManager: Pick | null | undefined
- imageStorageService?: Pick | null
- favoriteImageStorageService?: Pick | null
+ imageStorageService?: Pick | null
+ favoriteImageStorageService?: Pick | null
sections?: Partial
favoriteMergeStrategy?: DataManagerFavoritesMergeStrategy
onProgress?: RemoteSnapshotProgressReporter
@@ -263,7 +263,7 @@ const validateJsonText = (text: string, label: string): void => {
const getImportStorageService = (
store: DataManagerImageStoreKey,
options: RestoreRemoteSnapshotOptions,
-): Pick | null | undefined =>
+): Pick | null | undefined =>
store === 'favoriteImages'
? options.favoriteImageStorageService
: options.imageStorageService
@@ -551,8 +551,8 @@ const prepareRemoteSnapshotRestore = async (
continue
}
- const existing = await storageService.getMetadata(asset.id)
- if (existing) {
+ const existing = await storageService.getImage(asset.id)
+ if (existing?.data) {
report.skipped += 1
continue
}
diff --git a/packages/ui/tests/unit/components/DataManager.spec.ts b/packages/ui/tests/unit/components/DataManager.spec.ts
index 10b484e1..f1940869 100644
--- a/packages/ui/tests/unit/components/DataManager.spec.ts
+++ b/packages/ui/tests/unit/components/DataManager.spec.ts
@@ -575,6 +575,69 @@ describe('DataManager storage breakdown', () => {
}))
})
+ it('allows remote restore to skip image sections from a partial backup', async () => {
+ window.localStorage.setItem('prompt-optimizer:remote-backup-settings', JSON.stringify({
+ provider: {
+ kind: 'cloudflare-r2',
+ accountId: 'account-123',
+ bucket: 'prompt-optimizer-backups',
+ accessKeyId: 'ak',
+ secretAccessKey: 'sk',
+ },
+ }))
+
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ const setupState = (wrapper.vm as any).$?.setupState
+ setupState.remoteConnectionStates = {
+ ...setupState.remoteConnectionStates,
+ 'cloudflare-r2': {
+ ...setupState.remoteConnectionStates['cloudflare-r2'],
+ status: 'connected',
+ },
+ }
+ setupState.remoteBackups = [{
+ id: 'snap-partial',
+ name: 'snap-partial',
+ manifestPath: 'v1/snapshots/snap-partial/manifest.json',
+ updatedAt: '2026-05-06T10:30:00.000Z',
+ manifest: {
+ includedSections: ['appData', 'favorites', 'imageCache', 'favoriteImages'],
+ missingAssets: [
+ { store: 'imageCache', id: 'missing-image' },
+ { store: 'favoriteImages', id: 'missing-favorite-image' },
+ ],
+ },
+ }]
+ setupState.selectedRemoteBackupId = 'snap-partial'
+ await flushPromises()
+
+ setupState.remoteRestoreAppData = true
+ setupState.remoteRestoreFavorites = true
+ setupState.remoteRestoreAppDataImages = false
+ setupState.remoteRestoreFavoriteImages = false
+ await flushPromises()
+
+ const restoreButton = wrapper.findAll('button').find((button) =>
+ button.text().includes('Restore Selected Backup')
+ )
+ expect(restoreButton).toBeTruthy()
+ await restoreButton!.trigger('click')
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('missing image resources')
+ expect(remoteSnapshotMocks.restoreRemoteSnapshotBackup).toHaveBeenCalledWith(expect.objectContaining({
+ snapshotId: 'snap-partial',
+ sections: {
+ appData: true,
+ favorites: true,
+ imageCache: false,
+ favoriteImages: false,
+ },
+ }))
+ })
+
it('disables remote backup actions while a remote data operation is running', async () => {
window.localStorage.setItem('prompt-optimizer:remote-backup-settings', JSON.stringify({
provider: {
diff --git a/packages/ui/tests/unit/components/FavoriteImportPanel.spec.ts b/packages/ui/tests/unit/components/FavoriteImportPanel.spec.ts
index 8845779a..8a6fa6a6 100644
--- a/packages/ui/tests/unit/components/FavoriteImportPanel.spec.ts
+++ b/packages/ui/tests/unit/components/FavoriteImportPanel.spec.ts
@@ -195,7 +195,7 @@ describe('FavoriteImportPanel', () => {
}),
}
const favoriteImageStorageService = {
- getMetadata: vi.fn(async () => null),
+ getImage: vi.fn(async () => null),
saveImage: vi.fn(async (image: FullImageData) => {
order.push(`save:${image.metadata.id}`)
return image.metadata.id
diff --git a/packages/ui/tests/unit/utils/data-manager-resource-package.spec.ts b/packages/ui/tests/unit/utils/data-manager-resource-package.spec.ts
index 34755304..c9912fb1 100644
--- a/packages/ui/tests/unit/utils/data-manager-resource-package.spec.ts
+++ b/packages/ui/tests/unit/utils/data-manager-resource-package.spec.ts
@@ -140,6 +140,36 @@ describe('dataManagerResourcePackage', () => {
expect(favoriteTarget.store.has('favorite-1')).toBe(true)
})
+ it('restores package resources when local metadata exists but image bytes are missing', async () => {
+ const exported = await createDataManagerResourcePackage({
+ dataManager: {
+ exportAllData: vi.fn(async () => JSON.stringify({ version: 1, data: {} })),
+ },
+ favoriteManager: null,
+ imageStorageService: createStorage([createImage('session-1', 'session-image')]),
+ favoriteImageStorageService: createStorage([]),
+ })
+ const target = createStorage([])
+ target.getMetadata.mockResolvedValue(createImage('session-1', 'metadata-only').metadata)
+
+ const result = await importDataManagerResourcePackage(await exported.blob.arrayBuffer(), {
+ dataManager: { importAllData: vi.fn(async () => {}) },
+ favoriteManager: null,
+ imageStorageService: target,
+ favoriteImageStorageService: createStorage([]),
+ sections: {
+ appData: false,
+ favorites: false,
+ imageCache: true,
+ favoriteImages: false,
+ },
+ })
+
+ expect(result.resources.restored).toBe(1)
+ expect(target.saveImage).toHaveBeenCalledTimes(1)
+ expect(target.store.has('session-1')).toBe(true)
+ })
+
it('exports resource metadata from actual bytes instead of stale image metadata', async () => {
const jpegBytes = [0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0xff, 0xd9]
const image: FullImageData = {
diff --git a/packages/ui/tests/unit/utils/favorite-resource-package.spec.ts b/packages/ui/tests/unit/utils/favorite-resource-package.spec.ts
index 426eb752..c336ab67 100644
--- a/packages/ui/tests/unit/utils/favorite-resource-package.spec.ts
+++ b/packages/ui/tests/unit/utils/favorite-resource-package.spec.ts
@@ -340,6 +340,32 @@ describe('favoriteResourcePackage', () => {
expect(order).toEqual(['save:media-asset', 'import:favorites'])
})
+ it('restores package resources when local metadata exists but image bytes are missing', async () => {
+ const favorite = createMediaOnlyFavorite([])
+ const exported = await createFavoriteResourcePackage({
+ favoriteManager: {
+ exportFavorites: vi.fn(async () => JSON.stringify({
+ version: '1.0',
+ favorites: [favorite],
+ })),
+ },
+ imageStorageService: createStorage([createImage('cover-asset', 'cover')]),
+ })
+ const target = createStorage([])
+ target.getMetadata.mockResolvedValue(createImage('cover-asset', 'metadata-only').metadata)
+
+ const result = await importFavoriteResourcePackage(await exported.blob.arrayBuffer(), {
+ favoriteManager: {
+ importFavorites: vi.fn(async () => ({ imported: 1, skipped: 0, errors: [] })),
+ },
+ imageStorageService: target,
+ })
+
+ expect(result.resources.restored).toBe(1)
+ expect(target.saveImage).toHaveBeenCalledTimes(1)
+ expect(target.store.has('cover-asset')).toBe(true)
+ })
+
it('blocks favorites JSON import when package resources are corrupt', async () => {
stubSha256Digest()
diff --git a/packages/ui/tests/unit/utils/image-resource-backup.spec.ts b/packages/ui/tests/unit/utils/image-resource-backup.spec.ts
index 553e6f7d..5768eabc 100644
--- a/packages/ui/tests/unit/utils/image-resource-backup.spec.ts
+++ b/packages/ui/tests/unit/utils/image-resource-backup.spec.ts
@@ -1,8 +1,9 @@
-import { describe, expect, it } from 'vitest'
+import { describe, expect, it, vi } from 'vitest'
import {
assertImageResourceRestoreReportSafe,
createFullImageDataFromResource,
+ restoreImageResource,
safeImageResourceFileName,
validateImageResourceBytes,
} from '../../../src/utils/image-resource-backup'
@@ -62,4 +63,41 @@ describe('image resource backup helpers', () => {
expect(image.metadata.mimeType).toBe('image/jpeg')
expect(image.metadata.source).toBe('generated')
})
+
+ it('skips only when local storage can return complete image bytes', async () => {
+ const bytes = new TextEncoder().encode('backup-image')
+ const entry = {
+ kind: 'image' as const,
+ id: 'asset-3',
+ path: 'resources/images/asset-3.png',
+ mimeType: 'image/png',
+ createdAt: 1,
+ source: 'uploaded' as const,
+ }
+ const saveImage = vi.fn(async () => 'asset-3')
+ const metadataOnlyStorage = {
+ getImage: vi.fn(async () => null),
+ saveImage,
+ }
+
+ await expect(restoreImageResource(entry, bytes, metadataOnlyStorage)).resolves.toBe('restored')
+ expect(saveImage).toHaveBeenCalledTimes(1)
+
+ const completeStorage = {
+ getImage: vi.fn(async () => ({
+ metadata: {
+ id: 'asset-3',
+ mimeType: 'image/png',
+ sizeBytes: 1,
+ createdAt: 1,
+ source: 'uploaded' as const,
+ },
+ data: 'x',
+ })),
+ saveImage: vi.fn(async () => 'asset-3'),
+ }
+
+ await expect(restoreImageResource(entry, bytes, completeStorage)).resolves.toBe('skipped')
+ expect(completeStorage.saveImage).not.toHaveBeenCalled()
+ })
})
diff --git a/packages/ui/tests/unit/utils/open-external-url.spec.ts b/packages/ui/tests/unit/utils/open-external-url.spec.ts
index 502ed801..c0dba43e 100644
--- a/packages/ui/tests/unit/utils/open-external-url.spec.ts
+++ b/packages/ui/tests/unit/utils/open-external-url.spec.ts
@@ -51,7 +51,7 @@ describe('openExternalUrl', () => {
)
})
- it('falls back to window.open when the desktop bridge fails', async () => {
+ it('does not fall back to window.open when the desktop bridge rejects the URL', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const openExternal = vi.fn().mockRejectedValue(new Error('shell failed'))
Object.defineProperty(window, 'electronAPI', {
@@ -62,16 +62,32 @@ describe('openExternalUrl', () => {
const result = await openExternalUrl('https://example.com', { logPrefix: 'Test' })
- expect(result).toBe(true)
+ expect(result).toBe(false)
expect(openExternal).toHaveBeenCalledWith('https://example.com')
- expect(openSpy).toHaveBeenCalledWith(
- 'https://example.com',
- '_blank',
- 'noopener,noreferrer',
- )
+ expect(openSpy).not.toHaveBeenCalled()
expect(consoleErrorSpy).toHaveBeenCalledWith(
'[Test] Failed to open external URL in Electron:',
expect.any(Error),
)
})
+
+ it('rejects unsupported protocols before any runtime opener is called', async () => {
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
+ const openExternal = vi.fn().mockResolvedValue(undefined)
+ Object.defineProperty(window, 'electronAPI', {
+ configurable: true,
+ writable: true,
+ value: { shell: { openExternal } },
+ })
+
+ const result = await openExternalUrl('file:///tmp/prompt.txt', { logPrefix: 'Test' })
+
+ expect(result).toBe(false)
+ expect(openExternal).not.toHaveBeenCalled()
+ expect(openSpy).not.toHaveBeenCalled()
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ '[Test] Refused to open unsupported external URL:',
+ 'file:///tmp/prompt.txt',
+ )
+ })
})
diff --git a/packages/ui/tests/unit/utils/remote-snapshot-backup.spec.ts b/packages/ui/tests/unit/utils/remote-snapshot-backup.spec.ts
index ae33425e..f1e4cb86 100644
--- a/packages/ui/tests/unit/utils/remote-snapshot-backup.spec.ts
+++ b/packages/ui/tests/unit/utils/remote-snapshot-backup.spec.ts
@@ -325,6 +325,42 @@ describe('remote snapshot backup', () => {
])
})
+ it('restores remote image assets when local metadata exists but image bytes are missing', async () => {
+ const objectStore = createObjectStore()
+ const exported = await createRemoteSnapshotBackup({
+ objectStore,
+ dataManager: {
+ exportAllData: vi.fn(async () => JSON.stringify({ version: 1, data: {} })),
+ },
+ favoriteManager: null,
+ imageStorageService: createStorage([createImage('session-1', 'session-image')]),
+ favoriteImageStorageService: createStorage([]),
+ sections: {
+ appData: false,
+ favorites: false,
+ imageCache: true,
+ favoriteImages: false,
+ },
+ })
+ const target = createStorage([])
+ target.getMetadata.mockResolvedValue(createImage('session-1', 'metadata-only').metadata)
+
+ const result = await restoreRemoteSnapshotBackup({
+ objectStore,
+ snapshotId: exported.entry.id,
+ dataManager: {
+ importAllData: vi.fn(async () => {}),
+ },
+ favoriteManager: null,
+ imageStorageService: target,
+ favoriteImageStorageService: createStorage([]),
+ })
+
+ expect(result.restored).toBe(1)
+ expect(target.saveImage).toHaveBeenCalledTimes(1)
+ expect(target.store.has('session-1')).toBe(true)
+ })
+
it('does not restore sections that were not included in the remote snapshot', async () => {
const objectStore = createObjectStore()
const exportAllData = vi.fn(async () => JSON.stringify({ version: 1, data: { history: ['local'] } }))