diff --git a/MEMORY.md b/MEMORY.md index 18d2145..3e61895 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -1,6 +1,71 @@ # MEMORY.md -## 2026-03-17 - GAIA_BL01 部署错误修复 +## 2026-03-17 - GAIA_BL01 部署错误修复(更新) + +### 遇到什么问题 + +- 用户点击部署时,如果存在未修改的文件(status: "unchanged"),会报错:`[404] [GAIA_BL01] 指定したファイル(id: XXXXX)が見つかりません。` +- **之前分析不够准确**:原匹配逻辑只用文件名匹配,如果存在同名文件会匹配到错误的 fileKey + +### 根本原因(更正) + +1. **匹配逻辑缺陷**:只用文件名匹配,同名文件会返回第一个匹配的错误 fileKey +2. **类型守卫过于严格**:`isFileResource()` 要求 `fileKey` 存在才返回 true,可能导致有效文件被跳过 +3. Kintone Customization API 的正确用法: + - 从 `Get Customization` 获取的 fileKey 是实时有效的 + - 只要文件附加到 Customization,fileKey 就永久有效 + - 参考:https://docs-customine.gusuku.io/en/error/gaia_bl01/ + +### 如何解决的 + +修改 `src/main/ipc-handlers.ts` 中的匹配逻辑: + +1. **新增 `isFileType()` 类型守卫**:只检查 `type === "FILE"`,不要求 `fileKey` 存在 +2. **实现三级匹配策略**: + - 优先级 1:用前端传来的 `fileKey` 精确匹配(最可靠) + - 优先级 2:用 URL 精确匹配(针对 URL 类型) + - 优先级 3:用文件名匹配(fallback,同名文件可能出错) +3. 添加详细调试日志和验证检查 + +```typescript +// 新增类型守卫 +function isFileType(resource): boolean { + return resource.type === "FILE" && !!resource.file; +} + +// 三级匹配策略 +if (file.fileKey) { + matchingFile = currentFiles?.find( + (f) => isFileType(f) && f.file.fileKey === file.fileKey, + ); +} +if (!matchingFile && file.url) { + matchingFile = currentFiles?.find( + (f) => isUrlResource(f) && f.url === file.url, + ); +} +if (!matchingFile) { + matchingFile = currentFiles?.find( + (f) => isFileType(f) && f.file.name === file.fileName, + ); +} +``` + +### 以后如何避免 + +1. **匹配逻辑优先使用唯一标识符**:不要只用名称匹配,优先使用 ID、key 等唯一标识 +2. **类型守卫要区分"类型检查"和"有效性验证"**: + - 类型检查:`type === "FILE"` + - 有效性验证:`fileKey` 是否存在 + - 这两个应该分开处理 +3. **Kintone fileKey 的生命周期**: + - 用于 Customization:永久有效(只要附加到 App) + - 用于记录附件:3 天内必须使用 +4. **添加调试日志**:在复杂的匹配逻辑中添加调试日志,便于排查问题 + +--- + +## 2026-03-17 - GAIA_BL01 部署错误修复(初始记录 - 已过时) ### 遇到什么问题 @@ -27,7 +92,6 @@ --- - # MEMORY.md ## 2026-03-15 - CSS 模板字符串语法错误 diff --git a/src/main/errors.ts b/src/main/errors.ts index 1421f4f..34475ef 100644 --- a/src/main/errors.ts +++ b/src/main/errors.ts @@ -16,7 +16,10 @@ export type MainErrorKey = | "unknownError" | "rollbackNotImplemented" | "encryptPasswordFailed" - | "decryptPasswordFailed"; + | "decryptPasswordFailed" + | "deployFailed" + | "deployCancelled" + | "deployTimeout"; /** * Error messages by locale @@ -31,6 +34,9 @@ const errorMessages: Record> = { rollbackNotImplemented: "回滚功能尚未实现", encryptPasswordFailed: "密码加密失败", decryptPasswordFailed: "密码解密失败", + deployFailed: "部署失败", + deployCancelled: "部署已取消", + deployTimeout: "部署超时,请稍后刷新查看结果", }, "en-US": { domainNotFound: "Domain not found", @@ -41,6 +47,9 @@ const errorMessages: Record> = { rollbackNotImplemented: "Rollback not yet implemented", encryptPasswordFailed: "Failed to encrypt password", decryptPasswordFailed: "Failed to decrypt password", + deployFailed: "Deployment failed", + deployCancelled: "Deployment cancelled", + deployTimeout: "Deployment timed out, please refresh later", }, "ja-JP": { domainNotFound: "ドメインが見つかりません", @@ -51,6 +60,9 @@ const errorMessages: Record> = { rollbackNotImplemented: "ロールバック機能はまだ実装されていません", encryptPasswordFailed: "パスワードの暗号化に失敗しました", decryptPasswordFailed: "パスワードの復号化に失敗しました", + deployFailed: "デプロイに失敗しました", + deployCancelled: "デプロイがキャンセルされました", + deployTimeout: "デプロイがタイムアウトしました。後で更新してください", }, }; diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index 3a81ca9..4a10898 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -57,7 +57,13 @@ import type { BackupMetadata, DownloadFile, } from "@shared/types/version"; -import { isFileResource, isUrlResource, type AppCustomizeParameter, type AppDetail } from "@shared/types/kintone"; +import { + FileConfigResponse, + isFileResource, + isUrlResource, + type AppCustomizeParameter, + type AppDetail, +} from "@shared/types/kintone"; import { getDisplayName, getFileKey } from "@shared/utils/fileDisplay"; import { getErrorMessage } from "./errors"; @@ -96,12 +102,49 @@ function handle

( const data = await handler(params as P); return { success: true, data }; } catch (error) { - const message = error instanceof Error ? error.message : getErrorMessage("unknownError"); + const message = + error instanceof Error + ? error.message + : getErrorMessage("unknownError"); return { success: false, error: message }; } }); } +/** + * Wait for Kintone deployment to complete + * Polls getDeployStatus until SUCCESS, FAIL, CANCEL, or timeout + */ +async function waitForDeploySuccess( + client: KintoneClient, + appId: string, + options: { timeoutMs?: number; pollIntervalMs?: number } = {}, +): Promise { + const { timeoutMs = 60000, pollIntervalMs = 1000 } = options; + const startTime = Date.now(); + + while (Date.now() - startTime < timeoutMs) { + const status = await client.getDeployStatus(appId); + + if (status === "SUCCESS") { + return; + } + + if (status === "FAIL") { + throw new Error(getErrorMessage("deployFailed")); + } + + if (status === "CANCEL") { + throw new Error(getErrorMessage("deployCancelled")); + } + + // status === "PROCESSING" - wait and retry + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } + + throw new Error(getErrorMessage("deployTimeout")); +} + // ==================== Domain Management IPC Handlers ==================== /** @@ -304,7 +347,7 @@ async function addFilesToBackup( client: KintoneClient, appDetail: AppDetail, backupFiles: Map, - backupFileList: DownloadFile[] + backupFileList: DownloadFile[], ): Promise { const files = appDetail.customization?.[platform]?.[fileType] || []; @@ -353,10 +396,38 @@ function registerDeploy(): void { const backupFiles = new Map(); const backupFileList: BackupMetadata["files"] = []; - await addFilesToBackup("desktop", "js", client, appDetail, backupFiles, backupFileList); - await addFilesToBackup("desktop", "css", client, appDetail, backupFiles, backupFileList); - await addFilesToBackup("mobile", "js", client, appDetail, backupFiles, backupFileList); - await addFilesToBackup("mobile", "css", client, appDetail, backupFiles, backupFileList); + await addFilesToBackup( + "desktop", + "js", + client, + appDetail, + backupFiles, + backupFileList, + ); + await addFilesToBackup( + "desktop", + "css", + client, + appDetail, + backupFiles, + backupFileList, + ); + await addFilesToBackup( + "mobile", + "js", + client, + appDetail, + backupFiles, + backupFileList, + ); + await addFilesToBackup( + "mobile", + "css", + client, + appDetail, + backupFiles, + backupFileList, + ); const backupMetadata: BackupMetadata = { backedUpAt: new Date().toISOString(), @@ -381,16 +452,114 @@ function registerDeploy(): void { for (const file of params.files) { if (file.status === "deleted") continue; - type FileEntry = { type: "FILE"; file: { fileKey: string } } | { type: "URL"; url: string }; + type FileEntry = + | { type: "FILE"; file: { fileKey: string } } + | { type: "URL"; url: string }; let entry: FileEntry; if (file.status === "unchanged") { + // IMPORTANT: Get fresh fileKey from current Kintone config, not from frontend + // The fileKey from frontend may have been consumed by a previous deployment + // and would cause GAIA_BL01 error + const currentFiles = + file.platform === "desktop" + ? file.fileType === "js" + ? appDetail.customization?.desktop?.js + : appDetail.customization?.desktop?.css + : file.fileType === "js" + ? appDetail.customization?.mobile?.js + : appDetail.customization?.mobile?.css; + + // Strategy for matching unchanged files: + // 1. First, try to match by fileKey (most reliable for FILE type) + // 2. If not found, fall back to name matching (for URL type or if fileKey changed) + // + // Note: We use isFileType() instead of isFileResource() for matching + // because isFileResource() requires fileKey to exist, which could cause + // valid files to be skipped during matching. + + let matchingFile: FileConfigResponse | undefined; + let matchMethod: string = "unknown"; + + // Step 1: Try exact fileKey match (most reliable) if (file.fileKey) { - entry = { type: "FILE", file: { fileKey: file.fileKey } }; - } else if (file.url) { - entry = { type: "URL", url: file.url }; + matchingFile = currentFiles?.find( + (f) => isFileResource(f) && f.file.fileKey === file.fileKey, + ); + if (matchingFile) { + matchMethod = "fileKey exact match"; + console.log(`[DEPLOY DEBUG] Matched by fileKey: ${file.fileKey}`); + } + } + + // Step 2: Try URL match for URL-type files + if (!matchingFile && file.url) { + matchingFile = currentFiles?.find( + (f) => isUrlResource(f) && f.url === file.url, + ); + if (matchingFile) { + matchMethod = "URL exact match"; + console.log(`[DEPLOY DEBUG] Matched by URL: ${file.url}`); + } + } + + // Step 3: Fall back to name matching (less reliable, could match wrong file if duplicates exist) + if (!matchingFile) { + matchingFile = currentFiles?.find((f) => { + if (isFileResource(f)) { + return f.file.name === file.fileName; + } + return false; + }); + if (matchingFile) { + matchMethod = "filename match (fallback)"; + console.log( + `[DEPLOY DEBUG] Matched by filename (fallback): ${file.fileName}`, + ); + } + } + + console.log( + `[DEPLOY DEBUG] Final matching result for "${file.fileName}":`, + matchingFile + ? `${matchMethod} → ${isFileResource(matchingFile) ? `FILE key="${matchingFile.file.fileKey}"` : `URL "${matchingFile.url}"`}` + : "NOT FOUND", + ); + + if (matchingFile) { + if (isFileResource(matchingFile)) { + // Validate that the matched file has a valid fileKey + if (!matchingFile.file.fileKey) { + throw new Error( + `Matched file "${file.fileName}" has no fileKey in Kintone config. ` + + `This indicates corrupted data. Please refresh and try again.`, + ); + } + // Verify filename matches (sanity check) + if (matchingFile.file.name !== file.fileName) { + console.warn( + `[DEPLOY WARNING] Filename mismatch: expected "${file.fileName}", found "${matchingFile.file.name}". ` + + `Proceeding with matched fileKey.`, + ); + } + entry = { + type: "FILE", + file: { fileKey: matchingFile.file.fileKey }, + }; + } else if (isUrlResource(matchingFile)) { + entry = { type: "URL", url: matchingFile.url }; + } else { + throw new Error( + `Invalid file type in Kintone config for "${file.fileName}"`, + ); + } } else { - throw new Error(`Invalid unchanged file entry: no fileKey or url for "${file.fileName}"`); + // File not found in current Kintone config - this is an error + // The file may have been deleted from Kintone externally + throw new Error( + `File "${file.fileName}" not found in current Kintone configuration. ` + + `It may have been deleted externally. Please refresh and try again.`, + ); } } else { // added: read from disk and upload @@ -414,6 +583,9 @@ function registerDeploy(): void { await client.updateAppCustomize(params.appId, newConfig); await client.deployApp(params.appId); + // Wait for deployment to complete before returning + await waitForDeploySuccess(client, params.appId); + return { success: true, backupPath, @@ -690,14 +862,14 @@ function registerCheckForUpdates(): void { } const result = await autoUpdater.checkForUpdates(); - + if (result && result.updateInfo) { const currentVersion = app.getVersion(); const latestVersion = result.updateInfo.version; - + // Compare versions const hasUpdate = latestVersion !== currentVersion; - + return { hasUpdate, updateInfo: hasUpdate @@ -709,7 +881,7 @@ function registerCheckForUpdates(): void { : undefined, }; } - + return { hasUpdate: false, updateInfo: undefined, @@ -781,26 +953,32 @@ export function registerIpcHandlers(): void { * Show save dialog */ function registerShowSaveDialog(): void { - handle<{ defaultPath?: string }, string | null>("showSaveDialog", async (params) => { - const result = await dialog.showSaveDialog({ - defaultPath: params.defaultPath, - filters: [ - { name: "JavaScript", extensions: ["js"] }, - { name: "CSS", extensions: ["css"] }, - { name: "All Files", extensions: ["*"] }, - ], - }); - return result.filePath || null; - }); + handle<{ defaultPath?: string }, string | null>( + "showSaveDialog", + async (params) => { + const result = await dialog.showSaveDialog({ + defaultPath: params.defaultPath, + filters: [ + { name: "JavaScript", extensions: ["js"] }, + { name: "CSS", extensions: ["css"] }, + { name: "All Files", extensions: ["*"] }, + ], + }); + return result.filePath || null; + }, + ); } /** * Save file content to disk */ function registerSaveFileContent(): void { - handle<{ filePath: string; content: string }, void>("saveFileContent", async (params) => { - const fs = await import("fs"); - const buffer = Buffer.from(params.content, "base64"); - await fs.promises.writeFile(params.filePath, buffer); - }); + handle<{ filePath: string; content: string }, void>( + "saveFileContent", + async (params) => { + const fs = await import("fs"); + const buffer = Buffer.from(params.content, "base64"); + await fs.promises.writeFile(params.filePath, buffer); + }, + ); } diff --git a/src/main/kintone-api.ts b/src/main/kintone-api.ts index b5e691a..b081b78 100644 --- a/src/main/kintone-api.ts +++ b/src/main/kintone-api.ts @@ -121,7 +121,7 @@ export class KintoneClient { return this.withErrorHandling(async () => { const [appInfo, customizeInfo] = await Promise.all([ this.client.app.getApp({ id: appId }), - this.client.app.getAppCustomize({ app: appId }), + this.client.app.getAppCustomize({ app: appId, preview: true }), ]); return { diff --git a/src/renderer/src/components/AppDetail/AppDetail.tsx b/src/renderer/src/components/AppDetail/AppDetail.tsx index 6561c55..f953ef1 100644 --- a/src/renderer/src/components/AppDetail/AppDetail.tsx +++ b/src/renderer/src/components/AppDetail/AppDetail.tsx @@ -25,8 +25,7 @@ import { useFileChangeStore, } from "@renderer/stores"; import { CodeViewer } from "../CodeViewer"; -import { isFileResource, isUrlResource } from "@shared/types/kintone"; -import { getDisplayName, getFileKey } from "@shared/utils/fileDisplay"; +import { transformCustomizeToFiles } from "@shared/utils/fileTransform"; import type { DeployFileEntry } from "@shared/types/ipc"; import FileSection from "./FileSection"; @@ -115,71 +114,7 @@ const AppDetail: React.FC = () => { setSelectedFile(null); }, [selectedAppId]); - // Load app detail when selected - useEffect(() => { - if (currentDomain && selectedAppId) { - loadAppDetail(); - } - }, [currentDomain, selectedAppId]); - - // Initialize file change store from Kintone data - useEffect(() => { - if (!currentApp || !currentDomain || !selectedAppId) return; - - const customize = currentApp.customization; - if (!customize) return; - - const files = [ - ...(customize.desktop?.js ?? []).map((file, index) => ({ - id: crypto.randomUUID(), - fileName: getDisplayName(file, "js", index), - fileType: "js" as const, - platform: "desktop" as const, - size: isFileResource(file) - ? parseInt(file.file.size ?? "0", 10) || undefined - : undefined, - fileKey: getFileKey(file), - url: isUrlResource(file) ? file.url : undefined, - })), - ...(customize.desktop?.css ?? []).map((file, index) => ({ - id: crypto.randomUUID(), - fileName: getDisplayName(file, "css", index), - fileType: "css" as const, - platform: "desktop" as const, - size: isFileResource(file) - ? parseInt(file.file.size ?? "0", 10) || undefined - : undefined, - fileKey: getFileKey(file), - url: isUrlResource(file) ? file.url : undefined, - })), - ...(customize.mobile?.js ?? []).map((file, index) => ({ - id: crypto.randomUUID(), - fileName: getDisplayName(file, "js", index), - fileType: "js" as const, - platform: "mobile" as const, - size: isFileResource(file) - ? parseInt(file.file.size ?? "0", 10) || undefined - : undefined, - fileKey: getFileKey(file), - url: isUrlResource(file) ? file.url : undefined, - })), - ...(customize.mobile?.css ?? []).map((file, index) => ({ - id: crypto.randomUUID(), - fileName: getDisplayName(file, "css", index), - fileType: "css" as const, - platform: "mobile" as const, - size: isFileResource(file) - ? parseInt(file.file.size ?? "0", 10) || undefined - : undefined, - fileKey: getFileKey(file), - url: isUrlResource(file) ? file.url : undefined, - })), - ]; - - fileChangeStore.initializeApp(currentDomain.id, selectedAppId, files); - }, [currentApp?.appId]); - - const loadAppDetail = async () => { + const loadAppDetail = useCallback(async () => { if (!currentDomain || !selectedAppId) return undefined; setLoading(true); @@ -191,6 +126,8 @@ const AppDetail: React.FC = () => { // Check if we're still on the same app and component is mounted before updating if (result.success) { + // Clear changes and reload from Kintone + fileChangeStore.clearChanges(currentDomain.id, selectedAppId); setCurrentApp(result.data); return result.data; } @@ -201,7 +138,25 @@ const AppDetail: React.FC = () => { } finally { setLoading(false); } - }; + }, [currentDomain, selectedAppId, setCurrentApp, setLoading]); + + // Load app detail when selected + useEffect(() => { + if (currentDomain && selectedAppId) { + loadAppDetail(); + } + }, [currentDomain, selectedAppId, loadAppDetail]); + + // Initialize file change store from Kintone data + useEffect(() => { + if (!currentApp || !currentDomain || !selectedAppId) return; + + const customize = currentApp.customization; + if (!customize) return; + + const files = transformCustomizeToFiles(customize); + fileChangeStore.initializeApp(currentDomain.id, selectedAppId, files); + }, [currentApp]); const handleFileClick = useCallback((fileKey: string, name: string) => { const ext = name.split(".").pop()?.toLowerCase(); @@ -210,6 +165,21 @@ const AppDetail: React.FC = () => { setViewMode("code"); }, []); + const handleRefresh = useCallback(async () => { + if (!currentDomain || !selectedAppId || refreshing) return; + + setRefreshing(true); + try { + await loadAppDetail(); + message.success(t("refreshSuccess", { ns: "common" })); + } catch (error) { + console.error("Failed to refresh:", error); + message.error(t("refreshFailed", { ns: "common" })); + } finally { + setRefreshing(false); + } + }, [currentDomain, selectedAppId, refreshing, fileChangeStore, loadAppDetail, message, t]); + const handleBackToList = useCallback(() => { setViewMode("list"); setSelectedFile(null); @@ -333,8 +303,6 @@ const AppDetail: React.FC = () => { if (result.success) { message.success(t("deploySuccess")); - // Clear changes and reload from Kintone - fileChangeStore.clearChanges(currentDomain.id, selectedAppId); await loadAppDetail(); } else { message.error(result.error || t("deployFailed")); @@ -344,7 +312,7 @@ const AppDetail: React.FC = () => { } finally { setDeploying(false); } - }, [currentDomain, selectedAppId, deploying, fileChangeStore, message, t]); + }, [currentDomain, selectedAppId, deploying, fileChangeStore, loadAppDetail, message, t]); if (!currentDomain || !selectedAppId) { return ( diff --git a/src/renderer/src/components/AppDetail/FileSection.tsx b/src/renderer/src/components/AppDetail/FileSection.tsx index 3e9e5fd..043f4ae 100644 --- a/src/renderer/src/components/AppDetail/FileSection.tsx +++ b/src/renderer/src/components/AppDetail/FileSection.tsx @@ -176,7 +176,6 @@ const FileSection: React.FC = ({ }; addFile(domainId, appId, entry); - message.success(t("fileAdded", { name: result.data.fileName })); } catch { message.error(t("fileAddFailed")); } finally { diff --git a/src/renderer/src/locales/en-US/app.json b/src/renderer/src/locales/en-US/app.json index 5e8f950..1ce00bb 100644 --- a/src/renderer/src/locales/en-US/app.json +++ b/src/renderer/src/locales/en-US/app.json @@ -43,7 +43,6 @@ "dropFileHere": "Drop file here", "fileTypeNotSupported": "Only {{expected}} files are supported", "fileSizeExceeded": "File size exceeds the 20 MB limit", - "fileAdded": "{{name}} added", "fileAddFailed": "Failed to add file", "fileDeleteFailed": "Failed to delete file", "statusAdded": "New", diff --git a/src/renderer/src/locales/ja-JP/app.json b/src/renderer/src/locales/ja-JP/app.json index fc8841b..eee3d64 100644 --- a/src/renderer/src/locales/ja-JP/app.json +++ b/src/renderer/src/locales/ja-JP/app.json @@ -43,7 +43,6 @@ "dropFileHere": "ここにドロップ", "fileTypeNotSupported": "{{expected}} ファイルのみ対応しています", "fileSizeExceeded": "ファイルサイズが 20 MB 制限を超えています", - "fileAdded": "{{name}} を追加しました", "fileAddFailed": "ファイルの追加に失敗しました", "fileDeleteFailed": "ファイルの削除に失敗しました", "statusAdded": "新規", diff --git a/src/renderer/src/locales/zh-CN/app.json b/src/renderer/src/locales/zh-CN/app.json index 7410328..375a979 100644 --- a/src/renderer/src/locales/zh-CN/app.json +++ b/src/renderer/src/locales/zh-CN/app.json @@ -43,7 +43,6 @@ "dropFileHere": "松开以添加文件", "fileTypeNotSupported": "仅支持 {{expected}} 文件", "fileSizeExceeded": "文件大小超过 20 MB 限制", - "fileAdded": "{{name}} 已添加", "fileAddFailed": "添加文件失败", "fileDeleteFailed": "删除文件失败", "statusAdded": "新增", diff --git a/src/shared/types/ipc.ts b/src/shared/types/ipc.ts index 549b809..e53f2ad 100644 --- a/src/shared/types/ipc.ts +++ b/src/shared/types/ipc.ts @@ -55,6 +55,17 @@ export interface GetFileContentParams { // ==================== Deploy IPC Types ==================== +/** + * File for deployment - used in UI before actual deployment + * This is a renderer-side type for managing files to be deployed + */ +export interface DeployFile { + content: string; + fileName: string; + fileType: "js" | "css"; + position: string; +} + export type FileStatus = "unchanged" | "added" | "deleted"; export interface DeployFileEntry { diff --git a/src/shared/utils/fileTransform.ts b/src/shared/utils/fileTransform.ts new file mode 100644 index 0000000..ceaa022 --- /dev/null +++ b/src/shared/utils/fileTransform.ts @@ -0,0 +1,79 @@ +import type { + AppCustomizeResponse, + FileConfigResponse, +} from "@shared/types/kintone"; +import { isFileResource, isUrlResource } from "@shared/types/kintone"; +import type { FileEntry } from "@renderer/stores/fileChangeStore"; +import { getDisplayName, getFileKey } from "./fileDisplay"; + +/** + * Transform Kintone customize data into FileEntry array format + * Used to initialize file change store from Kintone API response + */ +export function transformCustomizeToFiles( + customize: AppCustomizeResponse | undefined, +): Array> { + if (!customize) return []; + + const files: Array> = []; + + // Desktop JS files + if (customize.desktop?.js) { + files.push( + ...customize.desktop.js.map((file, index) => + transformFileConfig(file, "js", "desktop", index), + ), + ); + } + + // Desktop CSS files + if (customize.desktop?.css) { + files.push( + ...customize.desktop.css.map((file, index) => + transformFileConfig(file, "css", "desktop", index), + ), + ); + } + + // Mobile JS files + if (customize.mobile?.js) { + files.push( + ...customize.mobile.js.map((file, index) => + transformFileConfig(file, "js", "mobile", index), + ), + ); + } + + // Mobile CSS files + if (customize.mobile?.css) { + files.push( + ...customize.mobile.css.map((file, index) => + transformFileConfig(file, "css", "mobile", index), + ), + ); + } + + return files; +} + +/** + * Transform a single file config into FileEntry format + */ +function transformFileConfig( + file: FileConfigResponse, + fileType: "js" | "css", + platform: "desktop" | "mobile", + index: number, +): Omit { + return { + id: crypto.randomUUID(), + fileName: getDisplayName(file, fileType, index), + fileType, + platform, + size: isFileResource(file) + ? parseInt(file.file.size ?? "0", 10) || undefined + : undefined, + fileKey: getFileKey(file), + url: isUrlResource(file) ? file.url : undefined, + }; +}