diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..0c34f38 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "unreal-mcp": { + "type": "http", + "url": "http://127.0.0.1:8000/mcp" + } + } +} diff --git a/.vscode/settings.json b/.vscode/settings.json index af7f1fa..8e90333 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -65,13 +65,16 @@ "EXPORTMAP", "fguid", "gatherable", + "Qfecheck", "scrollview", "SERIALSIZES", "SOFTOBJECTPATH", "TEMPLATEINDEX", "uasset", "uassets", - "UPACKAGE" + "umap", + "UPACKAGE", + "uproject" ], "[json]": { "editor.defaultFormatter": "biomejs.biome" diff --git a/README.md b/README.md index d7cd07c..e96457f 100644 --- a/README.md +++ b/README.md @@ -18,51 +18,3 @@ $ yarn install # Run the app in development mode $ yarn dev ``` - -## Dev Notes -### Keeping the UObject enum in sync with UE5 source -The enums at `src/main/lib/uasset/enums.ts` mirror a handful of headers from -[EpicGames/UnrealEngine](https://github.com/EpicGames/UnrealEngine) (private repo, requires an -Epic-linked GitHub account). A full build of the engine is ~400GB and even a plain clone is -10-15GB, so we keep a sparse checkout of just the `UObject` headers we care about. - -The source lives at `.ue5/UnrealEngine/` (already in `.gitignore`). - -One-time setup, from the repo root: -```bash -$ git clone --filter=blob:none --sparse https://github.com/EpicGames/UnrealEngine.git .ue5/UnrealEngine -$ cd .ue5/UnrealEngine -$ git sparse-checkout set Engine/Source/Runtime/Core/Public/UObject Engine/Source/Runtime/CoreUObject/Public/UObject -``` - -**Important:** always run `git sparse-checkout` commands from **inside** `.ue5/UnrealEngine/`, or -pass `-C .ue5/UnrealEngine` explicitly. If you run one from the gitgame repo root by accident, -git will silently enable sparse-checkout on the **gitgame** repo (because that's the enclosing -`.git` it finds) and hide 99% of your working tree. Recover with: -```bash -$ git sparse-checkout disable -``` -Files are never lost, they're still in `HEAD`/index, just filtered from the working tree. - -If the working tree stays empty after `set` on the UE clone (blob fetch silently failing on the -private repo), force a re-materialize: -```bash -$ git -C .ue5/UnrealEngine sparse-checkout reapply -$ git -C .ue5/UnrealEngine checkout HEAD -- . -``` - -To add more UE paths later (e.g. when we start writing type-specific shapers and need -`Engine/Classes/Engine/Texture.h`): -```bash -$ git -C .ue5/UnrealEngine sparse-checkout add Engine/Source/Runtime/Engine/Classes/Engine -``` - -To pull upstream updates before resyncing enum values: -```bash -$ git -C .ue5/UnrealEngine pull -``` - -Files that map to `enums.ts`: -- `Engine/Source/Runtime/Core/Public/UObject/ObjectVersion.h`: `EUnrealEngineObjectUE4Version`, `EUnrealEngineObjectUE5Version` -- `Engine/Source/Runtime/CoreUObject/Public/UObject/ObjectMacros.h`: `PackageFlags`, `EObjectFlags` -- `Engine/Source/Runtime/CoreUObject/Public/UObject/PackageFileSummary.h`: header layout the parser reads diff --git a/package.json b/package.json index 60f25c0..eba73a7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,6 @@ { "name": "@cybearl/gitgame", "version": "0.0.0-semantically-released", - "productName": "gitgame", "description": "A small tool with a web interface to simplify UE5-based game development with Git LFS.", "author": "Cybearl (https://www.cybearl.com/)", "license": "MIT", diff --git a/src/main/config/dialogs.ts b/src/main/config/dialogs.ts index 09eac0b..f50dcd0 100644 --- a/src/main/config/dialogs.ts +++ b/src/main/config/dialogs.ts @@ -1,10 +1,17 @@ +import type { DialogVariant } from "@/main/types/dialogs" + /** * The main configuration for dialogs. */ -const DIALOGS_CONFIG = { +const DIALOGS_CONFIG: { + sizes: Record +} = { sizes: { - confirm: { width: 420, height: 230 }, - error: { width: 480, height: 300 }, + confirm: { width: 480, height: 300 }, + message: { width: 480, height: 230 }, + error: { width: 480, height: 230 }, + "error-with-details": { width: 480, height: 380 }, + update: { width: 480, height: 380 }, }, } diff --git a/src/main/config/git.ts b/src/main/config/git.ts index 7a4f038..2d4bb66 100644 --- a/src/main/config/git.ts +++ b/src/main/config/git.ts @@ -5,6 +5,9 @@ const GIT_CONFIG = { defaultLogOutputLimit: 100, checkAttributeChunkSize: 256, + maxBufferSize: 64 * 1024 * 1024, // 64 MB + lockConcurrency: 6, + lockTimeoutMs: 60_000, } export default GIT_CONFIG diff --git a/src/main/config/updater.ts b/src/main/config/updater.ts index 356c641..dd7bbcd 100644 --- a/src/main/config/updater.ts +++ b/src/main/config/updater.ts @@ -4,6 +4,15 @@ const UPDATER_CONFIG = { checkIntervalMs: 4 * 60 * 60 * 1000, + startupCheckDelayMs: 15 * 1000, + releasesUrl: "https://github.com/cybearl/gitgame/releases/latest", + autoInstallPlatforms: ["win32"] as NodeJS.Platform[], + simulatedDownload: { + version: "9.9.9", + totalBytes: 86 * 1024 * 1024, + tickMs: 120, + tickPercent: 4, + }, } export default UPDATER_CONFIG diff --git a/src/main/index.ts b/src/main/index.ts index 648545b..6f36a49 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -7,8 +7,10 @@ import { registerGitCommandsHandlers } from "@main/lib/gitCommands/handlers" import { registerLfsCommandsHandlers } from "@main/lib/lfsCommands/handlers" import { registerProjectsHandlers } from "@main/lib/projects/handlers" import { registerShellsHandlers } from "@main/lib/shells/handlers" -import { startAutoUpdater } from "@main/lib/updater" -import { attachWindowStateBroadcaster, registerWindowsControlHandlers } from "@main/lib/windows" +import { startUpdater } from "@main/lib/updater" +import { registerUpdaterHandlers } from "@main/lib/updater/handlers" +import { registerUProjectHandlers } from "@main/lib/uproject/handlers" +import { attachWindowStateBroadcaster, registerWindowsControlHandlers, setMainWindow } from "@main/lib/windows" import { app, BrowserWindow, shell } from "electron" /** @@ -25,6 +27,9 @@ function createMainWindow(): BrowserWindow { // Broadcast focus/visibility/maximize/fullscreen state changes to the renderer attachWindowStateBroadcaster(window) + // Track it as the main window, so app-global features can parent to it + setMainWindow(window) + // Open external links in the user's default browser window.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url) @@ -50,9 +55,11 @@ app.whenReady().then(() => { registerLfsCommandsHandlers() registerFileTreeHandlers() registerProjectsHandlers() + registerUProjectHandlers() registerShellsHandlers() + registerUpdaterHandlers() createMainWindow() - startAutoUpdater() + startUpdater() // Re-create a window in the app when the dock icon is clicked (macOS) app.on("activate", () => { diff --git a/src/main/lib/constants.ts b/src/main/lib/constants.ts index 4b32745..1a3bc46 100644 --- a/src/main/lib/constants.ts +++ b/src/main/lib/constants.ts @@ -1,5 +1,5 @@ /** - * The constants used throughout the application. + * The constants for the main process. */ const CONSTANTS = { titleBarHeight: 28, @@ -39,46 +39,72 @@ const CONSTANTS = { projectsClearRecent: "projects:clear-recent", projectsGetPreferences: "projects:get-preferences", projectsSetPreferences: "projects:set-preferences", + // UProject + uprojectOpen: "uproject:open", // Shells shellsOpenExternal: "shells:open-external", + shellsShowFolder: "shells:show-folder", + shellsOpenTerminal: "shells:open-terminal", // Dialogs dialogsConfirm: "dialogs:confirm", + dialogsMessage: "dialogs:message", dialogsError: "dialogs:error", + dialogsErrorWithDetails: "dialogs:error-with-details", dialogsGetOptions: "dialogs:get-options", dialogsRespond: "dialogs:respond", + // Updater + updaterGetState: "updater:get-state", + updaterStateChanged: "updater:state-changed", + updaterCheck: "updater:check", + updaterDownload: "updater:download", + updaterInstall: "updater:install", + updaterOpenDialog: "updater:open-dialog", + updaterSimulate: "updater:simulate", + }, + updater: { + devCheckMessage: + 'Update checks are disabled in development builds, use the "Dev Tools" menu to preview the update dialog.', + }, + uproject: { + missingDirectoryMessage: "The project folder no longer exists on disk.", + missingFileMessage: "No .uproject file was found at the root of the project folder.", + openFailureMessage: + "The system could not open the .uproject file, make sure Unreal Engine is installed and registered as the handler for .uproject files.", + }, + shells: { + missingFolderMessage: "The folder no longer exists on disk.", + openFolderFailureMessage: "The system could not open the folder in the file manager.", + noTerminalMessage: "No terminal could be launched, none of the ones we know about are installed or on PATH.", + + /** + * The terminals tried in order when opening a folder in a terminal, the first one that + * launches wins. + * + * Note: Linux has no standard terminal, hence the list, `x-terminal-emulator` comes + * first because it is the Debian alternatives symlink pointing at the user's choice. + */ + terminals: { + win32: [ + { command: "wt.exe", args: ["-d"], appendDir: true }, + { command: "cmd.exe", args: ["/c", "start", "", "cmd.exe"], appendDir: false }, + ], + darwin: [{ command: "open", args: ["-a", "Terminal"], appendDir: true }], + linux: [ + { command: "x-terminal-emulator", args: [], appendDir: false }, + { command: "gnome-terminal", args: [], appendDir: false }, + { command: "konsole", args: [], appendDir: false }, + { command: "xfce4-terminal", args: [], appendDir: false }, + { command: "alacritty", args: [], appendDir: false }, + { command: "kitty", args: [], appendDir: false }, + { command: "xterm", args: [], appendDir: false }, + ], + } as Record, }, git: { logFieldSeparator: "\x1f", logRecordSeparator: "\x1e", logFormat: ["%H", "%h", "%s", "%an", "%ae", "%aI"], // Joined with field separator and ends with record separator }, - uasset: { - // Highest "LegacyFileVersion" the summary reader handles, anything below (i.e. more - // negative) uses a summary layout we haven't verified against UE source - currentLegacyFileVersion: -9, - // Prefix that marks user-created (game content) asset packages, imports whose outer - // chain terminates outside this prefix are engine/plugin classes - gamePackagePrefix: "/Game/", - // Bit values of "EPropertyTagFlags", the uint8 bitmask on every property tag header - propertyTagFlag: { - hasArrayIndex: 0x01, - hasPropertyGuid: 0x02, - hasPropertyExtensions: 0x04, - hasBinaryOrNativeSerialize: 0x08, - boolTrue: 0x10, - skippedSerialize: 0x20, - }, - // Bit values of "EPropertyTagExtension", the uint8 bitmask written when "hasPropertyExtensions" is set - propertyTagExtension: { - overridableInformation: 0x02, - hasExternalsObjects: 0x04, - }, - // Blueprint-specific magic strings the shaper matches on - blueprint: { - componentSuffix: "_GEN_VARIABLE", - compiledClassSuffix: "_C", - }, - }, } as const export default CONSTANTS diff --git a/src/main/lib/dialogs/index.ts b/src/main/lib/dialogs/index.ts index 80ce8e5..bcb7c7c 100644 --- a/src/main/lib/dialogs/index.ts +++ b/src/main/lib/dialogs/index.ts @@ -7,17 +7,15 @@ import WINDOWS_CONFIG from "@/main/config/windows" import type { ConfirmDialogOptions, DialogOptions } from "@/main/types/dialogs" /** - * A dialog window awaiting the user's response. + * The options of every open dialog window, keyed by their `BrowserWindow` ID. */ -type PendingDialog = { - options: DialogOptions - resolve: (result: boolean) => void -} +const dialogOptions = new Map() /** - * The open dialog windows, keyed by their `BrowserWindow` id. + * The resolvers of the dialog windows awaiting a boolean response, keyed by their + * `BrowserWindow` ID. */ -const pendingDialogs = new Map() +const pendingDialogs = new Map void>() /** * Loads the renderer's dialog route into the given window, in development or production. @@ -32,42 +30,63 @@ function loadDialog(window: BrowserWindow) { } /** - * Opens a frameless, Win95-styled modal dialog window and resolves once the user - * responds or closes it (a close counts as a cancel). + * Creates a frameless, Win95-styled dialog window sized for its variant and loads + * the renderer's dialog route into it. * @param parent The window that owns the dialog, or `null` to show it detached. * @param options The dialog contents and variant. - * @returns `true` when confirmed, `false` when cancelled or closed. + * @param isModal Whether the dialog blocks its parent, defaults to blocking whenever it has one. + * @returns The created dialog window. */ -export function openDialog(parent: BrowserWindow | null, options: DialogOptions): Promise { - return new Promise(resolve => { - const size = DIALOGS_CONFIG.sizes[options.variant] - - const window = new BrowserWindow({ - ...WINDOWS_CONFIG.dialog, - parent: parent ?? undefined, - modal: parent !== null, - width: size.width, - height: size.height, - }) +export function createDialogWindow( + parent: BrowserWindow | null, + options: DialogOptions, + isModal = parent !== null, +): BrowserWindow { + const size = DIALOGS_CONFIG.sizes[options.variant] + + const window = new BrowserWindow({ + ...WINDOWS_CONFIG.dialog, + parent: parent ?? undefined, + modal: isModal, + width: size.width, + height: size.height, + }) - pendingDialogs.set(window.id, { options, resolve }) + dialogOptions.set(window.id, options) - // Broadcast focus and visibility changes so the dialog's title bar can - // reflect the active state consistently with the main window - attachWindowStateBroadcaster(window) + // Broadcast focus and visibility changes so the dialog's title bar can + // reflect the active state consistently with the main window + attachWindowStateBroadcaster(window) - window.once("ready-to-show", () => window.show()) + window.once("ready-to-show", () => window.show()) - // A window closed without an explicit response resolves as a cancel - window.on("closed", () => { - const pending = pendingDialogs.get(window.id) - if (pending) { - pendingDialogs.delete(window.id) - pending.resolve(false) - } - }) + // A window closed without an explicit response resolves as a cancel + window.on("closed", () => { + dialogOptions.delete(window.id) + + const resolve = pendingDialogs.get(window.id) + if (resolve) { + pendingDialogs.delete(window.id) + resolve(false) + } + }) + + loadDialog(window) + + return window +} - loadDialog(window) +/** + * Opens a modal dialog window and resolves once the user responds or closes it + * (a close counts as a cancel). + * @param parent The window that owns the dialog, or `null` to show it detached. + * @param options The dialog contents and variant. + * @returns `true` when confirmed, `false` when cancelled or closed. + */ +export function openDialog(parent: BrowserWindow | null, options: DialogOptions): Promise { + return new Promise(resolve => { + const window = createDialogWindow(parent, options) + pendingDialogs.set(window.id, resolve) }) } @@ -80,23 +99,36 @@ export function registerDialogsHandlers() { openDialog(BrowserWindow.fromWebContents(event.sender), { ...options, variant: "confirm" }), ) - ipcMain.on(CONSTANTS.ipc.dialogsError, (event, title: string, message: string, detail?: string) => { - openDialog(BrowserWindow.fromWebContents(event.sender), { variant: "error", title, message, detail }) + ipcMain.on(CONSTANTS.ipc.dialogsMessage, (event, title: string, message: string) => { + openDialog(BrowserWindow.fromWebContents(event.sender), { variant: "message", title, message }) + }) + + ipcMain.on(CONSTANTS.ipc.dialogsError, (event, title: string, message: string) => { + openDialog(BrowserWindow.fromWebContents(event.sender), { variant: "error", title, message }) + }) + + ipcMain.on(CONSTANTS.ipc.dialogsErrorWithDetails, (event, title: string, message: string, details: string) => { + openDialog(BrowserWindow.fromWebContents(event.sender), { + variant: "error-with-details", + title, + message, + details, + }) }) ipcMain.handle(CONSTANTS.ipc.dialogsGetOptions, event => { const window = BrowserWindow.fromWebContents(event.sender) - return window ? (pendingDialogs.get(window.id)?.options ?? null) : null + return window ? (dialogOptions.get(window.id) ?? null) : null }) ipcMain.on(CONSTANTS.ipc.dialogsRespond, (event, result: boolean) => { const window = BrowserWindow.fromWebContents(event.sender) if (!window) return - const pending = pendingDialogs.get(window.id) - if (pending) { + const resolve = pendingDialogs.get(window.id) + if (resolve) { pendingDialogs.delete(window.id) - pending.resolve(result) + resolve(result) } window.close() diff --git a/src/main/lib/gitCommands/exec.ts b/src/main/lib/gitCommands/exec.ts index aaa103b..39182d2 100644 --- a/src/main/lib/gitCommands/exec.ts +++ b/src/main/lib/gitCommands/exec.ts @@ -1,35 +1,36 @@ import { execFile } from "node:child_process" import { promisify } from "node:util" +import GIT_CONFIG from "@/main/config/git" import type { GitResult } from "@/main/types/gitCommands" -const execFileAsync = promisify(execFile) - /** - * The maximum amount of stdout/stderr (in bytes) buffered from a single `git` - * invocation before the process is killed. + * Promisified version of `child_process.execFile`. */ -const MAX_BUFFER = 64 * 1024 * 1024 +const execFileAsync = promisify(execFile) /** * Options controlling a single `git` invocation. */ export type RunGitOptions = { cwd: string + timeoutMs?: number } /** * Runs a `git` subprocess and buffers its output, the promise is only rejected - * if the `git` binary cannot be spawned. + * if the `git` binary cannot be spawned. A `timeoutMs` in the options kills the + * process on expiry and returns it as a non-zero exit with a "timed out" stderr. * @param args The arguments passed to `git`, excluding the `git` binary itself. - * @param options The invocation options (currently the working directory). + * @param options The invocation options (working directory and optional timeout). * @returns The captured stdout, stderr, and exit code of the process. */ export async function runGit(args: string[], options: RunGitOptions): Promise { try { const { stdout, stderr } = await execFileAsync("git", args, { cwd: options.cwd, - maxBuffer: MAX_BUFFER, + maxBuffer: GIT_CONFIG.maxBufferSize, windowsHide: true, + timeout: options.timeoutMs, }) return { @@ -38,22 +39,33 @@ export async function runGit(args: string[], options: RunGitOptions): Promise( ok: true, value: await fn(event, ...(args as Args)), } - } catch (err) { - const message = err instanceof Error ? err.message : String(err) + } catch (error) { + const message = convertErrorToMessage(error) console.error(`[ipc] ${channel} failed:`, message) return { diff --git a/src/main/lib/lfsCommands/service.ts b/src/main/lib/lfsCommands/service.ts index 3ea4f5b..b51b1c8 100644 --- a/src/main/lib/lfsCommands/service.ts +++ b/src/main/lib/lfsCommands/service.ts @@ -1,6 +1,7 @@ import { runGit } from "@main/lib/gitCommands/exec" import { getStatus } from "@main/lib/gitCommands/service" import { parseLockablePaths, parseLocks } from "@main/lib/lfsCommands/parse" +import { parallelLimit } from "@main/lib/utils/async" import GIT_CONFIG from "@/main/config/git" import type { LfsLock, LfsLockMigration, LfsLockResult } from "@/main/types/lfsCommands" @@ -115,7 +116,7 @@ async function expandToLockableFiles(dir: string, paths: string[]): Promise { - const result = await runGit(args, { cwd: dir }) + const result = await runGit(args, { cwd: dir, timeoutMs: GIT_CONFIG.lockTimeoutMs }) if (result.exitCode === 0) return { path: file, ok: true } return { @@ -126,9 +127,9 @@ async function runLockCommand(dir: string, args: string[], file: string): Promis } /** - * Locks every lockable file covered by the given paths, in parallel, reporting - * per-file completion through the optional `onProgress` callback so a caller - * can render a live progress bar. + * Locks every lockable file covered by the given paths, capped at + * `GIT_CONFIG.lockConcurrency` in flight to stay under the LFS server's rate + * limit, reporting per-file completion through the optional `onProgress`. * @param dir A path inside the repository. * @param paths The repository-relative paths to lock (files or folders). * @param onProgress Called once with `(0, total)` after expansion, then again @@ -146,20 +147,18 @@ export async function lockPaths( onProgress?.(0, total) let done = 0 - return Promise.all( - files.map(async file => { - const result = await runLockCommand(dir, ["lfs", "lock", file], file) - done += 1 - onProgress?.(done, total) - return result - }), - ) + return parallelLimit(files, GIT_CONFIG.lockConcurrency, async file => { + const result = await runLockCommand(dir, ["lfs", "lock", file], file) + done += 1 + onProgress?.(done, total) + return result + }) } /** - * Unlocks every lockable file covered by the given paths, in parallel, reporting - * per-file completion through the optional `onProgress` callback so a caller - * can render a live progress bar. + * Unlocks every lockable file covered by the given paths, capped at + * `GIT_CONFIG.lockConcurrency` in flight to stay under the LFS server's rate + * limit, reporting per-file completion through the optional `onProgress`. * @param dir A path inside the repository. * @param paths The repository-relative paths to unlock (files or folders). * @param force Whether to force-unlock files locked by other users. @@ -179,15 +178,13 @@ export async function unlockPaths( onProgress?.(0, total) let done = 0 - return Promise.all( - files.map(async file => { - const args = force ? ["lfs", "unlock", "--force", file] : ["lfs", "unlock", file] - const result = await runLockCommand(dir, args, file) - done += 1 - onProgress?.(done, total) - return result - }), - ) + return parallelLimit(files, GIT_CONFIG.lockConcurrency, async file => { + const args = force ? ["lfs", "unlock", "--force", file] : ["lfs", "unlock", file] + const result = await runLockCommand(dir, args, file) + done += 1 + onProgress?.(done, total) + return result + }) } /** diff --git a/src/main/lib/projects/service.ts b/src/main/lib/projects/service.ts index b6b3593..d1e8ef8 100644 --- a/src/main/lib/projects/service.ts +++ b/src/main/lib/projects/service.ts @@ -2,22 +2,26 @@ import path from "node:path" import STORE_CONFIG from "@main/config/store" import { getRepositoryRoot, isRepository } from "@main/lib/gitCommands/service" import { getConfig, updateConfig } from "@main/lib/store" +import { readUProject } from "@main/lib/uproject/service" import { pathExists } from "@main/lib/utils/fs" import { app, type BrowserWindow, dialog } from "electron" import type { OpenProjectResult, Project } from "@/main/types/projects" import type { AppPreferences } from "@/main/types/store" +import type { UProject } from "@/main/types/uproject" /** * Records a repository as the most recently opened project, moving it to the * front of the recent projects list (deduplicated by path and capped). * @param root The absolute repository root path. + * @param uproject Metadata read from the repository's `.uproject` file. * @returns The stored recent project entry. */ -async function rememberProject(root: string): Promise { +async function rememberProject(root: string, uproject: UProject): Promise { const entry: Project = { path: root, name: path.basename(root), lastOpenedAt: new Date().toISOString(), + uproject, } await updateConfig(config => { @@ -60,7 +64,17 @@ export async function openProject(dir: string): Promise { } const root = await getRepositoryRoot(dir) - const project = await rememberProject(root) + + const uproject = await readUProject(root) + if (!uproject) { + return { + ok: false, + reason: "not-a-ue-project", + message: `"${root}" is not an Unreal Engine project (no .uproject file at the repo root).`, + } + } + + const project = await rememberProject(root, uproject) return { ok: true, diff --git a/src/main/lib/shells/handlers.ts b/src/main/lib/shells/handlers.ts index 49f9e29..42d4dfd 100644 --- a/src/main/lib/shells/handlers.ts +++ b/src/main/lib/shells/handlers.ts @@ -1,4 +1,6 @@ import CONSTANTS from "@main/lib/constants" +import { safeHandle } from "@main/lib/ipc" +import { openTerminal, showFolder } from "@main/lib/shells/service" import { ipcMain, shell } from "electron" /** @@ -10,10 +12,14 @@ import { ipcMain, shell } from "electron" const ALLOWED_PROTOCOLS = new Set(["http:", "https:"]) /** - * Registers the IPC handler that opens an external URL in the user's default - * browser, ignoring any URL that is malformed or uses a disallowed protocol. + * Registers the IPC handlers that open an external URL in the user's default browser + * (ignoring any URL that is malformed or uses a disallowed protocol) and that reveal a + * folder in the OS file manager. */ export function registerShellsHandlers() { + safeHandle(CONSTANTS.ipc.shellsShowFolder, (_event, dir: string) => showFolder(dir)) + safeHandle(CONSTANTS.ipc.shellsOpenTerminal, (_event, dir: string) => openTerminal(dir)) + ipcMain.on(CONSTANTS.ipc.shellsOpenExternal, (_event, url: string) => { let parsed: URL diff --git a/src/main/lib/shells/service.ts b/src/main/lib/shells/service.ts new file mode 100644 index 0000000..1e567f4 --- /dev/null +++ b/src/main/lib/shells/service.ts @@ -0,0 +1,69 @@ +import { spawn } from "node:child_process" +import { stat } from "node:fs/promises" +import CONSTANTS from "@main/lib/constants" +import { shell } from "electron" + +/** + * Confirm a path is a directory, so a renderer-provided string can only ever reach the OS + * as a folder and never as an executable. + * @param dir Absolute path to check. + * @throws When the path is missing or is not a directory. + */ +async function assertDirectory(dir: string): Promise { + const stats = await stat(dir).catch(() => null) + if (!stats?.isDirectory()) throw new Error(CONSTANTS.shells.missingFolderMessage) +} + +/** + * Launch a detached process and report whether it actually started, a command that is not + * installed surfaces asynchronously through the `error` event rather than throwing here. + * @param command The executable to run. + * @param args The arguments passed to it. + * @param cwd The working directory the process starts in. + * @returns True when the process spawned, `false` when the command is unavailable. + */ +function trySpawn(command: string, args: string[], cwd: string): Promise { + return new Promise(resolve => { + const child = spawn(command, args, { cwd, detached: true, stdio: "ignore" }) + + child.once("spawn", () => { + // Lets the terminal outlive the app rather than dying alongside it + child.unref() + resolve(true) + }) + + child.once("error", () => resolve(false)) + }) +} + +/** + * Open a folder in the OS file manager (via the default handler). + * @param dir Absolute path to the folder to open. + * @throws When the path is not a directory, or the OS refuses to open it. + */ +export async function showFolder(dir: string): Promise { + await assertDirectory(dir) + + // Returns an empty string if it worked, or an error message if it didn't + const failure = await shell.openPath(dir) + if (failure) throw new Error(`${CONSTANTS.shells.openFolderFailureMessage}\n\n${failure}`) +} + +/** + * Open a terminal with the given folder as its working directory, trying each candidate + * for the platform in turn and keeping the first one that launches. + * @param dir Absolute path to the folder to open the terminal in. + * @throws When the path is not a directory, or no known terminal could be launched. + */ +export async function openTerminal(dir: string): Promise { + await assertDirectory(dir) + + const candidates = CONSTANTS.shells.terminals[process.platform] ?? CONSTANTS.shells.terminals.linux + + for (const candidate of candidates) { + const args = candidate.appendDir ? [...candidate.args, dir] : candidate.args + if (await trySpawn(candidate.command, args, dir)) return + } + + throw new Error(CONSTANTS.shells.noTerminalMessage) +} diff --git a/src/main/lib/uassets/bufferReader.ts b/src/main/lib/uassets/bufferReader.ts deleted file mode 100644 index c693716..0000000 --- a/src/main/lib/uassets/bufferReader.ts +++ /dev/null @@ -1,217 +0,0 @@ -/** - * A position-tracking binary reader for `.uasset` files. - */ -export class UAssetBufferReader { - private readonly view: DataView - private cursor = 0 - private isLittleEndian: boolean - - /** - * @param source Raw buffer or a view over one, the view's `byteOffset`/`byteLength` are respected. - * @param isLittleEndian Endianness for subsequent multi-byte reads, defaults to `true`. - */ - constructor(source: ArrayBuffer | ArrayBufferView, isLittleEndian = true) { - if (source instanceof ArrayBuffer) { - this.view = new DataView(source) - } else { - this.view = new DataView(source.buffer, source.byteOffset, source.byteLength) - } - - this.isLittleEndian = isLittleEndian - } - - /** - * Current byte offset from the start of the buffer. - * @returns The current byte offset. - */ - tell(): number { - return this.cursor - } - - /** - * Move the cursor to an absolute byte position. - */ - seek(position: number): void { - this.cursor = position - } - - /** - * Advance the cursor by `byteCount` bytes without reading. - */ - skip(byteCount: number): void { - this.cursor += byteCount - } - - /** - * Bytes left between the cursor and the end of the buffer. - * @returns The number of bytes remaining. - */ - remaining(): number { - return this.view.byteLength - this.cursor - } - - /** - * True when the cursor has reached or passed the end of the buffer. - * @returns Whether the reader is at end-of-buffer. - */ - atEnd(): boolean { - return this.cursor >= this.view.byteLength - } - - /** - * Toggle endianness for subsequent multi-byte reads. - */ - setLittleEndian(isLittleEndian: boolean): void { - this.isLittleEndian = isLittleEndian - } - - /** - * Read one unsigned byte. - * @returns The unsigned byte value. - */ - uint8(): number { - const value = this.view.getUint8(this.cursor) - this.cursor += 1 - return value - } - - /** - * Read one signed byte. - * @returns The signed byte value. - */ - int8(): number { - const value = this.view.getInt8(this.cursor) - this.cursor += 1 - return value - } - - /** - * Read a 16-bit unsigned integer at the current endianness. - * @returns The 16-bit unsigned integer. - */ - uint16(): number { - const value = this.view.getUint16(this.cursor, this.isLittleEndian) - this.cursor += 2 - return value - } - - /** - * Read a 16-bit signed integer at the current endianness. - * @returns The 16-bit signed integer. - */ - int16(): number { - const value = this.view.getInt16(this.cursor, this.isLittleEndian) - this.cursor += 2 - return value - } - - /** - * Read a 32-bit unsigned integer at the current endianness. - * @returns The 32-bit unsigned integer. - */ - uint32(): number { - const value = this.view.getUint32(this.cursor, this.isLittleEndian) - this.cursor += 4 - return value - } - - /** - * Read a 32-bit signed integer at the current endianness. - * @returns The 32-bit signed integer. - */ - int32(): number { - const value = this.view.getInt32(this.cursor, this.isLittleEndian) - this.cursor += 4 - return value - } - - /** - * Read a 64-bit unsigned integer as `bigint` at the current endianness. - * @returns The 64-bit unsigned integer as `bigint`. - */ - uint64(): bigint { - const value = this.view.getBigUint64(this.cursor, this.isLittleEndian) - this.cursor += 8 - return value - } - - /** - * Read a 64-bit signed integer as `bigint` at the current endianness. - * @returns The 64-bit signed integer as `bigint`. - */ - int64(): bigint { - const value = this.view.getBigInt64(this.cursor, this.isLittleEndian) - this.cursor += 8 - return value - } - - /** - * Read a 32-bit IEEE-754 float at the current endianness. - * @returns The 32-bit float value. - */ - float32(): number { - const value = this.view.getFloat32(this.cursor, this.isLittleEndian) - this.cursor += 4 - return value - } - - /** - * Read a 64-bit IEEE-754 float at the current endianness. - * @returns The 64-bit float value. - */ - float64(): number { - const value = this.view.getFloat64(this.cursor, this.isLittleEndian) - this.cursor += 8 - return value - } - - /** - * Read a UE boolean, serialized as int32 (0 or 1). - * @returns The decoded boolean value. - */ - bool32(): boolean { - return this.int32() !== 0 - } - - /** - * Read `byteCount` bytes into a fresh (non-aliasing) `Uint8Array`. - * @returns A fresh `Uint8Array` containing the read bytes. - */ - bytes(byteCount: number): Uint8Array { - const start = this.view.byteOffset + this.cursor - const slice = new Uint8Array(this.view.buffer.slice(start, start + byteCount)) - this.cursor += byteCount - return slice - } - - /** - * Read a UE `FString`, an int32 length prefix followed by string bytes, ANSI when positive - * or UTF-16LE when negative, the stored length includes the null terminator which is stripped. - * @returns The decoded string, or the empty string when the length prefix is zero. - */ - fstring(): string { - const length = this.int32() - if (length === 0) return "" - - if (length > 0) { - const chars = this.bytes(length) - return new TextDecoder("ascii").decode(chars.subarray(0, length - 1)) - } - - const charCount = -length - const chars = this.bytes(charCount * 2) - return new TextDecoder("utf-16le").decode(chars.subarray(0, (charCount - 1) * 2)) - } - - /** - * Read a UE `FGuid` (16 bytes as four `uint32`) as its canonical 32-char upper-hex string. - * @returns The GUID formatted as 32 upper-case hex characters. - */ - fguid(): string { - const a = this.uint32().toString(16).padStart(8, "0") - const b = this.uint32().toString(16).padStart(8, "0") - const c = this.uint32().toString(16).padStart(8, "0") - const d = this.uint32().toString(16).padStart(8, "0") - return (a + b + c + d).toUpperCase() - } -} diff --git a/src/main/lib/uassets/enums/index.ts b/src/main/lib/uassets/enums/index.ts deleted file mode 100644 index bfff42b..0000000 --- a/src/main/lib/uassets/enums/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { UAssetObjectVersionUE4 } from "@main/lib/uassets/enums/objectVersionUE4" -export { UAssetObjectVersionUE5 } from "@main/lib/uassets/enums/objectVersionUE5" -export { UAssetPackageFileTag } from "@main/lib/uassets/enums/packageFileTag" -export { UAssetPackageFlags } from "@main/lib/uassets/enums/packageFlags" diff --git a/src/main/lib/uassets/enums/objectVersionUE4.ts b/src/main/lib/uassets/enums/objectVersionUE4.ts deleted file mode 100644 index 1267f49..0000000 --- a/src/main/lib/uassets/enums/objectVersionUE4.ts +++ /dev/null @@ -1,951 +0,0 @@ -import type { UAssetEnumEntry } from "@/main/types/uassets" - -/** - * UE4 package format version, read from `PackageFileSummary.FileVersionUE4`, frozen at 522 since UE5. - * @see /Engine/Source/Runtime/Core/Public/UObject/ObjectVersion.h - */ -export const UAssetObjectVersionUE4 = { - VER_UE4_OLDEST_LOADABLE_PACKAGE: { value: 214, comment: "Oldest package managed by Unreal Engine" }, - VER_UE4_BLUEPRINT_VARS_NOT_READ_ONLY: { - value: 215, - comment: "Removed restriction on blueprint-exposed variables from being read-only", - }, - VER_UE4_STATIC_MESH_STORE_NAV_COLLISION: { - value: 216, - comment: "Added manually serialized element to UStaticMesh (precalculated nav collision)", - }, - VER_UE4_ATMOSPHERIC_FOG_DECAY_NAME_CHANGE: { value: 217, comment: "Changed property name for atmospheric fog" }, - VER_UE4_SCENECOMP_TRANSLATION_TO_LOCATION: { - value: 218, - comment: "Change many properties/functions from Translation to Location", - }, - VER_UE4_MATERIAL_ATTRIBUTES_REORDERING: { value: 219, comment: "Material attributes reordering" }, - VER_UE4_COLLISION_PROFILE_SETTING: { - value: 220, - comment: "Collision Profile setting has been added, and all components that exists has to be properly upgraded", - }, - VER_UE4_BLUEPRINT_SKEL_TEMPORARY_TRANSIENT: { - value: 221, - comment: "Making the blueprint's skeleton class transient", - }, - VER_UE4_BLUEPRINT_SKEL_SERIALIZED_AGAIN: { - value: 222, - comment: "Making the blueprint's skeleton class serialized again", - }, - VER_UE4_BLUEPRINT_SETS_REPLICATION: { value: 223, comment: "Blueprint now controls replication settings again" }, - VER_UE4_WORLD_LEVEL_INFO: { value: 224, comment: "Added level info used by World browser" }, - VER_UE4_AFTER_CAPSULE_HALF_HEIGHT_CHANGE: { - value: 225, - comment: "Changed capsule height to capsule half-height (afterwards)", - }, - VER_UE4_ADDED_NAMESPACE_AND_KEY_DATA_TO_FTEXT: { - value: 226, - comment: "Added Namepace, GUID (Key) and Flags to FText", - }, - VER_UE4_ATTENUATION_SHAPES: { value: 227, comment: "Attenuation shapes" }, - VER_UE4_LIGHTCOMPONENT_USE_IES_TEXTURE_MULTIPLIER_ON_NON_IES_BRIGHTNESS: { - value: 228, - comment: "Use IES texture multiplier even when IES brightness is not being used", - }, - VER_UE4_REMOVE_INPUT_COMPONENTS_FROM_BLUEPRINTS: { - value: 229, - comment: "Removed InputComponent as a blueprint addable component", - }, - VER_UE4_VARK2NODE_USE_MEMBERREFSTRUCT: { - value: 230, - comment: "Use an FMemberReference struct in UK2Node_Variable", - }, - VER_UE4_REFACTOR_MATERIAL_EXPRESSION_SCENECOLOR_AND_SCENEDEPTH_INPUTS: { - value: 231, - comment: - "Refactored material expression inputs for UMaterialExpressionSceneColor and UMaterialExpressionSceneDepth", - }, - VER_UE4_SPLINE_MESH_ORIENTATION: { value: 232, comment: "Spline meshes changed from Z forwards to configurable" }, - VER_UE4_REVERB_EFFECT_ASSET_TYPE: { value: 233, comment: "Added ReverbEffect asset type" }, - VER_UE4_MAX_TEXCOORD_INCREASED: { value: 234, comment: "changed max texcoords from 4 to 8" }, - VER_UE4_SPEEDTREE_STATICMESH: { value: 235, comment: "static meshes changed to support SpeedTrees" }, - VER_UE4_LANDSCAPE_COMPONENT_LAZY_REFERENCES: { - value: 236, - comment: "Landscape component reference between landscape component and collision component", - }, - VER_UE4_SWITCH_CALL_NODE_TO_USE_MEMBER_REFERENCE: { - value: 237, - comment: "Refactored UK2Node_CallFunction to use FMemberReference", - }, - VER_UE4_ADDED_SKELETON_ARCHIVER_REMOVAL: { - value: 238, - comment: "Added fixup step to remove skeleton class references from blueprint objects", - }, - VER_UE4_ADDED_SKELETON_ARCHIVER_REMOVAL_SECOND_TIME: { value: 239, comment: "See above, take 2." }, - VER_UE4_BLUEPRINT_SKEL_CLASS_TRANSIENT_AGAIN: { - value: 240, - comment: "Making the skeleton class on blueprints transient", - }, - VER_UE4_ADD_COOKED_TO_UCLASS: { value: 241, comment: "UClass knows if it's been cooked" }, - VER_UE4_DEPRECATED_STATIC_MESH_THUMBNAIL_PROPERTIES_REMOVED: { - value: 242, - comment: "Deprecated static mesh thumbnail properties were removed", - }, - VER_UE4_COLLECTIONS_IN_SHADERMAPID: { value: 243, comment: "Added collections in material shader map ids" }, - VER_UE4_REFACTOR_MOVEMENT_COMPONENT_HIERARCHY: { - value: 244, - comment: "Renamed some Movement Component properties, added PawnMovementComponent", - }, - VER_UE4_FIX_TERRAIN_LAYER_SWITCH_ORDER: { - value: 245, - comment: "Swap UMaterialExpressionTerrainLayerSwitch::LayerUsed/LayerNotUsed the correct way round", - }, - VER_UE4_ALL_PROPS_TO_CONSTRAINTINSTANCE: { value: 246, comment: "Remove URB_ConstraintSetup" }, - VER_UE4_LOW_QUALITY_DIRECTIONAL_LIGHTMAPS: { value: 247, comment: "Low quality directional lightmaps" }, - VER_UE4_ADDED_NOISE_EMITTER_COMPONENT: { - value: 248, - comment: "Added NoiseEmitterComponent and removed related Pawn properties.", - }, - VER_UE4_ADD_TEXT_COMPONENT_VERTICAL_ALIGNMENT: { value: 249, comment: "Add text component vertical alignment" }, - VER_UE4_ADDED_FBX_ASSET_IMPORT_DATA: { - value: 250, - comment: "Added AssetImportData for FBX asset types, deprecating SourceFilePath and SourceFileTimestamp", - }, - VER_UE4_REMOVE_LEVELBODYSETUP: { value: 251, comment: "Remove LevelBodySetup from ULevel" }, - VER_UE4_REFACTOR_CHARACTER_CROUCH: { value: 252, comment: "Refactor character crouching" }, - VER_UE4_SMALLER_DEBUG_MATERIALSHADER_UNIFORM_EXPRESSIONS: { - value: 253, - comment: "Trimmed down material shader debug information.", - }, - VER_UE4_APEX_CLOTH: { value: 254, comment: "APEX Clothing" }, - VER_UE4_SAVE_COLLISIONRESPONSE_PER_CHANNEL: { - value: 255, - comment: - "@note!!! once we pass this CL, we can rename FCollisionResponseContainer enum values. we should rename to match ECollisionChannel", - }, - VER_UE4_ADDED_LANDSCAPE_SPLINE_EDITOR_MESH: { value: 256, comment: "Added Landscape Spline editor meshes" }, - VER_UE4_CHANGED_MATERIAL_REFACTION_TYPE: { - value: 257, - comment: "Fixup input expressions for reading from refraction material attributes.", - }, - VER_UE4_REFACTOR_PROJECTILE_MOVEMENT: { - value: 258, - comment: "Refactor projectile movement, along with some other movement component work.", - }, - VER_UE4_REMOVE_PHYSICALMATERIALPROPERTY: { - value: 259, - comment: "Remove PhysicalMaterialProperty and replace with user defined enum", - }, - VER_UE4_PURGED_FMATERIAL_COMPILE_OUTPUTS: { value: 260, comment: "Removed all compile outputs from FMaterial" }, - VER_UE4_ADD_COOKED_TO_LANDSCAPE: { value: 261, comment: "Ability to save cooked PhysX meshes to Landscape" }, - VER_UE4_CONSUME_INPUT_PER_BIND: { value: 262, comment: "Change how input component consumption works" }, - VER_UE4_SOUND_CLASS_GRAPH_EDITOR: { value: 263, comment: "Added new Graph based SoundClass Editor" }, - VER_UE4_FIXUP_TERRAIN_LAYER_NODES: { - value: 264, - comment: "Fixed terrain layer node guids which was causing artifacts", - }, - VER_UE4_RETROFIT_CLAMP_EXPRESSIONS_SWAP: { - value: 265, - comment: "Added clamp min/max swap check to catch older materials", - }, - VER_UE4_REMOVE_LIGHT_MOBILITY_CLASSES: { value: 266, comment: "Remove static/movable/stationary light classes" }, - VER_UE4_REFACTOR_PHYSICS_BLENDING: { - value: 267, - comment: "Refactor the way physics blending works to allow partial blending", - }, - VER_UE4_WORLD_LEVEL_INFO_UPDATED: { - value: 268, - comment: "WorldLevelInfo: Added reference to parent level and streaming distance", - }, - VER_UE4_STATIC_SKELETAL_MESH_SERIALIZATION_FIX: { - value: 269, - comment: "Fixed cooking of skeletal/static meshes due to bad serialization logic", - }, - VER_UE4_REMOVE_STATICMESH_MOBILITY_CLASSES: { value: 270, comment: "Removal of InterpActor and PhysicsActor" }, - VER_UE4_REFACTOR_PHYSICS_TRANSFORMS: { value: 271, comment: "Refactor physics transforms" }, - VER_UE4_REMOVE_ZERO_TRIANGLE_SECTIONS: { - value: 272, - comment: "Remove zero triangle sections from static meshes and compact material indices.", - }, - VER_UE4_CHARACTER_MOVEMENT_DECELERATION: { - value: 273, - comment: "Add param for deceleration in character movement instead of using acceleration.", - }, - VER_UE4_CAMERA_ACTOR_USING_CAMERA_COMPONENT: { - value: 274, - comment: "Made ACameraActor use a UCameraComponent for parameter storage, etc...", - }, - VER_UE4_CHARACTER_MOVEMENT_DEPRECATE_PITCH_ROLL: { - value: 275, - comment: "Deprecated some pitch/roll properties in CharacterMovementComponent", - }, - VER_UE4_REBUILD_TEXTURE_STREAMING_DATA_ON_LOAD: { - value: 276, - comment: "Rebuild texture streaming data on load for uncooked builds", - }, - VER_UE4_SUPPORT_32BIT_STATIC_MESH_INDICES: { - value: 277, - comment: "Add support for 32 bit index buffers for static meshes.", - }, - VER_UE4_ADDED_CHUNKID_TO_ASSETDATA_AND_UPACKAGE: { - value: 278, - comment: "Added streaming install ChunkID to AssetData and UPackage", - }, - VER_UE4_CHARACTER_DEFAULT_MOVEMENT_BINDINGS: { - value: 279, - comment: "Add flag to control whether Character blueprints receive default movement bindings.", - }, - VER_UE4_APEX_CLOTH_LOD: { value: 280, comment: "APEX Clothing LOD Info" }, - VER_UE4_ATMOSPHERIC_FOG_CACHE_DATA: { value: 281, comment: "Added atmospheric fog texture data to be general" }, - VAR_UE4_ARRAY_PROPERTY_INNER_TAGS: { value: 282, comment: "Arrays serialize their inner's tags" }, - VER_UE4_KEEP_SKEL_MESH_INDEX_DATA: { - value: 283, - comment: "Skeletal mesh index data is kept in memory in game to support mesh merging.", - }, - VER_UE4_BODYSETUP_COLLISION_CONVERSION: { - value: 284, - comment: "Added compatibility for the body instance collision change", - }, - VER_UE4_REFLECTION_CAPTURE_COOKING: { value: 285, comment: "Reflection capture cooking" }, - VER_UE4_REMOVE_DYNAMIC_VOLUME_CLASSES: { - value: 286, - comment: "Removal of DynamicTriggerVolume, DynamicBlockingVolume, DynamicPhysicsVolume", - }, - VER_UE4_STORE_HASCOOKEDDATA_FOR_BODYSETUP: { - value: 287, - comment: "Store an additional flag in the BodySetup to indicate whether there is any cooked data to load", - }, - VER_UE4_REFRACTION_BIAS_TO_REFRACTION_DEPTH_BIAS: { - value: 288, - comment: "Changed name of RefractionBias to RefractionDepthBias.", - }, - VER_UE4_REMOVE_SKELETALPHYSICSACTOR: { value: 289, comment: "Removal of SkeletalPhysicsActor" }, - VER_UE4_PC_ROTATION_INPUT_REFACTOR: { value: 290, comment: "PlayerController rotation input refactor" }, - VER_UE4_LANDSCAPE_PLATFORMDATA_COOKING: { value: 291, comment: "Landscape Platform Data cooking" }, - VER_UE4_CREATEEXPORTS_CLASS_LINKING_FOR_BLUEPRINTS: { - value: 292, - comment: "Added call for linking classes in CreateExport to ensure memory is initialized properly", - }, - VER_UE4_REMOVE_NATIVE_COMPONENTS_FROM_BLUEPRINT_SCS: { - value: 293, - comment: "Remove native component nodes from the blueprint SimpleConstructionScript", - }, - VER_UE4_REMOVE_SINGLENODEINSTANCE: { value: 294, comment: "Removal of Single Node Instance" }, - VER_UE4_CHARACTER_BRAKING_REFACTOR: { value: 295, comment: "Character movement braking changes" }, - VER_UE4_VOLUME_SAMPLE_LOW_QUALITY_SUPPORT: { - value: 296, - comment: "Supported low quality lightmaps in volume samples", - }, - VER_UE4_SPLIT_TOUCH_AND_CLICK_ENABLES: { - value: 297, - comment: "Split bEnableTouchEvents out from bEnableClickEvents", - }, - VER_UE4_HEALTH_DEATH_REFACTOR: { value: 298, comment: "Health/Death refactor" }, - VER_UE4_SOUND_NODE_ENVELOPER_CURVE_CHANGE: { - value: 299, - comment: "Moving USoundNodeEnveloper from UDistributionFloatConstantCurve to FRichCurve", - }, - VER_UE4_POINT_LIGHT_SOURCE_RADIUS: { value: 300, comment: "Moved SourceRadius to UPointLightComponent" }, - VER_UE4_SCENE_CAPTURE_CAMERA_CHANGE: { value: 301, comment: "Scene capture actors based on camera actors." }, - VER_UE4_MOVE_SKELETALMESH_SHADOWCASTING: { - value: 302, - comment: "Moving SkeletalMesh shadow casting flag from LoD details to material", - }, - VER_UE4_CHANGE_SETARRAY_BYTECODE: { value: 303, comment: "Changing bytecode operators for creating arrays" }, - VER_UE4_MATERIAL_INSTANCE_BASE_PROPERTY_OVERRIDES: { - value: 304, - comment: "Material Instances overriding base material properties.", - }, - VER_UE4_COMBINED_LIGHTMAP_TEXTURES: { value: 305, comment: "Combined top/bottom lightmap textures" }, - VER_UE4_BUMPED_MATERIAL_EXPORT_GUIDS: { value: 306, comment: "Forced material lightmass guids to be regenerated" }, - VER_UE4_BLUEPRINT_INPUT_BINDING_OVERRIDES: { - value: 307, - comment: "Allow overriding of parent class input bindings", - }, - VER_UE4_FIXUP_BODYSETUP_INVALID_CONVEX_TRANSFORM: { value: 308, comment: "Fix up convex invalid transform" }, - VER_UE4_FIXUP_STIFFNESS_AND_DAMPING_SCALE: { - value: 309, - comment: "Fix up scale of physics stiffness and damping value", - }, - VER_UE4_REFERENCE_SKELETON_REFACTOR: { - value: 310, - comment: "Convert USkeleton and FBoneContrainer to using FReferenceSkeleton.", - }, - VER_UE4_K2NODE_REFERENCEGUIDS: { - value: 311, - comment: "Adding references to variable, function, and macro nodes to be able to update to renamed values", - }, - VER_UE4_FIXUP_ROOTBONE_PARENT: { value: 312, comment: "Fix up the 0th bone's parent bone index." }, - VER_UE4_TEXT_RENDER_COMPONENTS_WORLD_SPACE_SIZING: { - value: 313, - comment: "Allow setting of TextRenderComponents size in world space.", - }, - VER_UE4_MATERIAL_INSTANCE_BASE_PROPERTY_OVERRIDES_PHASE_2: { - value: 314, - comment: "Material Instances overriding base material properties #2.", - }, - VER_UE4_CLASS_NOTPLACEABLE_ADDED: { value: 315, comment: "CLASS_Placeable becomes CLASS_NotPlaceable" }, - VER_UE4_WORLD_LEVEL_INFO_LOD_LIST: { value: 316, comment: "Added LOD info list to a world tile description" }, - VER_UE4_CHARACTER_MOVEMENT_VARIABLE_RENAMING_1: { - value: 317, - comment: "CharacterMovement variable naming refactor", - }, - VER_UE4_FSLATESOUND_CONVERSION: { - value: 318, - comment: "FName properties containing sound names converted to FSlateSound properties", - }, - VER_UE4_WORLD_LEVEL_INFO_ZORDER: { value: 319, comment: "Added ZOrder to a world tile description" }, - VER_UE4_PACKAGE_REQUIRES_LOCALIZATION_GATHER_FLAGGING: { - value: 320, - comment: "Added flagging of localization gather requirement to packages", - }, - VER_UE4_BP_ACTOR_VARIABLE_DEFAULT_PREVENTING: { - value: 321, - comment: "Preventing Blueprint Actor variables from having default values", - }, - VER_UE4_TEST_ANIMCOMP_CHANGE: { - value: 322, - comment: "Preventing Blueprint Actor variables from having default values", - }, - VER_UE4_EDITORONLY_BLUEPRINTS: { value: 323, comment: "Class as primary asset, name convention changed" }, - VER_UE4_EDGRAPHPINTYPE_SERIALIZATION: { value: 324, comment: "Custom serialization for FEdGraphPinType" }, - VER_UE4_NO_MIRROR_BRUSH_MODEL_COLLISION: { - value: 325, - comment: "Stop generating 'mirrored' cooked mesh for Brush and Model components", - }, - VER_UE4_CHANGED_CHUNKID_TO_BE_AN_ARRAY_OF_CHUNKIDS: { - value: 326, - comment: "Changed ChunkID to be an array of IDs.", - }, - VER_UE4_WORLD_NAMED_AFTER_PACKAGE: { - value: 327, - comment: 'Worlds have been renamed from "TheWorld" to be named after the package containing them', - }, - VER_UE4_SKY_LIGHT_COMPONENT: { value: 328, comment: "Added sky light component" }, - VER_UE4_WORLD_LAYER_ENABLE_DISTANCE_STREAMING: { - value: 329, - comment: "Added Enable distance streaming flag to FWorldTileLayer", - }, - VER_UE4_REMOVE_ZONES_FROM_MODEL: { value: 330, comment: "Remove visibility/zone information from UModel" }, - VER_UE4_FIX_ANIMATIONBASEPOSE_SERIALIZATION: { value: 331, comment: "Fix base pose serialization" }, - VER_UE4_SUPPORT_8_BONE_INFLUENCES_SKELETAL_MESHES: { - value: 332, - comment: "Support for up to 8 skinning influences per vertex on skeletal meshes (on non-gpu vertices)", - }, - VER_UE4_ADD_OVERRIDE_GRAVITY_FLAG: { value: 333, comment: "Add explicit bOverrideGravity to world settings" }, - VER_UE4_SUPPORT_GPUSKINNING_8_BONE_INFLUENCES: { - value: 334, - comment: "Support for up to 8 skinning influences per vertex on skeletal meshes (on gpu vertices)", - }, - VER_UE4_ANIM_SUPPORT_NONUNIFORM_SCALE_ANIMATION: { value: 335, comment: "Supporting nonuniform scale animation" }, - VER_UE4_ENGINE_VERSION_OBJECT: { - value: 336, - comment: "Engine version is stored as a FEngineVersion object rather than changelist number", - }, - VER_UE4_PUBLIC_WORLDS: { value: 337, comment: "World assets now have RF_Public" }, - VER_UE4_SKELETON_GUID_SERIALIZATION: { value: 338, comment: "Skeleton Guid" }, - VER_UE4_CHARACTER_MOVEMENT_WALKABLE_FLOOR_REFACTOR: { - value: 339, - comment: "Character movement WalkableFloor refactor", - }, - VER_UE4_INVERSE_SQUARED_LIGHTS_DEFAULT: { value: 340, comment: "Lights default to inverse squared" }, - VER_UE4_DISABLED_SCRIPT_LIMIT_BYTECODE: { value: 341, comment: "Disabled SCRIPT_LIMIT_BYTECODE_TO_64KB" }, - VER_UE4_PRIVATE_REMOTE_ROLE: { value: 342, comment: "Made remote role private, exposed bReplicates" }, - VER_UE4_FOLIAGE_STATIC_MOBILITY: { - value: 343, - comment: - "Fix up old foliage components to have static mobility (superseded by VER_UE4_FOLIAGE_MOVABLE_MOBILITY)", - }, - VER_UE4_BUILD_SCALE_VECTOR: { value: 344, comment: "Change BuildScale from a float to a vector" }, - VER_UE4_FOLIAGE_COLLISION: { - value: 345, - comment: "After implementing foliage collision, need to disable collision on old foliage instances", - }, - VER_UE4_SKY_BENT_NORMAL: { value: 346, comment: "Added sky bent normal to indirect lighting cache" }, - VER_UE4_LANDSCAPE_COLLISION_DATA_COOKING: { value: 347, comment: "Added cooking for landscape collision data" }, - VER_UE4_MORPHTARGET_CPU_TANGENTZDELTA_FORMATCHANGE: { - value: 348, - comment: "we still convert all to FVector in CPU time whenever any calculation", - }, - VER_UE4_SOFT_CONSTRAINTS_USE_MASS: { - value: 349, - comment: "Soft constraint limits will implicitly use the mass of the bodies", - }, - VER_UE4_REFLECTION_DATA_IN_PACKAGES: { value: 350, comment: "Reflection capture data saved in packages" }, - VER_UE4_FOLIAGE_MOVABLE_MOBILITY: { - value: 351, - comment: - "Fix up old foliage components to have movable mobility (superseded by VER_UE4_FOLIAGE_STATIC_LIGHTING_SUPPORT)", - }, - VER_UE4_UNDO_BREAK_MATERIALATTRIBUTES_CHANGE: { - value: 352, - comment: "Undo BreakMaterialAttributes changes as it broke old content", - }, - VER_UE4_ADD_CUSTOMPROFILENAME_CHANGE: { - value: 353, - comment: "Now Default custom profile name isn't NONE anymore due to copy/paste not working properly with it", - }, - VER_UE4_FLIP_MATERIAL_COORDS: { value: 354, comment: "Permanently flip and scale material expression coordinates" }, - VER_UE4_MEMBERREFERENCE_IN_PINTYPE: { - value: 355, - comment: "PinSubCategoryMemberReference added to FEdGraphPinType", - }, - VER_UE4_VEHICLES_UNIT_CHANGE: { - value: 356, - comment: "Vehicles use Nm for Torque instead of cm and RPM instead of rad/s", - }, - VER_UE4_ANIMATION_REMOVE_NANS: { - value: 357, - comment: "now importing should detect NaNs, so we should not have NaNs in source data", - }, - VER_UE4_SKELETON_ASSET_PROPERTY_TYPE_CHANGE: { - value: 358, - comment: "Change skeleton preview attached assets property type", - }, - VER_UE4_FIX_BLUEPRINT_VARIABLE_FLAGS: { - value: 359, - comment: "Fix some blueprint variables that have the CPF_DisableEditOnTemplate flag set when they shouldn't", - }, - VER_UE4_VEHICLES_UNIT_CHANGE2: { - value: 360, - comment: - "Vehicles use Nm for Torque instead of cm and RPM instead of rad/s part two (missed conversion for some variables", - }, - VER_UE4_UCLASS_SERIALIZE_INTERFACES_AFTER_LINKING: { - value: 361, - comment: "Changed order of interface class serialization", - }, - VER_UE4_STATIC_MESH_SCREEN_SIZE_LODS: { value: 362, comment: "Change from LOD distances to display factors" }, - VER_UE4_FIX_MATERIAL_COORDS: { - value: 363, - comment: "Requires test of material coords to ensure they're saved correctly", - }, - VER_UE4_SPEEDTREE_WIND_V7: { value: 364, comment: "Changed SpeedTree wind presets to v7" }, - VER_UE4_LOAD_FOR_EDITOR_GAME: { value: 365, comment: "NeedsLoadForEditorGame added" }, - VER_UE4_SERIALIZE_RICH_CURVE_KEY: { value: 366, comment: "Manual serialization of FRichCurveKey to save space" }, - VER_UE4_MOVE_LANDSCAPE_MICS_AND_TEXTURES_WITHIN_LEVEL: { - value: 367, - comment: - "Change the outer of ULandscapeMaterialInstanceConstants and Landscape-related textures to the level in which they reside", - }, - VER_UE4_FTEXT_HISTORY: { - value: 368, - comment: "FTexts have creation history data, removed Key, Namespaces, and SourceString", - }, - VER_UE4_FIX_MATERIAL_COMMENTS: { - value: 369, - comment: "Shift comments to the left to contain expressions properly", - }, - VER_UE4_STORE_BONE_EXPORT_NAMES: { - value: 370, - comment: - "Bone names stored as FName means that we can't guarantee the correct case on export, now we store a separate string for export purposes only", - }, - VER_UE4_MESH_EMITTER_INITIAL_ORIENTATION_DISTRIBUTION: { - value: 371, - comment: "changed mesh emitter initial orientation to distribution", - }, - VER_UE4_DISALLOW_FOLIAGE_ON_BLUEPRINTS: { value: 372, comment: "Foliage on blueprints causes crashes" }, - VER_UE4_FIXUP_MOTOR_UNITS: { - value: 373, - comment: "change motors to use revolutions per second instead of rads/second", - }, - VER_UE4_DEPRECATED_MOVEMENTCOMPONENT_MODIFIED_SPEEDS: { - value: 374, - comment: 'deprecated MovementComponent functions including "ModifiedMaxSpeed" et al', - }, - VER_UE4_RENAME_CANBECHARACTERBASE: { value: 375, comment: "rename CanBeCharacterBase" }, - VER_UE4_GAMEPLAY_TAG_CONTAINER_TAG_TYPE_CHANGE: { - value: 376, - comment: - "Change GameplayTagContainers to have FGameplayTags instead of FNames; Required to fix-up native serialization", - }, - VER_UE4_FOLIAGE_SETTINGS_TYPE: { - value: 377, - comment: - "Change from UInstancedFoliageSettings to UFoliageType, and change the api from being keyed on UStaticMesh* to UFoliageType*", - }, - VER_UE4_STATIC_SHADOW_DEPTH_MAPS: { value: 378, comment: "Lights serialize static shadow depth maps" }, - VER_UE4_ADD_TRANSACTIONAL_TO_DATA_ASSETS: { - value: 379, - comment: "Add RF_Transactional to data assets, fixing undo problems when editing them", - }, - VER_UE4_ADD_LB_WEIGHTBLEND: { - value: 380, - comment: "Change LB_AlphaBlend to LB_WeightBlend in ELandscapeLayerBlendType", - }, - VER_UE4_ADD_ROOTCOMPONENT_TO_FOLIAGEACTOR: { - value: 381, - comment: "Add root component to an foliage actor, all foliage cluster components will be attached to a root", - }, - VER_UE4_FIX_MATERIAL_PROPERTY_OVERRIDE_SERIALIZE: { - value: 382, - comment: "FMaterialInstanceBasePropertyOverrides didn't use proper UObject serialize", - }, - VER_UE4_ADD_LINEAR_COLOR_SAMPLER: { - value: 383, - comment: - "Addition of linear color sampler. color sample type is changed to linear sampler if source texture !sRGB", - }, - VER_UE4_ADD_STRING_ASSET_REFERENCES_MAP: { - value: 384, - comment: "Added StringAssetReferencesMap to support renames of FStringAssetReference properties.", - }, - VER_UE4_BLUEPRINT_USE_SCS_ROOTCOMPONENT_SCALE: { - value: 385, - comment: - "Apply scale from SCS RootComponent details in the Blueprint Editor to new actor instances at construction time", - }, - VER_UE4_LEVEL_STREAMING_DRAW_COLOR_TYPE_CHANGE: { - value: 386, - comment: "Changed level streaming to have a linear color since the visualization doesn't gamma correct.", - }, - VER_UE4_CLEAR_NOTIFY_TRIGGERS: { value: 387, comment: "Cleared end triggers from non-state anim notifies" }, - VER_UE4_SKELETON_ADD_SMARTNAMES: { - value: 388, - comment: "Convert old curve names stored in anim assets into skeleton smartnames", - }, - VER_UE4_ADDED_CURRENCY_CODE_TO_FTEXT: { - value: 389, - comment: "Added the currency code field to FTextHistory_AsCurrency", - }, - VER_UE4_ENUM_CLASS_SUPPORT: { value: 390, comment: "Added support for C++11 enum classes" }, - VER_UE4_FIXUP_WIDGET_ANIMATION_CLASS: { value: 391, comment: "Fixup widget animation class" }, - VER_UE4_SOUND_COMPRESSION_TYPE_ADDED: { - value: 392, - comment: "USoundWave objects now contain details about compression scheme used.", - }, - VER_UE4_AUTO_WELDING: { value: 393, comment: "Bodies will automatically weld when attached" }, - VER_UE4_RENAME_CROUCHMOVESCHARACTERDOWN: { - value: 394, - comment: "Rename UCharacterMovementComponent::bCrouchMovesCharacterDown", - }, - VER_UE4_LIGHTMAP_MESH_BUILD_SETTINGS: { value: 395, comment: "Lightmap parameters in FMeshBuildSettings" }, - VER_UE4_RENAME_SM3_TO_ES3_1: { - value: 396, - comment: "Rename SM3 to ES3_1 and updates featurelevel material node selector", - }, - VER_UE4_DEPRECATE_UMG_STYLE_ASSETS: { value: 397, comment: "Deprecated separate style assets for use in UMG" }, - VER_UE4_POST_DUPLICATE_NODE_GUID: { - value: 398, - comment: "Duplicating Blueprints will regenerate NodeGuids after this version", - }, - VER_UE4_RENAME_CAMERA_COMPONENT_VIEW_ROTATION: { - value: 399, - comment: - "Rename UCameraComponent::bUseControllerViewRotation to bUsePawnViewRotation (and change the default value)", - }, - VER_UE4_CASE_PRESERVING_FNAME: { value: 400, comment: "Changed FName to be case preserving" }, - VER_UE4_RENAME_CAMERA_COMPONENT_CONTROL_ROTATION: { - value: 401, - comment: "Rename UCameraComponent::bUsePawnViewRotation to bUsePawnControlRotation", - }, - VER_UE4_FIX_REFRACTION_INPUT_MASKING: { value: 402, comment: "Fix bad refraction material attribute masks" }, - VER_UE4_GLOBAL_EMITTER_SPAWN_RATE_SCALE: { value: 403, comment: "A global spawn rate for emitters." }, - VER_UE4_CLEAN_DESTRUCTIBLE_SETTINGS: { value: 404, comment: "Cleanup destructible mesh settings" }, - VER_UE4_CHARACTER_MOVEMENT_UPPER_IMPACT_BEHAVIOR: { - value: 405, - comment: - "CharacterMovementComponent refactor of AdjustUpperHemisphereImpact and deprecation of some associated vars.", - }, - VER_UE4_BP_MATH_VECTOR_EQUALITY_USES_EPSILON: { - value: 406, - comment: - 'Changed Blueprint math equality functions for vectors and rotators to operate as a "nearly" equals rather than "exact"', - }, - VER_UE4_FOLIAGE_STATIC_LIGHTING_SUPPORT: { - value: 407, - comment: "Static lighting support was re-added to foliage, and mobility was returned to static", - }, - VER_UE4_SLATE_COMPOSITE_FONTS: { value: 408, comment: "Added composite fonts to Slate font info" }, - VER_UE4_REMOVE_SAVEGAMESUMMARY: { - value: 409, - comment: "Remove UDEPRECATED_SaveGameSummary, required for UWorld::Serialize", - }, - VER_UE4_REMOVE_SKELETALMESH_COMPONENT_BODYSETUP_SERIALIZATION: { - value: 410, - comment: "Remove bodyseutp serialization from skeletal mesh component", - }, - VER_UE4_SLATE_BULK_FONT_DATA: { - value: 411, - comment: "Made Slate font data use bulk data to store the embedded font data", - }, - VER_UE4_ADD_PROJECTILE_FRICTION_BEHAVIOR: { - value: 412, - comment: "Add new friction behavior in ProjectileMovementComponent.", - }, - VER_UE4_MOVEMENTCOMPONENT_AXIS_SETTINGS: { value: 413, comment: "Add axis settings enum to MovementComponent." }, - VER_UE4_GRAPH_INTERACTIVE_COMMENTBUBBLES: { - value: 414, - comment: "Switch to new interactive comments, requires boundry conversion to preserve previous states", - }, - VER_UE4_LANDSCAPE_SERIALIZE_PHYSICS_MATERIALS: { - value: 415, - comment: "Landscape serializes physical materials for collision objects", - }, - VER_UE4_RENAME_WIDGET_VISIBILITY: { value: 416, comment: "Rename Visiblity on widgets to Visibility" }, - VER_UE4_ANIMATION_ADD_TRACKCURVES: { value: 417, comment: "add track curves for animation" }, - VER_UE4_MONTAGE_BRANCHING_POINT_REMOVAL: { - value: 418, - comment: "Removed BranchingPoints from AnimMontages and converted them to regular AnimNotifies.", - }, - VER_UE4_BLUEPRINT_ENFORCE_CONST_IN_FUNCTION_OVERRIDES: { - value: 419, - comment: "Enforce const-correctness in Blueprint implementations of native C++ const class methods", - }, - VER_UE4_ADD_PIVOT_TO_WIDGET_COMPONENT: { - value: 420, - comment: "Added pivot to widget components, need to load old versions as a 0,0 pivot, new default is 0.5,0.5", - }, - VER_UE4_PAWN_AUTO_POSSESS_AI: { - value: 421, - comment: - "Added finer control over when AI Pawns are automatically possessed. Also renamed Pawn.AutoPossess to Pawn.AutoPossessPlayer indicate this was a setting for players and not AI.", - }, - VER_UE4_FTEXT_HISTORY_DATE_TIMEZONE: { - value: 422, - comment: "Added serialization of timezone to FTextHistory for AsDate operations.", - }, - VER_UE4_SORT_ACTIVE_BONE_INDICES: { - value: 423, - comment: "Sort ActiveBoneIndices on lods so that we can avoid doing it at run time", - }, - VER_UE4_PERFRAME_MATERIAL_UNIFORM_EXPRESSIONS: { - value: 424, - comment: "Added per-frame material uniform expressions", - }, - VER_UE4_MIKKTSPACE_IS_DEFAULT: { - value: 425, - comment: "Make MikkTSpace the default tangent space calculation method for static meshes.", - }, - VER_UE4_LANDSCAPE_GRASS_COOKING: { value: 426, comment: "Only applies to cooked files, grass cooking support." }, - VER_UE4_FIX_SKEL_VERT_ORIENT_MESH_PARTICLES: { - value: 427, - comment: "Fixed code for using the bOrientMeshEmitters property.", - }, - VER_UE4_LANDSCAPE_STATIC_SECTION_OFFSET: { - value: 428, - comment: "Do not change landscape section offset on load under world composition", - }, - VER_UE4_ADD_MODIFIERS_RUNTIME_GENERATION: { - value: 429, - comment: "New options for navigation data runtime generation (static, modifiers only, dynamic)", - }, - VER_UE4_MATERIAL_MASKED_BLENDMODE_TIDY: { - value: 430, - comment: "Tidied up material's handling of masked blend mode.", - }, - VER_UE4_MERGED_ADD_MODIFIERS_RUNTIME_GENERATION_TO_4_7_DEPRECATED: { - value: 431, - comment: - "Original version of VER_UE4_MERGED_ADD_MODIFIERS_RUNTIME_GENERATION_TO_4_7; renumbered to prevent blocking promotion in main.", - }, - VER_UE4_AFTER_MERGED_ADD_MODIFIERS_RUNTIME_GENERATION_TO_4_7_DEPRECATED: { - value: 432, - comment: - "Original version of VER_UE4_AFTER_MERGED_ADD_MODIFIERS_RUNTIME_GENERATION_TO_4_7; renumbered to prevent blocking promotion in main.", - }, - VER_UE4_MERGED_ADD_MODIFIERS_RUNTIME_GENERATION_TO_4_7: { - value: 433, - comment: "After merging VER_UE4_ADD_MODIFIERS_RUNTIME_GENERATION into 4.7 branch", - }, - VER_UE4_AFTER_MERGING_ADD_MODIFIERS_RUNTIME_GENERATION_TO_4_7: { - value: 434, - comment: "After merging VER_UE4_ADD_MODIFIERS_RUNTIME_GENERATION into 4.7 branch", - }, - VER_UE4_SERIALIZE_LANDSCAPE_GRASS_DATA: { - value: 435, - comment: "Landscape grass weightmap data is now generated in the editor and serialized.", - }, - VER_UE4_OPTIONALLY_CLEAR_GPU_EMITTERS_ON_INIT: { - value: 436, - comment: "New property to optionally prevent gpu emitters clearing existing particles on Init().", - }, - VER_UE4_SERIALIZE_LANDSCAPE_GRASS_DATA_MATERIAL_GUID: { - value: 437, - comment: "Also store the Material guid with the landscape grass data", - }, - VER_UE4_BLUEPRINT_GENERATED_CLASS_COMPONENT_TEMPLATES_PUBLIC: { - value: 438, - comment: "Make sure that all template components from blueprint generated classes are flagged as public", - }, - VER_UE4_ACTOR_COMPONENT_CREATION_METHOD: { - value: 439, - comment: - "Split out creation method on ActorComponents to distinguish between native, instance, and simple or user construction script", - }, - VER_UE4_K2NODE_EVENT_MEMBER_REFERENCE: { - value: 440, - comment: "K2Node_Event now uses FMemberReference for handling references", - }, - VER_UE4_STRUCT_GUID_IN_PROPERTY_TAG: { value: 441, comment: "FPropertyTag stores GUID of struct" }, - VER_UE4_REMOVE_UNUSED_UPOLYS_FROM_UMODEL: { - value: 442, - comment: "Remove unused UPolys from UModel cooked content", - }, - VER_UE4_REBUILD_HIERARCHICAL_INSTANCE_TREES: { - value: 443, - comment: - 'This doesn\'t do anything except trigger a rebuild on HISMC cluster trees, in this case to get a good "occlusion query" level', - }, - VER_UE4_PACKAGE_SUMMARY_HAS_COMPATIBLE_ENGINE_VERSION: { - value: 444, - comment: - "Package summary includes an CompatibleWithEngineVersion field, separately to the version it's saved with", - }, - VER_UE4_TRACK_UCS_MODIFIED_PROPERTIES: { value: 445, comment: "Track UCS modified properties on Actor Components" }, - VER_UE4_LANDSCAPE_SPLINE_CROSS_LEVEL_MESHES: { - value: 446, - comment: - "Allowed landscape spline meshes to be stored into landscape streaming levels rather than the spline's level", - }, - VER_UE4_DEPRECATE_USER_WIDGET_DESIGN_SIZE: { - value: 447, - comment: "Deprecate the variables used for sizing in the designer on UUserWidget", - }, - VER_UE4_ADD_EDITOR_VIEWS: { value: 448, comment: "Make the editor views array dynamically sized" }, - VER_UE4_FOLIAGE_WITH_ASSET_OR_CLASS: { - value: 449, - comment: "Updated foliage to work with either FoliageType assets or blueprint classes", - }, - VER_UE4_BODYINSTANCE_BINARY_SERIALIZATION: { - value: 450, - comment: "Allows PhysicsSerializer to serialize shapes and actors for faster load times", - }, - VER_UE4_SERIALIZE_BLUEPRINT_EVENTGRAPH_FASTCALLS_IN_UFUNCTION: { - value: 451, - comment: "Added fastcall data serialization directly in UFunction", - }, - VER_UE4_INTERPCURVE_SUPPORTS_LOOPING: { value: 452, comment: "Changes to USplineComponent and FInterpCurve" }, - VER_UE4_MATERIAL_INSTANCE_BASE_PROPERTY_OVERRIDES_DITHERED_LOD_TRANSITION: { - value: 453, - comment: "Material Instances overriding base material LOD transitions", - }, - VER_UE4_SERIALIZE_LANDSCAPE_ES2_TEXTURES: { - value: 454, - comment: "Serialize ES2 textures separately rather than overwriting the properties used on other platforms", - }, - VER_UE4_CONSTRAINT_INSTANCE_MOTOR_FLAGS: { - value: 455, - comment: "Constraint motor velocity is broken into per-component", - }, - VER_UE4_SERIALIZE_PINTYPE_CONST: { value: 456, comment: "Serialize bIsConst in FEdGraphPinType" }, - VER_UE4_LIBRARY_CATEGORIES_AS_FTEXT: { - value: 457, - comment: - "Change UMaterialFunction::LibraryCategories to LibraryCategoriesText (old assets were saved before auto-conversion of FArrayProperty was possible)", - }, - VER_UE4_SKIP_DUPLICATE_EXPORTS_ON_SAVE_PACKAGE: { - value: 458, - comment: "Check for duplicate exports while saving packages.", - }, - VER_UE4_SERIALIZE_TEXT_IN_PACKAGES: { - value: 459, - comment: "Pre-gathering of gatherable, localizable text in packages to optimize text gathering operation times", - }, - VER_UE4_ADD_BLEND_MODE_TO_WIDGET_COMPONENT: { - value: 460, - comment: "Added pivot to widget components, need to load old versions as a 0,0 pivot, new default is 0.5,0.5", - }, - VER_UE4_NEW_LIGHTMASS_PRIMITIVE_SETTING: { value: 461, comment: "Added lightmass primitive setting" }, - VER_UE4_REPLACE_SPRING_NOZ_PROPERTY: { - value: 462, - comment: "Deprecate NoZSpring property on spring nodes to be replaced with TranslateZ property", - }, - VER_UE4_TIGHTLY_PACKED_ENUMS: { - value: 463, - comment: "Keep enums tight and serialize their values as pairs of FName and value. Don't insert dummy values.", - }, - VER_UE4_ASSET_IMPORT_DATA_AS_JSON: { - value: 464, - comment: "Changed Asset import data to serialize file meta data as JSON", - }, - VER_UE4_TEXTURE_LEGACY_GAMMA: { value: 465, comment: "Legacy gamma support for textures." }, - VER_UE4_ADDED_NATIVE_SERIALIZATION_FOR_IMMUTABLE_STRUCTURES: { - value: 466, - comment: - "Added WithSerializer for basic native structures like FVector, FColor etc to improve serialization performance", - }, - VER_UE4_DEPRECATE_UMG_STYLE_OVERRIDES: { - value: 467, - comment: "Deprecated attributes that override the style on UMG widgets", - }, - VER_UE4_STATIC_SHADOWMAP_PENUMBRA_SIZE: { value: 468, comment: "Shadowmap penumbra size stored" }, - VER_UE4_NIAGARA_DATA_OBJECT_DEV_UI_FIX: { - value: 469, - comment: "Fix BC on Niagara effects from the data object and dev UI changes.", - }, - VER_UE4_FIXED_DEFAULT_ORIENTATION_OF_WIDGET_COMPONENT: { - value: 470, - comment: "Fixed the default orientation of widget component so it faces down +x", - }, - VER_UE4_REMOVED_MATERIAL_USED_WITH_UI_FLAG: { - value: 471, - comment: "Removed bUsedWithUI flag from UMaterial and replaced it with a new material domain for UI", - }, - VER_UE4_CHARACTER_MOVEMENT_ADD_BRAKING_FRICTION: { - value: 472, - comment: "Added braking friction separate from turning friction.", - }, - VER_UE4_BSP_UNDO_FIX: { value: 473, comment: "Removed TTransArrays from UModel" }, - VER_UE4_DYNAMIC_PARAMETER_DEFAULT_VALUE: { value: 474, comment: "Added default value to dynamic parameter." }, - VER_UE4_STATIC_MESH_EXTENDED_BOUNDS: { value: 475, comment: "Added ExtendedBounds to StaticMesh" }, - VER_UE4_ADDED_NON_LINEAR_TRANSITION_BLENDS: { - value: 476, - comment: "Added non-linear blending to anim transitions, deprecating old types", - }, - VER_UE4_AO_MATERIAL_MASK: { value: 477, comment: "AO Material Mask texture" }, - VER_UE4_NAVIGATION_AGENT_SELECTOR: { - value: 478, - comment: "Replaced navigation agents selection with single structure", - }, - VER_UE4_MESH_PARTICLE_COLLISIONS_CONSIDER_PARTICLE_SIZE: { - value: 479, - comment: "Mesh particle collisions consider particle size.", - }, - VER_UE4_BUILD_MESH_ADJ_BUFFER_FLAG_EXPOSED: { - value: 480, - comment: "Adjacency buffer building no longer automatically handled based on triangle count, user-controlled", - }, - VER_UE4_MAX_ANGULAR_VELOCITY_DEFAULT: { value: 481, comment: "Change the default max angular velocity" }, - VER_UE4_APEX_CLOTH_TESSELLATION: { value: 482, comment: "Build Adjacency index buffer for clothing tessellation" }, - VER_UE4_DECAL_SIZE: { value: 483, comment: "Added DecalSize member, solved backward compatibility" }, - VER_UE4_KEEP_ONLY_PACKAGE_NAMES_IN_STRING_ASSET_REFERENCES_MAP: { - value: 484, - comment: "Keep only package names in StringAssetReferencesMap", - }, - VER_UE4_COOKED_ASSETS_IN_EDITOR_SUPPORT: { - value: 485, - comment: "Support sound cue not saving out editor only data", - }, - VER_UE4_DIALOGUE_WAVE_NAMESPACE_AND_CONTEXT_CHANGES: { - value: 486, - comment: "Updated dialogue wave localization gathering logic.", - }, - VER_UE4_MAKE_ROT_RENAME_AND_REORDER: { - value: 487, - comment: "Renamed MakeRot MakeRotator and rearranged parameters.", - }, - VER_UE4_K2NODE_VAR_REFERENCEGUIDS: { - value: 488, - comment: "K2Node_Variable will properly have the VariableReference Guid set if available", - }, - VER_UE4_SOUND_CONCURRENCY_PACKAGE: { - value: 489, - comment: "Added support for sound concurrency settings structure and overrides", - }, - VER_UE4_USERWIDGET_DEFAULT_FOCUSABLE_FALSE: { - value: 490, - comment: "Changing the default value for focusable user widgets to false", - }, - VER_UE4_BLUEPRINT_CUSTOM_EVENT_CONST_INPUT: { - value: 491, - comment: "Custom event nodes implicitly set 'const' on array and non-array pass-by-reference input params", - }, - VER_UE4_USE_LOW_PASS_FILTER_FREQ: { value: 492, comment: "Renamed HighFrequencyGain to LowPassFilterFrequency" }, - VER_UE4_NO_ANIM_BP_CLASS_IN_GAMEPLAY_CODE: { - value: 493, - comment: - "UAnimBlueprintGeneratedClass can be replaced by a dynamic class. Use TSubclassOf instead.", - }, - VER_UE4_SCS_STORES_ALLNODES_ARRAY: { - value: 494, - comment: - "The SCS keeps a list of all nodes in its hierarchy rather than recursively building it each time it is requested", - }, - VER_UE4_FBX_IMPORT_DATA_RANGE_ENCAPSULATION: { - value: 495, - comment: "Moved StartRange and EndRange in UFbxAnimSequenceImportData to use FInt32Interval", - }, - VER_UE4_CAMERA_COMPONENT_ATTACH_TO_ROOT: { - value: 496, - comment: "Adding a new root scene component to camera component", - }, - VER_UE4_INSTANCED_STEREO_UNIFORM_UPDATE: { - value: 497, - comment: "Updating custom material expression nodes for instanced stereo implementation", - }, - VER_UE4_STREAMABLE_TEXTURE_MIN_MAX_DISTANCE: { - value: 498, - comment: "Texture streaming min and max distance to handle HLOD", - }, - VER_UE4_INJECT_BLUEPRINT_STRUCT_PIN_CONVERSION_NODES: { - value: 499, - comment: "Fixing up invalid struct-to-struct pin connections by injecting available conversion nodes", - }, - VER_UE4_INNER_ARRAY_TAG_INFO: { value: 500, comment: "Saving tag data for Array Property's inner property" }, - VER_UE4_FIX_SLOT_NAME_DUPLICATION: { - value: 501, - comment: "Fixed duplicating slot node names in skeleton due to skeleton preload on compile", - }, - VER_UE4_STREAMABLE_TEXTURE_AABB: { value: 502, comment: "Texture streaming using AABBs instead of Spheres" }, - VER_UE4_PROPERTY_GUID_IN_PROPERTY_TAG: { value: 503, comment: "FPropertyTag stores GUID of property" }, - VER_UE4_NAME_HASHES_SERIALIZED: { - value: 504, - comment: "Name table hashes are calculated and saved out rather than at load time", - }, - VER_UE4_INSTANCED_STEREO_UNIFORM_REFACTOR: { - value: 505, - comment: "Updating custom material expression nodes for instanced stereo implementation refactor", - }, - VER_UE4_COMPRESSED_SHADER_RESOURCES: { - value: 506, - comment: "Added compression to the shader resource for memory savings", - }, - VER_UE4_PRELOAD_DEPENDENCIES_IN_COOKED_EXPORTS: { - value: 507, - comment: - "Cooked files contain the dependency graph for the event driven loader (the serialization is largely independent of the use of the new loader)", - }, - VER_UE4_TEMPLATEINDEX_IN_COOKED_EXPORTS: { - value: 508, - comment: - "Cooked files contain the TemplateIndex used by the event driven loader (the serialization is largely independent of the use of the new loader, i.e. this will be null if cooking for the old loader)", - }, - VER_UE4_PROPERTY_TAG_SET_MAP_SUPPORT: { - value: 509, - comment: "FPropertyTag includes contained type(s) for Set and Map properties:", - }, - VER_UE4_ADDED_SEARCHABLE_NAMES: { - value: 510, - comment: "Added SearchableNames to the package summary and asset registry", - }, - VER_UE4_64BIT_EXPORTMAP_SERIALSIZES: { - value: 511, - comment: - "Increased size of SerialSize and SerialOffset in export map entries to 64 bit, allow support for bigger files", - }, - VER_UE4_SKYLIGHT_MOBILE_IRRADIANCE_MAP: { - value: 512, - comment: "Sky light stores IrradianceMap for mobile renderer.", - }, - VER_UE4_ADDED_SWEEP_WHILE_WALKING_FLAG: { - value: 513, - comment: "Added flag to control sweep behavior while walking in UCharacterMovementComponent.", - }, - VER_UE4_ADDED_SOFT_OBJECT_PATH: { - value: 514, - comment: - "StringAssetReference changed to SoftObjectPath and swapped to serialize as a name+string instead of a string", - }, - VER_UE4_POINTLIGHT_SOURCE_ORIENTATION: { - value: 515, - comment: "Changed the source orientation of point lights to match spot lights (z axis)", - }, - VER_UE4_ADDED_PACKAGE_SUMMARY_LOCALIZATION_ID: { - value: 516, - comment: "LocalizationId has been added to the package summary (editor-only)", - }, - VER_UE4_FIX_WIDE_STRING_CRC: { - value: 517, - comment: "Fixed case insensitive hashes of wide strings containing character values from 128-255", - }, - VER_UE4_ADDED_PACKAGE_OWNER: { value: 518, comment: "Added package owner to allow private references" }, - VER_UE4_SKINWEIGHT_PROFILE_DATA_LAYOUT_CHANGES: { - value: 519, - comment: "Changed the data layout for skin weight profile data", - }, - VER_UE4_NON_OUTER_PACKAGE_IMPORT: { - value: 520, - comment: "Added import that can have package different than their outer", - }, - VER_UE4_ASSETREGISTRY_DEPENDENCYFLAGS: { value: 521, comment: "Added DependencyFlags to AssetRegistry" }, - VER_UE4_CORRECT_LICENSEE_FLAG: { value: 522, comment: "Fixed corrupt licensee flag in 4.26 assets" }, - VER_UE4_AUTOMATIC_VERSION_PLUS_ONE: { value: 523, comment: "Last version +1" }, - VER_UE4_AUTOMATIC_VERSION: { value: 522, comment: "Fixed corrupt licensee flag in 4.26 assets" }, -} as const satisfies Record diff --git a/src/main/lib/uassets/enums/objectVersionUE5.ts b/src/main/lib/uassets/enums/objectVersionUE5.ts deleted file mode 100644 index 8ea4fad..0000000 --- a/src/main/lib/uassets/enums/objectVersionUE5.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { UAssetEnumEntry } from "@/main/types/uassets" - -/** - * UE5 package format version, read from `PackageFileSummary.FileVersionUE5`, entries drop the `VER_UE5_` prefix to match the current scoped `enum class`. - * @see /Engine/Source/Runtime/Core/Public/UObject/ObjectVersion.h - */ -export const UAssetObjectVersionUE5 = { - INITIAL_VERSION: { - value: 1000, - comment: - "The original UE5 version, at the time this was added the UE4 version was 522, so UE5 will start from 1000 to show a clear difference", - }, - NAMES_REFERENCED_FROM_EXPORT_DATA: { - value: 1001, - comment: "Support stripping names that are not referenced from export data", - }, - PAYLOAD_TOC: { value: 1002, comment: "Added a payload table of contents to the package summary" }, - OPTIONAL_RESOURCES: { - value: 1003, - comment: "Added data to identify references from and to optional package", - }, - LARGE_WORLD_COORDINATES: { - value: 1004, - comment: "Large world coordinates converts a number of core types to double components by default.", - }, - REMOVE_OBJECT_EXPORT_PACKAGE_GUID: { value: 1005, comment: "Remove package GUID from FObjectExport" }, - TRACK_OBJECT_EXPORT_IS_INHERITED: { value: 1006, comment: "Add IsInherited to the FObjectExport entry" }, - FSOFTOBJECTPATH_REMOVE_ASSET_PATH_FNAMES: { - value: 1007, - comment: "Replace FName asset path in FSoftObjectPath with (package name, asset name) pair FTopLevelAssetPath", - }, - ADD_SOFTOBJECTPATH_LIST: { - value: 1008, - comment: "Add a soft object path list to the package summary for fast remap", - }, - DATA_RESOURCES: { value: 1009, comment: "Added bulk/data resource table" }, - SCRIPT_SERIALIZATION_OFFSET: { - value: 1010, - comment: "Added script property serialization offset to export table entries for saved, versioned packages", - }, - PROPERTY_TAG_EXTENSION_AND_OVERRIDABLE_SERIALIZATION: { - value: 1011, - comment: - "Adding property tag extension, Support for overridable serialization on UObject, Support for overridable logic in containers", - }, - PROPERTY_TAG_COMPLETE_TYPE_NAME: { - value: 1012, - comment: "Added property tag complete type name and serialization type", - }, - ASSETREGISTRY_PACKAGEBUILDDEPENDENCIES: { - value: 1013, - comment: "Changed UE::AssetRegistry::WritePackageData to include PackageBuildDependencies", - }, - METADATA_SERIALIZATION_OFFSET: { - value: 1014, - comment: "Added meta data serialization offset to for saved, versioned packages", - }, - VERSE_CELLS: { value: 1015, comment: "Added VCells to the object graph" }, - PACKAGE_SAVED_HASH: { - value: 1016, - comment: "Changed PackageFileSummary to write FIoHash PackageSavedHash instead of FGuid Guid", - }, - OS_SUB_OBJECT_SHADOW_SERIALIZATION: { value: 1017, comment: "OS shadow serialization of subobjects" }, - IMPORT_TYPE_HIERARCHIES: { - value: 1018, - comment: "Adds a table of hierarchical type information for imports in a package", - }, - AUTOMATIC_VERSION_PLUS_ONE: { value: 1019, comment: "Last version +1" }, - AUTOMATIC_VERSION: { value: 1018, comment: "AUTOMATIC_VERSION_PLUS_ONE - 1" }, -} as const satisfies Record diff --git a/src/main/lib/uassets/enums/packageFileTag.ts b/src/main/lib/uassets/enums/packageFileTag.ts deleted file mode 100644 index ed0eca2..0000000 --- a/src/main/lib/uassets/enums/packageFileTag.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Magic numbers at the start of every `.uasset`, the swapped variant flags opposite-endian files. - * @see /Engine/Source/Runtime/CoreUObject/Public/UObject/PackageFileSummary.h - */ -export const UAssetPackageFileTag = { - PACKAGE_FILE_TAG: 0x9e2a83c1, - PACKAGE_FILE_TAG_SWAPPED: 0xc1832a9e, -} as const diff --git a/src/main/lib/uassets/enums/packageFlags.ts b/src/main/lib/uassets/enums/packageFlags.ts deleted file mode 100644 index 697c2da..0000000 --- a/src/main/lib/uassets/enums/packageFlags.ts +++ /dev/null @@ -1,88 +0,0 @@ -import type { UAssetEnumEntry } from "@/main/types/uassets" - -/** - * Bit flags in `PackageFileSummary.PackageFlags`, refreshed against UE 5.8 source. - * @see /Engine/Source/Runtime/CoreUObject/Public/UObject/ObjectMacros.h - */ -export const UAssetPackageFlags = { - PKG_None: { value: 0x00000000, comment: "No flags" }, - PKG_NewlyCreated: { value: 0x00000001, comment: "Newly created package, not saved yet. In editor only." }, - PKG_ClientOptional: { value: 0x00000002, comment: "Purely optional for clients." }, - PKG_ServerSideOnly: { value: 0x00000004, comment: "Only needed on the server side." }, - PKG_CompiledIn: { value: 0x00000010, comment: 'This package is from "compiled in" classes.' }, - PKG_ForDiffing: { value: 0x00000020, comment: "This package was loaded just for the purposes of diffing" }, - PKG_EditorOnly: { - value: 0x00000040, - comment: "This is editor-only package (for example: editor module script package)", - }, - PKG_Developer: { value: 0x00000080, comment: "Developer module" }, - PKG_UncookedOnly: { value: 0x00000100, comment: "Loaded only in uncooked builds (i.e. runtime in editor)" }, - PKG_Cooked: { value: 0x00000200, comment: "Package is cooked" }, - PKG_ContainsNoAsset: { - value: 0x00000400, - comment: "Package doesn't contain any asset object (although asset tags can be present)", - }, - PKG_NotExternallyReferenceable: { - value: 0x00000800, - comment: - "Objects in this package cannot be referenced in a different plugin or mount point (i.e /Game -> /Engine)", - }, - PKG_AccessSpecifierEpicInternal: { - value: 0x00001000, - comment: "Objects in this package can only be referenced in a different plugin or mount point by Epic", - }, - PKG_UnversionedProperties: { - value: 0x00002000, - comment: "Uses unversioned property serialization instead of versioned tagged property serialization", - }, - PKG_ContainsMapData: { - value: 0x00004000, - comment: "Contains map data (UObjects only referenced by a single ULevel) but is stored in a different package", - }, - PKG_IsSaving: { value: 0x00008000, comment: "Temporarily set on a package while it is being saved." }, - PKG_Compiling: { value: 0x00010000, comment: "package is currently being compiled" }, - PKG_ContainsMap: { value: 0x00020000, comment: "Set if the package contains a ULevel/ UWorld object" }, - PKG_RequiresLocalizationGather: { - value: 0x00040000, - comment: "Set if the package contains any data to be gathered by localization", - }, - PKG_LoadUncooked: { - value: 0x00080000, - comment: - "This package must be loaded uncooked from IoStore/ZenStore (set only when cooking, to communicate to zenstore via the PackageStoreOptimizer)", - }, - PKG_PlayInEditor: { value: 0x00100000, comment: "Set if the package was created for the purpose of PIE" }, - PKG_ContainsScript: { value: 0x00200000, comment: "Package is allowed to contain UClass objects" }, - PKG_DisallowExport: { value: 0x00400000, comment: "Editor should not export asset in this package" }, - PKG_Unused_800000: { value: 0x00800000, comment: "Unused" }, - PKG_Unused_1000000: { value: 0x01000000, comment: "Unused" }, - PKG_Unused_2000000: { value: 0x02000000, comment: "Unused" }, - PKG_Unused_4000000: { value: 0x04000000, comment: "Unused" }, - PKG_CookGenerated: { - value: 0x08000000, - comment: "This package was generated by the cooker and does not exist in the WorkspaceDomain", - }, - PKG_DynamicImports: { - value: 0x10000000, - comment: "Obsolete flag, deprecated in UE 5.8.", - }, - PKG_RuntimeGenerated: { - value: 0x20000000, - comment: - "This package contains elements that are runtime generated, and may not follow standard loading order rules", - }, - PKG_ReloadingForCooker: { - value: 0x40000000, - comment: - "This package is reloading in the cooker, try to avoid getting data we will never need. We won't save this package.", - }, - PKG_FilterEditorOnly: { value: 0x80000000, comment: "Package has editor-only data filtered out" }, - PKG_TransientFlags: { - value: 0x00000001 | 0x00008000 | 0x40000000, - comment: "Transient Flags are cleared when serializing to or from PackageFileSummary", - }, - PKG_InMemoryOnly: { - value: 0x00000010 | 0x00000001, - comment: "Flag mask that indicates if this package is a package that exists in memory only.", - }, -} as const satisfies Record diff --git a/src/main/lib/uassets/parseUAsset.ts b/src/main/lib/uassets/parseUAsset.ts deleted file mode 100644 index 3a17cfa..0000000 --- a/src/main/lib/uassets/parseUAsset.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { UAssetBufferReader } from "@main/lib/uassets/bufferReader" -import { readUAssetNameTable } from "@main/lib/uassets/parser/nameTable" -import { readUAssetExportMap, readUAssetImportMap } from "@main/lib/uassets/parser/objectResource" -import { readUAssetPackageFileSummary } from "@main/lib/uassets/parser/packageFileSummary" -import type { UAssetPackage } from "@/main/types/uassets" - -/** - * Parse a raw `.uasset` file buffer into its decoded Layer 1 parts, the package summary, name - * table, import map, and export map, per-export tagged property streams are not decoded here - * since their location inside an export depends on the export's class (a Layer 2 concern). - * @param source Raw `.uasset` bytes as an `ArrayBuffer` or a view over one. - * @returns The fully decoded package. - * @throws When the file is not a valid `.uasset`, uses a summary format outside the reader's - * supported range, is stored with legacy compressed chunks, or was saved unversioned. - */ -export function parseUAsset(source: ArrayBuffer | ArrayBufferView): UAssetPackage { - const reader = new UAssetBufferReader(source) - - const summary = readUAssetPackageFileSummary(reader) - const names = readUAssetNameTable(reader, summary) - const imports = readUAssetImportMap(reader, summary, names) - const exports = readUAssetExportMap(reader, summary, names) - - return { - summary, - names, - imports, - exports, - } -} diff --git a/src/main/lib/uassets/parser/nameTable.ts b/src/main/lib/uassets/parser/nameTable.ts deleted file mode 100644 index 95a6bca..0000000 --- a/src/main/lib/uassets/parser/nameTable.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { UAssetBufferReader } from "@main/lib/uassets/bufferReader" -import { UAssetObjectVersionUE4 as UE4 } from "@main/lib/uassets/enums/objectVersionUE4" -import { readNameEntry } from "@main/lib/uassets/parser/utils/names" -import type { UAssetPackageFileSummary } from "@/main/types/uassets" - -/** - * Read the entire name table into an array of strings, indexed by FName index, imports, - * exports, property tags and every other in-package identifier reference names by their - * zero-based index into this table so it must be loaded before any of them. - * @param reader Buffer reader, will be seeked to `summary.nameOffset`. - * @param summary Package summary providing `nameOffset`, `nameCount`, and the UE4 version - * used to gate the trailing hash bytes. - * @returns Array of length `summary.nameCount` where index `i` is the `i`-th FName string. - * @see /Engine/Source/Runtime/CoreUObject/Private/UObject/LinkerLoad.cpp `SerializeNameMap` - */ -export function readUAssetNameTable(reader: UAssetBufferReader, summary: UAssetPackageFileSummary): string[] { - reader.seek(summary.nameOffset) - - const hasHashes = summary.fileVersionUE4 >= UE4.VER_UE4_NAME_HASHES_SERIALIZED.value - const names: string[] = new Array(summary.nameCount) - - for (let i = 0; i < summary.nameCount; i++) { - names[i] = readNameEntry(reader, hasHashes) - } - - return names -} diff --git a/src/main/lib/uassets/parser/objectResource.ts b/src/main/lib/uassets/parser/objectResource.ts deleted file mode 100644 index 898ec50..0000000 --- a/src/main/lib/uassets/parser/objectResource.ts +++ /dev/null @@ -1,204 +0,0 @@ -import type { UAssetBufferReader } from "@main/lib/uassets/bufferReader" -import { UAssetObjectVersionUE4 as UE4 } from "@main/lib/uassets/enums/objectVersionUE4" -import { UAssetObjectVersionUE5 as UE5 } from "@main/lib/uassets/enums/objectVersionUE5" -import { UAssetPackageFlags } from "@main/lib/uassets/enums/packageFlags" -import { readFName } from "@main/lib/uassets/parser/utils/names" -import type { UAssetExport, UAssetImport, UAssetPackageFileSummary } from "@/main/types/uassets" - -/** - * Read a single `FObjectImport` from the reader's current position. - * @param reader Buffer reader positioned at the start of the entry. - * @param summary Package summary, supplies the file versions used to gate optional fields. - * @param names Name table used to resolve embedded FName references. - * @returns The decoded import entry. - */ -function readImport(reader: UAssetBufferReader, summary: UAssetPackageFileSummary, names: string[]): UAssetImport { - const classPackage = readFName(reader, names) - const className = readFName(reader, names) - const outerIndex = reader.int32() - const objectName = readFName(reader, names) - - let packageName = "" - if (summary.fileVersionUE4 >= UE4.VER_UE4_NON_OUTER_PACKAGE_IMPORT.value) { - packageName = readFName(reader, names) - } - - let bImportOptional = false - if (summary.fileVersionUE5 >= UE5.OPTIONAL_RESOURCES.value) { - bImportOptional = reader.bool32() - } - - return { - classPackage, - className, - outerIndex, - objectName, - packageName, - bImportOptional, - } -} - -/** - * Read every entry in the import map into an array of resolved imports, FName fields inside - * each import are pre-resolved against `names` so the returned strings are ready to feed - * into commit-message context without further lookup, `FPackageIndex` fields (only - * `outerIndex` on imports) stay as raw signed integers. - * @param reader Buffer reader, will be seeked to `summary.importOffset`. - * @param summary Package summary providing `importOffset`, `importCount`, and gating versions. - * @param names Fully resolved name table from `readUAssetNameTable`. - * @returns Array of length `summary.importCount`. - * @see /Engine/Source/Runtime/CoreUObject/Private/UObject/ObjectResource.cpp `operator<<(FStructuredArchive::FSlot, FObjectImport&)` - */ -export function readUAssetImportMap( - reader: UAssetBufferReader, - summary: UAssetPackageFileSummary, - names: string[], -): UAssetImport[] { - reader.seek(summary.importOffset) - - const imports: UAssetImport[] = new Array(summary.importCount) - for (let i = 0; i < summary.importCount; i++) { - imports[i] = readImport(reader, summary, names) - } - - return imports -} - -/** - * Read a single `FObjectExport` from the reader's current position. - * @param reader Buffer reader positioned at the start of the entry. - * @param summary Package summary, supplies file versions and the unversioned-properties flag. - * @param names Name table used to resolve embedded FName references. - * @returns The decoded export entry. - */ -function readExport(reader: UAssetBufferReader, summary: UAssetPackageFileSummary, names: string[]): UAssetExport { - const classIndex = reader.int32() - const superIndex = reader.int32() - - let templateIndex = 0 - if (summary.fileVersionUE4 >= UE4.VER_UE4_TEMPLATEINDEX_IN_COOKED_EXPORTS.value) { - templateIndex = reader.int32() - } - - const outerIndex = reader.int32() - const objectName = readFName(reader, names) - const objectFlags = reader.uint32() - - // "SerialSize" / "SerialOffset" widened from int32 to int64 in "VER_UE4_64BIT_EXPORTMAP_SERIALSIZES" (511) - let serialSize: bigint - let serialOffset: bigint - if (summary.fileVersionUE4 >= UE4.VER_UE4_64BIT_EXPORTMAP_SERIALSIZES.value) { - serialSize = reader.int64() - serialOffset = reader.int64() - } else { - serialSize = BigInt(reader.int32()) - serialOffset = BigInt(reader.int32()) - } - - const bForcedExport = reader.bool32() - const bNotForClient = reader.bool32() - const bNotForServer = reader.bool32() - - // Pre-REMOVE_OBJECT_EXPORT_PACKAGE_GUID (1005) files carry a 16-byte dummy FGuid here - if (summary.fileVersionUE5 < UE5.REMOVE_OBJECT_EXPORT_PACKAGE_GUID.value) { - reader.skip(16) - } - - let bIsInheritedInstance = false - if (summary.fileVersionUE5 >= UE5.TRACK_OBJECT_EXPORT_IS_INHERITED.value) { - bIsInheritedInstance = reader.bool32() - } - - const packageFlags = reader.uint32() - - let bNotAlwaysLoadedForEditorGame = false - if (summary.fileVersionUE4 >= UE4.VER_UE4_LOAD_FOR_EDITOR_GAME.value) { - bNotAlwaysLoadedForEditorGame = reader.bool32() - } - - let bIsAsset = false - if (summary.fileVersionUE4 >= UE4.VER_UE4_COOKED_ASSETS_IN_EDITOR_SUPPORT.value) { - bIsAsset = reader.bool32() - } - - let bGeneratePublicHash = false - if (summary.fileVersionUE5 >= UE5.OPTIONAL_RESOURCES.value) { - bGeneratePublicHash = reader.bool32() - } - - let firstExportDependency = -1 - let serializationBeforeSerializationDependencies = 0 - let createBeforeSerializationDependencies = 0 - let serializationBeforeCreateDependencies = 0 - let createBeforeCreateDependencies = 0 - if (summary.fileVersionUE4 >= UE4.VER_UE4_PRELOAD_DEPENDENCIES_IN_COOKED_EXPORTS.value) { - firstExportDependency = reader.int32() - serializationBeforeSerializationDependencies = reader.int32() - createBeforeSerializationDependencies = reader.int32() - serializationBeforeCreateDependencies = reader.int32() - createBeforeCreateDependencies = reader.int32() - } - - // "ScriptSerializationStartOffset" / "EndOffset" only present when the package uses versioned - // property serialization and is at least "SCRIPT_SERIALIZATION_OFFSET" (1010) - const usesUnversionedProps = (summary.packageFlags & UAssetPackageFlags.PKG_UnversionedProperties.value) !== 0 - let scriptSerializationStartOffset = 0n - let scriptSerializationEndOffset = 0n - if (!usesUnversionedProps && summary.fileVersionUE5 >= UE5.SCRIPT_SERIALIZATION_OFFSET.value) { - scriptSerializationStartOffset = reader.int64() - scriptSerializationEndOffset = reader.int64() - } - - return { - classIndex, - superIndex, - templateIndex, - outerIndex, - objectName, - objectFlags, - serialSize, - serialOffset, - bForcedExport, - bNotForClient, - bNotForServer, - bIsInheritedInstance, - packageFlags, - bNotAlwaysLoadedForEditorGame, - bIsAsset, - bGeneratePublicHash, - firstExportDependency, - serializationBeforeSerializationDependencies, - createBeforeSerializationDependencies, - serializationBeforeCreateDependencies, - createBeforeCreateDependencies, - scriptSerializationStartOffset, - scriptSerializationEndOffset, - } -} - -/** - * Read every entry in the export map into an array of resolved exports, `objectName` is - * pre-resolved, all `FPackageIndex` fields (`classIndex`, `superIndex`, `templateIndex`, - * `outerIndex`) stay as raw signed integers using UE's convention, `0` is null, positive `N` - * points at export `N - 1`, negative `-N` points at import `N - 1`. - * @param reader Buffer reader, will be seeked to `summary.exportOffset`. - * @param summary Package summary providing `exportOffset`, `exportCount`, and gating versions. - * @param names Fully resolved name table from `readUAssetNameTable`. - * @returns Array of length `summary.exportCount`. - * @see /Engine/Source/Runtime/CoreUObject/Private/UObject/ObjectResource.cpp `operator<<(FStructuredArchive::FSlot, FObjectExport&)` - */ -export function readUAssetExportMap( - reader: UAssetBufferReader, - summary: UAssetPackageFileSummary, - names: string[], -): UAssetExport[] { - reader.seek(summary.exportOffset) - - const exports: UAssetExport[] = new Array(summary.exportCount) - - for (let i = 0; i < summary.exportCount; i++) { - exports[i] = readExport(reader, summary, names) - } - - return exports -} diff --git a/src/main/lib/uassets/parser/packageFileSummary.ts b/src/main/lib/uassets/parser/packageFileSummary.ts deleted file mode 100644 index 598cff2..0000000 --- a/src/main/lib/uassets/parser/packageFileSummary.ts +++ /dev/null @@ -1,316 +0,0 @@ -import CONSTANTS from "@main/lib/constants" -import type { UAssetBufferReader } from "@main/lib/uassets/bufferReader" -import { UAssetObjectVersionUE4 as UE4 } from "@main/lib/uassets/enums/objectVersionUE4" -import { UAssetObjectVersionUE5 as UE5 } from "@main/lib/uassets/enums/objectVersionUE5" -import { UAssetPackageFileTag } from "@main/lib/uassets/enums/packageFileTag" -import { UAssetPackageFlags } from "@main/lib/uassets/enums/packageFlags" -import { readEngineVersion, readIoHashHex } from "@main/lib/uassets/parser/utils/structs" -import dedent from "dedent" -import type { - UAssetCustomVersion, - UAssetEngineVersion, - UAssetGenerationInfo, - UAssetPackageFileSummary, -} from "@/main/types/uassets" - -/** - * Zeroed `UAssetEngineVersion`, used as a default when the summary predates engine-version tracking. - * @returns An all-zero engine version with empty branch string. - */ -function emptyEngineVersion(): UAssetEngineVersion { - return { - major: 0, - minor: 0, - patch: 0, - changelist: 0, - branch: "", - } -} - -/** - * Read the `FPackageFileSummary` at the reader's current position. - * @param reader Buffer reader positioned at the start of the file. - * @returns The decoded summary. - * @throws When the tag is invalid, the summary format is too old/new to parse, the file is - * saved unversioned (not supported), or the package is stored with legacy compression. - */ -export function readUAssetPackageFileSummary(reader: UAssetBufferReader): UAssetPackageFileSummary { - const tag = reader.uint32() - - if (tag === UAssetPackageFileTag.PACKAGE_FILE_TAG_SWAPPED) { - reader.setLittleEndian(false) - } else if (tag !== UAssetPackageFileTag.PACKAGE_FILE_TAG) { - throw new Error(`Not a .uasset file: tag 0x${tag.toString(16)} does not match PACKAGE_FILE_TAG.`) - } - - const legacyFileVersion = reader.int32() - - if (legacyFileVersion >= 0) { - throw new Error(`Unsupported .uasset: legacy UE3-era file (LegacyFileVersion=${legacyFileVersion}).`) - } - - if (legacyFileVersion < CONSTANTS.uasset.currentLegacyFileVersion) { - throw new Error( - dedent`Unsupported .uasset: LegacyFileVersion=${legacyFileVersion} is newer than this - reader supports (max ${CONSTANTS.uasset.currentLegacyFileVersion}).`, - ) - } - - // LegacyFileVersion == -4 is the one revision where LegacyUE3Version was dropped from the wire - let legacyUE3Version = 0 - if (legacyFileVersion !== -4) { - legacyUE3Version = reader.int32() - } - - const fileVersionUE4 = reader.int32() - const fileVersionUE5 = legacyFileVersion <= -8 ? reader.int32() : 0 - const fileVersionLicenseeUE = reader.int32() - - if (fileVersionUE4 === 0 && fileVersionUE5 === 0 && fileVersionLicenseeUE === 0) { - throw new Error("Unsupported .uasset: unversioned packages are not supported by this reader.") - } - if (fileVersionUE4 < UE4.VER_UE4_OLDEST_LOADABLE_PACKAGE.value) { - throw new Error( - `Unsupported .uasset: FileVersionUE4=${fileVersionUE4} is older than the oldest loadable package.`, - ) - } - - // Post-PACKAGE_SAVED_HASH the SavedHash + TotalHeaderSize block moves up before custom versions - const hasEarlySavedHash = fileVersionUE5 >= UE5.PACKAGE_SAVED_HASH.value - let savedHash = "" - let totalHeaderSize = 0 - if (hasEarlySavedHash) { - savedHash = readIoHashHex(reader) - totalHeaderSize = reader.int32() - } - - // LegacyFileVersion <= -2 always writes a custom version container - // For our supported range (>= -9) the format is `Optimized`: int32 count followed by (FGuid Key, int32 Version) pairs - const customVersions: UAssetCustomVersion[] = [] - const customVersionCount = reader.int32() - for (let i = 0; i < customVersionCount; i++) { - const key = reader.fguid() - const version = reader.int32() - customVersions.push({ key, version }) - } - - if (!hasEarlySavedHash) { - totalHeaderSize = reader.int32() - } - - const packageName = reader.fstring() - const packageFlags = reader.uint32() - const filterEditorOnly = (packageFlags & UAssetPackageFlags.PKG_FilterEditorOnly.value) !== 0 - - const nameCount = reader.int32() - const nameOffset = reader.int32() - - let softObjectPathsCount = 0 - let softObjectPathsOffset = -1 - if (fileVersionUE5 >= UE5.ADD_SOFTOBJECTPATH_LIST.value) { - softObjectPathsCount = reader.int32() - softObjectPathsOffset = reader.int32() - } - - let localizationId = "" - if (!filterEditorOnly && fileVersionUE4 >= UE4.VER_UE4_ADDED_PACKAGE_SUMMARY_LOCALIZATION_ID.value) { - localizationId = reader.fstring() - } - - let gatherableTextDataCount = 0 - let gatherableTextDataOffset = -1 - if (fileVersionUE4 >= UE4.VER_UE4_SERIALIZE_TEXT_IN_PACKAGES.value) { - gatherableTextDataCount = reader.int32() - gatherableTextDataOffset = reader.int32() - } - - const exportCount = reader.int32() - const exportOffset = reader.int32() - const importCount = reader.int32() - const importOffset = reader.int32() - - let cellExportCount = 0 - let cellExportOffset = -1 - let cellImportCount = 0 - let cellImportOffset = -1 - if (fileVersionUE5 >= UE5.VERSE_CELLS.value) { - cellExportCount = reader.int32() - cellExportOffset = reader.int32() - cellImportCount = reader.int32() - cellImportOffset = reader.int32() - } - - let metaDataOffset = -1 - if (fileVersionUE5 >= UE5.METADATA_SERIALIZATION_OFFSET.value) { - metaDataOffset = reader.int32() - } - - const dependsOffset = reader.int32() - - let softPackageReferencesCount = 0 - let softPackageReferencesOffset = -1 - if (fileVersionUE4 >= UE4.VER_UE4_ADD_STRING_ASSET_REFERENCES_MAP.value) { - softPackageReferencesCount = reader.int32() - softPackageReferencesOffset = reader.int32() - } - - let searchableNamesOffset = -1 - if (fileVersionUE4 >= UE4.VER_UE4_ADDED_SEARCHABLE_NAMES.value) { - searchableNamesOffset = reader.int32() - } - - const thumbnailTableOffset = reader.int32() - - let importTypeHierarchiesCount = 0 - let importTypeHierarchiesOffset = -1 - if (fileVersionUE5 >= UE5.IMPORT_TYPE_HIERARCHIES.value) { - importTypeHierarchiesCount = reader.int32() - importTypeHierarchiesOffset = reader.int32() - } - - // Pre-PACKAGE_SAVED_HASH files stored a legacy FGuid here instead of the FIoHash above - if (!hasEarlySavedHash) { - savedHash = reader.fguid() - } - - let persistentGuid = "" - if (!filterEditorOnly && fileVersionUE4 >= UE4.VER_UE4_ADDED_PACKAGE_OWNER.value) { - persistentGuid = reader.fguid() - // OwnerPersistentGuid existed for one UE4 version between VER_UE4_ADDED_PACKAGE_OWNER (518) and VER_UE4_NON_OUTER_PACKAGE_IMPORT (520) - // Read and discard when present - if (fileVersionUE4 < UE4.VER_UE4_NON_OUTER_PACKAGE_IMPORT.value) { - reader.fguid() - } - } - - const generationCount = reader.int32() - const generations: UAssetGenerationInfo[] = [] - for (let i = 0; i < generationCount; i++) { - generations.push({ exportCount: reader.int32(), nameCount: reader.int32() }) - } - - let savedByEngineVersion: UAssetEngineVersion = emptyEngineVersion() - if (fileVersionUE4 >= UE4.VER_UE4_ENGINE_VERSION_OBJECT.value) { - savedByEngineVersion = readEngineVersion(reader) - } else { - const engineChangelist = reader.int32() - if (engineChangelist !== 0) { - savedByEngineVersion = { major: 4, minor: 0, patch: 0, changelist: engineChangelist, branch: "" } - } - } - - let compatibleWithEngineVersion: UAssetEngineVersion = savedByEngineVersion - if (fileVersionUE4 >= UE4.VER_UE4_PACKAGE_SUMMARY_HAS_COMPATIBLE_ENGINE_VERSION.value) { - compatibleWithEngineVersion = readEngineVersion(reader) - } - - const compressionFlags = reader.uint32() - const compressedChunkCount = reader.int32() - if (compressedChunkCount !== 0) { - throw new Error( - `Unsupported .uasset: package uses legacy compressed chunks (count=${compressedChunkCount}), only uncompressed source assets are readable.`, - ) - } - - const packageSource = reader.uint32() - - // AdditionalPackagesToCook is unused since ages, but still serialized as TArray - const additionalPackagesCount = reader.int32() - for (let i = 0; i < additionalPackagesCount; i++) { - reader.fstring() - } - - // Legacy texture allocation info was removed in LegacyFileVersion -7 - // Our supported range never hits the read path since CONSTANTS.uasset.currentLegacyFileVersion is -9 - - const assetRegistryDataOffset = reader.int32() - const bulkDataStartOffset = reader.int64() - - let worldTileInfoDataOffset = -1 - if (fileVersionUE4 >= UE4.VER_UE4_WORLD_LEVEL_INFO.value) { - worldTileInfoDataOffset = reader.int32() - } - - const chunkIds: number[] = [] - if (fileVersionUE4 >= UE4.VER_UE4_CHANGED_CHUNKID_TO_BE_AN_ARRAY_OF_CHUNKIDS.value) { - const chunkIdCount = reader.int32() - for (let i = 0; i < chunkIdCount; i++) chunkIds.push(reader.int32()) - } else if (fileVersionUE4 >= UE4.VER_UE4_ADDED_CHUNKID_TO_ASSETDATA_AND_UPACKAGE.value) { - const singleChunkId = reader.int32() - if (singleChunkId >= 0) chunkIds.push(singleChunkId) - } - - let preloadDependencyCount = -1 - let preloadDependencyOffset = 0 - if (fileVersionUE4 >= UE4.VER_UE4_PRELOAD_DEPENDENCIES_IN_COOKED_EXPORTS.value) { - preloadDependencyCount = reader.int32() - preloadDependencyOffset = reader.int32() - } - - let namesReferencedFromExportDataCount = nameCount - if (fileVersionUE5 >= UE5.NAMES_REFERENCED_FROM_EXPORT_DATA.value) { - namesReferencedFromExportDataCount = reader.int32() - } - - let payloadTocOffset = -1n - if (fileVersionUE5 >= UE5.PAYLOAD_TOC.value) { - payloadTocOffset = reader.int64() - } - - let dataResourceOffset = -1 - if (fileVersionUE5 >= UE5.DATA_RESOURCES.value) { - dataResourceOffset = reader.int32() - } - - return { - tag, - legacyFileVersion, - legacyUE3Version, - fileVersionUE4, - fileVersionUE5, - fileVersionLicenseeUE, - customVersions, - savedHash, - totalHeaderSize, - packageName, - packageFlags, - nameCount, - nameOffset, - softObjectPathsCount, - softObjectPathsOffset, - localizationId, - gatherableTextDataCount, - gatherableTextDataOffset, - exportCount, - exportOffset, - importCount, - importOffset, - cellExportCount, - cellExportOffset, - cellImportCount, - cellImportOffset, - metaDataOffset, - dependsOffset, - softPackageReferencesCount, - softPackageReferencesOffset, - searchableNamesOffset, - thumbnailTableOffset, - importTypeHierarchiesCount, - importTypeHierarchiesOffset, - persistentGuid, - generations, - savedByEngineVersion, - compatibleWithEngineVersion, - compressionFlags, - packageSource, - assetRegistryDataOffset, - bulkDataStartOffset, - worldTileInfoDataOffset, - chunkIds, - preloadDependencyCount, - preloadDependencyOffset, - namesReferencedFromExportDataCount, - payloadTocOffset, - dataResourceOffset, - } -} diff --git a/src/main/lib/uassets/parser/propertyTag.ts b/src/main/lib/uassets/parser/propertyTag.ts deleted file mode 100644 index 70f5161..0000000 --- a/src/main/lib/uassets/parser/propertyTag.ts +++ /dev/null @@ -1,163 +0,0 @@ -import CONSTANTS from "@main/lib/constants" -import type { UAssetBufferReader } from "@main/lib/uassets/bufferReader" -import { UAssetObjectVersionUE5 as UE5 } from "@main/lib/uassets/enums/objectVersionUE5" -import { UAssetPackageFlags } from "@main/lib/uassets/enums/packageFlags" -import { readFName } from "@main/lib/uassets/parser/utils/names" -import dedent from "dedent" -import type { UAssetPackageFileSummary, UAssetPropertyTag } from "@/main/types/uassets" - -/** - * Advance past the optional property-extensions block written when `HasPropertyExtensions` is - * set, the two currently defined extension groups (`OverridableInformation`, - * `HasExternalsObjects`) are consumed but their values are discarded. - * @param reader Buffer reader positioned at the start of the extensions block. - */ -function skipPropertyExtensions(reader: UAssetBufferReader): void { - const extensions = reader.uint8() - - if ((extensions & CONSTANTS.uasset.propertyTagExtension.overridableInformation) !== 0) { - reader.uint8() // "OverriddenPropertyOperation" - reader.bool32() // "ExperimentalOverridableLogic" - } - - if ((extensions & CONSTANTS.uasset.propertyTagExtension.hasExternalsObjects) !== 0) { - reader.bool32() // "ExperimentalExternalObjects" - } -} - -/** - * Recursively render a `FPropertyTypeName` subtree into `Name(child1,child2,...)` form. - * @param nodes Full node array from `readPropertyTypeName`. - * @param index Starting index of the subtree to render. - * @returns The subtree's rendered text and the index of the next sibling node. - */ -function renderTypeName( - nodes: { - name: string - innerCount: number - }[], - index: number, -): { text: string; next: number } { - const node = nodes[index] - if (node.innerCount === 0) { - return { - text: node.name, - next: index + 1, - } - } - - const parts: string[] = [] - let next = index + 1 - for (let i = 0; i < node.innerCount; i++) { - const child = renderTypeName(nodes, next) - parts.push(child.text) - next = child.next - } - - return { - text: `${node.name}(${parts.join(",")})`, - next, - } -} - -/** - * Read an `FPropertyTypeName` (a pre-order-encoded tree of `{FName, int32 innerCount}` nodes) - * and render it as a human-readable dotted string, e.g. `"StructProperty(Vector)"`. - * @param reader Buffer reader positioned at the start of the type name tree. - * @param names Name table used to resolve node FName references. - * @returns The rendered type name string. - */ -function readPropertyTypeName(reader: UAssetBufferReader, names: string[]): string { - const nodes: { name: string; innerCount: number }[] = [] - - let remaining = 1 - do { - const name = readFName(reader, names) - const innerCount = reader.int32() - nodes.push({ name, innerCount }) - remaining += innerCount - 1 - } while (remaining > 0) - - return renderTypeName(nodes, 0).text -} - -/** - * Read a single tag header from the reader's current position, or return `null` when the - * stream terminator (a None-named tag) is encountered. - * @param reader Buffer reader positioned at the start of the tag. - * @param names Name table used to resolve embedded FName references. - * @returns The decoded tag header, or `null` at end-of-stream. - */ -function readTagHeader(reader: UAssetBufferReader, names: string[]): UAssetPropertyTag | null { - const name = readFName(reader, names) - if (name === "None") return null - - const typeName = readPropertyTypeName(reader, names) - const size = reader.int32() - const flags = reader.uint8() - - const arrayIndex = (flags & CONSTANTS.uasset.propertyTagFlag.hasArrayIndex) === 0 ? 0 : reader.int32() - const propertyGuid = (flags & CONSTANTS.uasset.propertyTagFlag.hasPropertyGuid) === 0 ? "" : reader.fguid() - - if ((flags & CONSTANTS.uasset.propertyTagFlag.hasPropertyExtensions) !== 0) { - skipPropertyExtensions(reader) - } - - const valueOffset = BigInt(reader.tell()) - - return { - name, - typeName, - size, - arrayIndex, - boolValue: (flags & CONSTANTS.uasset.propertyTagFlag.boolTrue) !== 0, - skipped: (flags & CONSTANTS.uasset.propertyTagFlag.skippedSerialize) !== 0, - binaryOrNativeSerialize: (flags & CONSTANTS.uasset.propertyTagFlag.hasBinaryOrNativeSerialize) !== 0, - propertyGuid, - valueOffset, - } -} - -/** - * Read the property tag stream starting at `startOffset` and stop when the None terminator - * is reached, value payloads are skipped over (not decoded), each returned tag carries the - * absolute `valueOffset` and byte `size` so callers can locate the raw payload later, only - * the modern tag format (post-`PROPERTY_TAG_COMPLETE_TYPE_NAME`, UE 5.4+) is supported. - * @param reader Buffer reader, will be seeked to `startOffset`. - * @param summary Package summary, provides version gates and the unversioned-properties flag. - * @param names Fully resolved name table from `readUAssetNameTable`. - * @param startOffset Byte offset of the tag stream, typically `export.serialOffset`. - * @returns Array of decoded tag headers in stream order (terminator excluded). - * @throws When the package uses unversioned property serialization (no tags emitted) or when - * the file predates `PROPERTY_TAG_COMPLETE_TYPE_NAME` (legacy format not supported). - * @see /Engine/Source/Runtime/CoreUObject/Private/UObject/PropertyTag.cpp `operator<<(FStructuredArchive::FSlot, FPropertyTag&)` - */ -export function readUAssetPropertyTags( - reader: UAssetBufferReader, - summary: UAssetPackageFileSummary, - names: string[], - startOffset: number, -): UAssetPropertyTag[] { - if ((summary.packageFlags & UAssetPackageFlags.PKG_UnversionedProperties.value) !== 0) { - throw new Error("Cannot read tagged properties: package uses unversioned property serialization.") - } - if (summary.fileVersionUE5 < UE5.PROPERTY_TAG_COMPLETE_TYPE_NAME.value) { - throw new Error( - dedent`Legacy property tag format (pre-PROPERTY_TAG_COMPLETE_TYPE_NAME, - UE5 version ${summary.fileVersionUE5}) is not supported by this reader.`, - ) - } - - reader.seek(startOffset) - - const tags: UAssetPropertyTag[] = [] - while (true) { - const tag = readTagHeader(reader, names) - if (tag === null) break - - tags.push(tag) - reader.skip(tag.size) - } - - return tags -} diff --git a/src/main/lib/uassets/parser/utils/names.ts b/src/main/lib/uassets/parser/utils/names.ts deleted file mode 100644 index 4c62d2b..0000000 --- a/src/main/lib/uassets/parser/utils/names.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { UAssetBufferReader } from "@main/lib/uassets/bufferReader" - -/** - * Read a single `FNameEntrySerialized` from the reader's current position, the wire format - * is an `FString` (length prefix + string bytes + null terminator) followed by two `uint16` - * hashes for post-`VER_UE4_NAME_HASHES_SERIALIZED` files, UE writes both hashes out of habit - * but ignores them on load so we skip past them without decoding. - * @param reader Buffer reader positioned at the start of the entry. - * @param hasHashes True when the entry ends with `NonCasePreservingHash` + `CasePreservingHash`. - * @returns The decoded name string. - * @see /Engine/Source/Runtime/Core/Private/UObject/UnrealNames.cpp `operator<<(FArchive&, FNameEntrySerialized&)` - */ -export function readNameEntry(reader: UAssetBufferReader, hasHashes: boolean): string { - const name = reader.fstring() - - if (hasHashes) { - // NonCasePreservingHash + CasePreservingHash, both uint16, unused at load time - reader.uint16() - reader.uint16() - } - - return name -} - -/** - * Read an on-disk `FName` reference (int32 name index + int32 numeric suffix) and resolve it - * against the given name table, UE stores the suffix as `ExternalNumber + 1` so `0` means - * "no suffix", `1` means `_0`, `2` means `_1`, and so on. - * @param reader Buffer reader positioned at the start of the FName pair. - * @param names Name table produced by `readUAssetNameTable`. - * @returns The base name string with `_N` suffix appended when the on-disk number is positive. - * @throws When the name index is out of range for `names`, which indicates a corrupt file. - */ -export function readFName(reader: UAssetBufferReader, names: string[]): string { - const nameIndex = reader.int32() - const number = reader.int32() - - if (nameIndex < 0 || nameIndex >= names.length) { - throw new Error(`Invalid FName index ${nameIndex} (name table has ${names.length} entries).`) - } - - const base = names[nameIndex] - return number > 0 ? `${base}_${number - 1}` : base -} diff --git a/src/main/lib/uassets/parser/utils/structs.ts b/src/main/lib/uassets/parser/utils/structs.ts deleted file mode 100644 index d3140e8..0000000 --- a/src/main/lib/uassets/parser/utils/structs.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { UAssetBufferReader } from "@main/lib/uassets/bufferReader" -import type { UAssetEngineVersion } from "@/main/types/uassets" - -/** - * Read a 20-byte `FIoHash` and return it as an upper-case hex string. - * @param reader Buffer reader positioned at the start of the hash. - * @returns The 40-character upper-case hex hash. - * @see /Engine/Source/Runtime/Core/Public/IO/IoHash.h - */ -export function readIoHashHex(reader: UAssetBufferReader): string { - const bytes = reader.bytes(20) - let hex = "" - for (const byte of bytes) hex += byte.toString(16).padStart(2, "0") - return hex.toUpperCase() -} - -/** - * Read an `FEngineVersion` in the on-disk order (Major, Minor, Patch, Changelist, Branch). - * @param reader Buffer reader positioned at the start of the version block. - * @returns The decoded engine version. - * @see /Engine/Source/Runtime/Core/Private/Misc/EngineVersion.cpp `operator<<(FStructuredArchive::FSlot, FEngineVersion&)` - */ -export function readEngineVersion(reader: UAssetBufferReader): UAssetEngineVersion { - return { - major: reader.uint16(), - minor: reader.uint16(), - patch: reader.uint16(), - changelist: reader.uint32(), - branch: reader.fstring(), - } -} diff --git a/src/main/lib/uassets/resolveMainAsset.ts b/src/main/lib/uassets/resolveMainAsset.ts deleted file mode 100644 index 9ce0488..0000000 --- a/src/main/lib/uassets/resolveMainAsset.ts +++ /dev/null @@ -1,119 +0,0 @@ -import type { UAssetMainAsset, UAssetPackage } from "@/main/types/uassets" - -/** - * Walk an import's outer chain up to the top-level entry (where `outerIndex === 0`) and - * return its `objectName`, which for a class import is the module path like `"/Script/Engine"`, - * `"None"` when the chain terminates without a proper package (rare, indicates a corrupt file). - * @param pkg Parsed package. - * @param startOuterIndex The `outerIndex` of the import whose enclosing package we want. - * @returns The `objectName` of the top-of-chain import. - */ -function resolveOuterPackage(pkg: UAssetPackage, startOuterIndex: number): string { - let outerIndex = startOuterIndex - let last = "" - - while (outerIndex < 0) { - const importIndex = -outerIndex - 1 - if (importIndex >= pkg.imports.length) return last - const parent = pkg.imports[importIndex] - last = parent.objectName - outerIndex = parent.outerIndex - } - - return last -} - -/** - * Resolve an `FPackageIndex` (the export's `classIndex`) to its `className` + `classPackage`, - * negative values point into the import map, positive values into the export map, zero is - * the null class (never valid for a main asset). - * @param pkg Parsed package. - * @param classIndex Raw `FPackageIndex` from the main export. - * @returns The class's name and outer package path. - * @throws When the index is `0` or out of range for the map it points into. - */ -function resolveClass(pkg: UAssetPackage, classIndex: number): { className: string; classPackage: string } { - if (classIndex < 0) { - const importIndex = -classIndex - 1 - - if (importIndex >= pkg.imports.length) { - throw new Error(`Main export classIndex=${classIndex} is out of range for the import map.`) - } - - const classImport = pkg.imports[importIndex] - return { - className: classImport.objectName, - classPackage: resolveOuterPackage(pkg, classImport.outerIndex), - } - } - - if (classIndex > 0) { - const exportIndex = classIndex - 1 - - if (exportIndex >= pkg.exports.length) { - throw new Error(`Main export classIndex=${classIndex} is out of range for the export map.`) - } - - return { - className: pkg.exports[exportIndex].objectName, - classPackage: "", - } - } - - throw new Error("Main export has a null classIndex (0), which is not a valid class reference.") -} - -/** - * Extract the short asset name from a full package path (`"/Game/Foo/Bar/BP_Enemy"` into `"BP_Enemy"`). - * @param packagePath Full UE package path from the summary. - * @returns The last `/`-separated segment, or the original string when it has no slashes. - */ -function shortNameOf(packagePath: string): string { - const slash = packagePath.lastIndexOf("/") - return slash < 0 ? packagePath : packagePath.slice(slash + 1) -} - -/** - * Locate the main export's index inside `pkg.exports`, matching by name first and falling - * back to the first top-level export. - * @param pkg Parsed package. - * @param shortName Expected `objectName` of the main asset. - * @returns Zero-based index into `pkg.exports`. - */ -function findMainExportIndex(pkg: UAssetPackage, shortName: string): number { - const byName = pkg.exports.findIndex(entry => entry.objectName === shortName) - if (byName !== -1) return byName - - const byOuter = pkg.exports.findIndex(entry => entry.outerIndex === 0) - if (byOuter !== -1) return byOuter - - return 0 -} - -/** - * Identify the "main" asset export inside a parsed package and resolve its class, the - * returned `className` is what Layer 2 shapers dispatch on (`"Blueprint"`, `"Material"`, - * `"Texture2D"`, ...), detection prefers the export whose `objectName` matches the last - * segment of `summary.packageName` and falls back to the first top-level export - * (`outerIndex === 0`) when no name match exists. - * @param pkg Package returned by `parseUAsset`. - * @returns The main asset descriptor with its class resolved. - * @throws When the package has no exports or the main export's `classIndex` is out of range. - */ -export function resolveMainAsset(pkg: UAssetPackage): UAssetMainAsset { - if (pkg.exports.length === 0) { - throw new Error("Cannot resolve main asset: package has no exports.") - } - - const shortName = shortNameOf(pkg.summary.packageName) - const index = findMainExportIndex(pkg, shortName) - const mainExport = pkg.exports[index] - const classInfo = resolveClass(pkg, mainExport.classIndex) - - return { - index, - export: mainExport, - className: classInfo.className, - classPackage: classInfo.classPackage, - } -} diff --git a/src/main/lib/uassets/shapers/blueprint.ts b/src/main/lib/uassets/shapers/blueprint.ts deleted file mode 100644 index 3a53e4a..0000000 --- a/src/main/lib/uassets/shapers/blueprint.ts +++ /dev/null @@ -1,171 +0,0 @@ -import CONSTANTS from "@main/lib/constants" -import type { - UAssetBlueprintComponent, - UAssetBlueprintShape, - UAssetExport, - UAssetMainAsset, - UAssetPackage, -} from "@/main/types/uassets" - -/** - * Resolve an `FPackageIndex` to just the class/object name string, negative values point into - * the import map, positive values into the export map, `0` returns the empty string. - * @param pkg Parsed package. - * @param packageIndex Raw `FPackageIndex` (signed int32 from the disk format). - * @returns The referenced object's `objectName`, or the empty string when the index is null or out of range. - */ -function resolveClassName(pkg: UAssetPackage, packageIndex: number): string { - if (packageIndex < 0) { - const importIndex = -packageIndex - 1 - if (importIndex >= pkg.imports.length) return "" - return pkg.imports[importIndex].objectName - } - - if (packageIndex > 0) { - const exportIndex = packageIndex - 1 - if (exportIndex >= pkg.exports.length) return "" - return pkg.exports[exportIndex].objectName - } - - return "" -} - -/** - * Walk an import's outer chain up to the top-level entry and return that entry's `objectName`, - * which for a class import is the module path like `"/Script/Engine"` or `"/Game/Foo/Bar"`. - * @param pkg Parsed package. - * @param startOuterIndex The `outerIndex` of the import whose enclosing package we want. - * @returns The top-of-chain `objectName`, or the empty string when the chain is empty. - */ -function walkOuterToPackage(pkg: UAssetPackage, startOuterIndex: number): string { - let outerIndex = startOuterIndex - let last = "" - - while (outerIndex < 0) { - const importIndex = -outerIndex - 1 - if (importIndex >= pkg.imports.length) return last - const parent = pkg.imports[importIndex] - last = parent.objectName - outerIndex = parent.outerIndex - } - - return last -} - -/** - * Locate the `UBlueprintGeneratedClass` export associated with the main Blueprint, matched by - * the `_C`-suffixed name convention. - * @param pkg Parsed package. - * @param main Descriptor returned by `resolveMainAsset`. - * @returns The generated-class export, or `null` when no matching export exists. - */ -function findGeneratedClassExport(pkg: UAssetPackage, main: UAssetMainAsset): UAssetExport | null { - const expectedName = `${main.export.objectName}${CONSTANTS.uasset.blueprint.compiledClassSuffix}` - return pkg.exports.find(entry => entry.objectName === expectedName) ?? null -} - -/** - * Resolve the Blueprint's parent class by looking at the `UBlueprintGeneratedClass` export's - * `superIndex` and turning it into a class name. - * @param pkg Parsed package. - * @param main Descriptor returned by `resolveMainAsset`. - * @returns The parent class name, or the empty string when the generated class or its super can't be resolved. - */ -function resolveParentClass(pkg: UAssetPackage, main: UAssetMainAsset): string { - const generatedClass = findGeneratedClassExport(pkg, main) - if (!generatedClass) return "" - return resolveClassName(pkg, generatedClass.superIndex) -} - -/** - * Extract every SCS component template (exports whose name ends in `_GEN_VARIABLE`), the - * variable name is everything before the suffix and the class name comes from resolving - * the export's `classIndex`. - * @param pkg Parsed package. - * @returns Component templates in export-order. - */ -function extractComponents(pkg: UAssetPackage): UAssetBlueprintComponent[] { - const result: UAssetBlueprintComponent[] = [] - - for (const entry of pkg.exports) { - if (!entry.objectName.endsWith(CONSTANTS.uasset.blueprint.componentSuffix)) continue - - result.push({ - name: entry.objectName.slice(0, -CONSTANTS.uasset.blueprint.componentSuffix.length), - className: resolveClassName(pkg, entry.classIndex), - }) - } - - return result -} - -/** - * Extract every user-defined function graph (exports whose class resolves to `"Function"`). - * @param pkg Parsed package. - * @returns Function names in export-order. - */ -function extractFunctions(pkg: UAssetPackage): string[] { - const result: string[] = [] - - for (const entry of pkg.exports) { - if (resolveClassName(pkg, entry.classIndex) !== "Function") continue - result.push(entry.objectName) - } - - return result -} - -/** - * Collect the distinct `/Game/`-rooted package paths referenced by this Blueprint's imports, - * deduplicated and sorted alphabetically. - * @param pkg Parsed package. - * @returns Sorted unique referenced asset package paths. - */ -function extractReferencedAssets(pkg: UAssetPackage): string[] { - const seen = new Set() - - for (const imp of pkg.imports) { - const outerPackage = walkOuterToPackage(pkg, imp.outerIndex) - if (outerPackage.startsWith(CONSTANTS.uasset.gamePackagePrefix)) seen.add(outerPackage) - } - - return Array.from(seen).sort() -} - -/** - * Format the shaper output into a compact single-line summary suitable for an AI prompt. - * @param shape Everything the shaper collected, minus `renderedSummary` and `kind`. - * @returns One line like `"Blueprint(BP_Enemy, parent=Actor, components=2, functions=1, refs=3)"`. - */ -function renderSummary(shape: Omit): string { - const parent = shape.parentClass || "?" - return `Blueprint(${shape.name}, parent=${parent}, components=${shape.components.length}, functions=${shape.functions.length}, refs=${shape.referencedAssets.length})` -} - -/** - * Shape a Blueprint main asset into the Layer 2 view a commit-message tool feeds to the AI, - * pulls parent class, SCS components, user functions, and referenced `/Game/` assets out of - * the parsed package. - * @param pkg Parsed package returned by `parseUAsset`. - * @param main Main-asset descriptor returned by `resolveMainAsset` (expected `className === "Blueprint"`). - * @returns The Blueprint shape with a pre-rendered one-line summary. - */ -export function shapeBlueprint(pkg: UAssetPackage, main: UAssetMainAsset): UAssetBlueprintShape { - const name = main.export.objectName - const parentClass = resolveParentClass(pkg, main) - const components = extractComponents(pkg) - const functions = extractFunctions(pkg) - const referencedAssets = extractReferencedAssets(pkg) - - const renderedSummary = renderSummary({ name, parentClass, components, functions, referencedAssets }) - - return { - kind: "blueprint", - name, - parentClass, - components, - functions, - referencedAssets, - renderedSummary, - } -} diff --git a/src/main/lib/updater/handlers.ts b/src/main/lib/updater/handlers.ts new file mode 100644 index 0000000..cd038f7 --- /dev/null +++ b/src/main/lib/updater/handlers.ts @@ -0,0 +1,35 @@ +import CONSTANTS from "@main/lib/constants" +import { safeHandle } from "@main/lib/ipc" +import { openUpdateDialog } from "@main/lib/updater" +import { checkForUpdates, downloadUpdate, installUpdate, simulateUpdate } from "@main/lib/updater/service" +import { getUpdaterState } from "@main/lib/updater/store" +import { ipcMain } from "electron" +import type { UpdaterSimulation } from "@/main/types/updater" + +/** + * Registers the IPC handlers that expose the updater to the renderer, letting the + * dialog read the current state, start a check or a download, open the dialog from + * the status bar, and trigger the restart that installs the staged version. + */ +export function registerUpdaterHandlers() { + safeHandle(CONSTANTS.ipc.updaterGetState, () => getUpdaterState()) + + ipcMain.on(CONSTANTS.ipc.updaterOpenDialog, () => openUpdateDialog()) + + safeHandle(CONSTANTS.ipc.updaterCheck, async (_event, isManualCheck: boolean) => { + // A manual check shows the dialog up front, so the user sees the check run + // and gets told when there is nothing to install + if (isManualCheck) openUpdateDialog() + + await checkForUpdates(isManualCheck) + }) + + safeHandle(CONSTANTS.ipc.updaterDownload, () => downloadUpdate()) + + ipcMain.on(CONSTANTS.ipc.updaterInstall, () => installUpdate()) + + ipcMain.on(CONSTANTS.ipc.updaterSimulate, (_event, scenario: UpdaterSimulation) => { + openUpdateDialog() + simulateUpdate(scenario) + }) +} diff --git a/src/main/lib/updater/index.ts b/src/main/lib/updater/index.ts index 48d6532..c5e8b45 100644 --- a/src/main/lib/updater/index.ts +++ b/src/main/lib/updater/index.ts @@ -1,20 +1,120 @@ -import UPDATER_CONFIG from "@main/config/updater" -import { app } from "electron" -import { autoUpdater } from "electron-updater" +import CONSTANTS from "@main/lib/constants" +import { createDialogWindow, openDialog } from "@main/lib/dialogs" +import { initUpdater } from "@main/lib/updater/service" +import { dismissAvailableVersion, isAvailableVersionDismissed, subscribeToUpdaterState } from "@main/lib/updater/store" +import { getMainWindow } from "@main/lib/windows" +import { BrowserWindow } from "electron" +import type { UpdaterState, UpdaterStatus } from "@/main/types/updater" /** - * Starts the auto-updater against the GitHub Releases feed configured in - * `electron-builder.yml`. + * The open update dialog window, tracked so a second check cannot stack another + * one on top of it. */ -export function startAutoUpdater() { - if (!app.isPackaged) return +let updateWindow: BrowserWindow | null = null - autoUpdater.autoDownload = true - autoUpdater.autoInstallOnAppQuit = true +/** + * Pushes the updater state to every open window, so both the dialog and the main + * window's status bar follow the same transitions. + * @param next The new updater state. + */ +function broadcastUpdaterState(next: UpdaterState) { + BrowserWindow.getAllWindows().forEach(window => { + if (window.isDestroyed()) return + window.webContents.send(CONSTANTS.ipc.updaterStateChanged, next) + }) +} + +/** + * Opens the update dialog, or focuses the one already open. It is parented to the + * main window but deliberately not modal, so a download never blocks the app. + */ +export function openUpdateDialog() { + if (updateWindow && !updateWindow.isDestroyed()) { + if (updateWindow.isMinimized()) updateWindow.restore() + updateWindow.focus() + + return + } + + updateWindow = createDialogWindow( + getMainWindow(), + { + variant: "update", + title: "Software Update", + }, + false, + ) + + // Closing the dialog on a merely available update counts as "not now", whichever + // way it was closed, so the periodic checks stop reopening it for that version + updateWindow.on("closed", () => { + updateWindow = null + dismissAvailableVersion() + }) +} + +/** + * Closes the update dialog, without the close counting as a dismissal, so a result + * with nothing to act on can be handed over to a smaller dialog instead. + * @returns `true` when a dialog was actually open, meaning someone is waiting on the result. + */ +function closeUpdateDialog(): boolean { + if (!updateWindow || updateWindow.isDestroyed()) return false + + updateWindow.close() + updateWindow = null + + return true +} + +/** + * Hands a result that carries nothing to act on over to a plain dialog, since the + * update window is sized for release notes and progress that these states do not + * have, errors go through the error variant so they chime like any other failure. + * @param next The updater state that ended the flow. + * @param wasDownloading Whether the state interrupted a download the user started. + */ +function surfaceTerminalResult(next: UpdaterState, wasDownloading: boolean) { + const wasDialogOpen = closeUpdateDialog() + + // Automatic checks stay silent, only a waiting user gets told + if (!wasDialogOpen && !wasDownloading) return + + if (next.status === "error") { + openDialog(getMainWindow(), { + variant: "error", + title: "Software Update", + message: next.error ?? "The update could not be checked.", + }) + + return + } + + openDialog(getMainWindow(), { + variant: "message", + title: "Software Update", + message: `GitGame is up to date.\n\nYou are running version ${next.currentVersion}, the latest one published.`, + }) +} + +/** + * Starts the updater, mirroring its state onto every window and surfacing the + * dialog whenever a new version turns up. + */ +export function startUpdater() { + let previousStatus: UpdaterStatus = "idle" + + subscribeToUpdaterState(next => { + broadcastUpdaterState(next) + + if (next.status === "available" && !isAvailableVersionDismissed()) openUpdateDialog() - autoUpdater.on("error", err => console.error("[updater] error:", err)) + if (next.status === "not-available" || next.status === "error") { + surfaceTerminalResult(next, previousStatus === "downloading") + } - autoUpdater.checkForUpdatesAndNotify() + previousStatus = next.status + }) - setInterval(() => autoUpdater.checkForUpdatesAndNotify(), UPDATER_CONFIG.checkIntervalMs) + initUpdater() } diff --git a/src/main/lib/updater/parse.ts b/src/main/lib/updater/parse.ts new file mode 100644 index 0000000..b43b1ad --- /dev/null +++ b/src/main/lib/updater/parse.ts @@ -0,0 +1,34 @@ +import type { ProgressInfo, UpdateInfo } from "electron-updater" +import type { UpdaterDownloadProgress } from "@/main/types/updater" + +/** + * Flattens electron-updater's release notes, which arrive either as a single + * string or as one entry per skipped version. + * @param notes The release notes carried by the update info. + * @returns The notes as a single string, or `null` when the feed carried none. + */ +export function normalizeReleaseNotes(notes: UpdateInfo["releaseNotes"]): string | null { + if (!notes) return null + if (typeof notes === "string") return notes + + const joined = notes + .map(entry => (entry.version ? `${entry.version}\n${entry.note ?? ""}` : (entry.note ?? ""))) + .filter(entry => entry.trim().length > 0) + .join("\n\n") + + return joined.length > 0 ? joined : null +} + +/** + * Narrows electron-updater's progress payload to the fields the dialog renders. + * @param info The progress info emitted while downloading. + * @returns The progress snapshot. + */ +export function parseDownloadProgress(info: ProgressInfo): UpdaterDownloadProgress { + return { + percent: info.percent, + transferred: info.transferred, + total: info.total, + bytesPerSecond: info.bytesPerSecond, + } +} diff --git a/src/main/lib/updater/service.ts b/src/main/lib/updater/service.ts new file mode 100644 index 0000000..4a5a2d7 --- /dev/null +++ b/src/main/lib/updater/service.ts @@ -0,0 +1,213 @@ +import UPDATER_CONFIG from "@main/config/updater" +import CONSTANTS from "@main/lib/constants" +import { normalizeReleaseNotes, parseDownloadProgress } from "@main/lib/updater/parse" +import { buildSimulatedProgress, buildSimulatedState } from "@main/lib/updater/simulation" +import { + CLEARED_RELEASE_FIELDS, + clearDismissedVersion, + emitUpdaterState, + getUpdaterState, + isUpdaterBusy, + setUpdaterState, +} from "@main/lib/updater/store" +import { convertErrorToMessage } from "@main/lib/utils/errors" +import { app } from "electron" +import { autoUpdater, type ProgressInfo, type UpdateInfo } from "electron-updater" +import type { UpdaterSimulation } from "@/main/types/updater" + +/** + * The fake flow currently being driven from the `Dev Tools` menu, if any. While + * set, the real `autoUpdater` is never called. + */ +let simulation: UpdaterSimulation | null = null + +/** + * The interval stepping a simulated download forward. + */ +let simulationTimer: NodeJS.Timeout | null = null + +/** + * Stops any simulated download still stepping forward. + */ +function stopSimulationTimer() { + if (!simulationTimer) return + + clearInterval(simulationTimer) + simulationTimer = null +} + +/** + * Steps a fake download to completion so the progress bar and the restart prompt + * can be exercised without a real release. + */ +function runSimulatedDownload() { + let percent = 0 + + stopSimulationTimer() + + simulationTimer = setInterval(() => { + percent = Math.min(100, percent + UPDATER_CONFIG.simulatedDownload.tickPercent) + + if (percent >= 100) { + stopSimulationTimer() + setUpdaterState({ status: "downloaded", progress: null }) + + return + } + + setUpdaterState({ status: "downloading", progress: buildSimulatedProgress(percent) }) + }, UPDATER_CONFIG.simulatedDownload.tickMs) +} + +/** + * Mirrors the `autoUpdater` lifecycle onto the updater state. + */ +function attachAutoUpdaterEvents() { + autoUpdater.on("update-available", (info: UpdateInfo) => { + setUpdaterState({ + status: "available", + version: info.version, + releaseNotes: normalizeReleaseNotes(info.releaseNotes), + releaseDate: info.releaseDate ?? null, + error: null, + }) + }) + + autoUpdater.on("update-not-available", () => { + setUpdaterState({ status: "not-available", ...CLEARED_RELEASE_FIELDS, error: null }) + }) + + autoUpdater.on("download-progress", (info: ProgressInfo) => { + setUpdaterState({ status: "downloading", progress: parseDownloadProgress(info) }) + }) + + autoUpdater.on("update-downloaded", (info: UpdateInfo) => { + setUpdaterState({ status: "downloaded", version: info.version, progress: null }) + }) + + autoUpdater.on("error", error => { + console.error("[updater] error:", error) + setUpdaterState({ status: "error", error: convertErrorToMessage(error), progress: null }) + }) +} + +/** + * Checks the release feed for a newer version, unless a check or download is + * already running, in which case the live state is re-emitted so a manual check + * surfaces the progress that is already there instead of restarting it. + * @param isManualCheck Whether the user asked for this check. + */ +export async function checkForUpdates(isManualCheck = false) { + if (simulation) { + emitUpdaterState() + return + } + + if (isUpdaterBusy()) { + if (isManualCheck) emitUpdaterState() + return + } + + if (!app.isPackaged) { + setUpdaterState({ + status: "error", + ...CLEARED_RELEASE_FIELDS, + error: CONSTANTS.updater.devCheckMessage, + }) + + return + } + + setUpdaterState({ + status: "checking", + ...CLEARED_RELEASE_FIELDS, + error: null, + progress: null, + }) + + try { + await autoUpdater.checkForUpdates() + } catch { + // Swallowed on purpose: electron-updater emits `error` before rethrowing, so + // the listener has already recorded it and reacting here would broadcast twice + } +} + +/** + * Downloads the pending installer, only from the `available` state so a second + * call while a download is running is a no-op. + */ +export async function downloadUpdate() { + const state = getUpdaterState() + if (state.status !== "available" || !state.canAutoInstall) return + + setUpdaterState({ + status: "downloading", + progress: { + percent: 0, + transferred: 0, + total: 0, + bytesPerSecond: 0, + }, + }) + + if (simulation) { + runSimulatedDownload() + return + } + + try { + await autoUpdater.downloadUpdate() + } catch { + // Swallowed for the same reason as in `checkForUpdates`, the `error` listener owns it + } +} + +/** + * Quits and installs the staged update, deferred by a tick so the IPC call that + * triggered it can unwind before the app goes away. + */ +export function installUpdate() { + if (getUpdaterState().status !== "downloaded") return + + if (simulation) { + console.log("[updater] simulated install, skipping the real restart") + return + } + + setImmediate(() => autoUpdater.quitAndInstall()) +} + +/** + * Drives a fake updater flow from the `Dev Tools` menu, ignored in packaged + * builds so it can never interfere with a real update. + * @param scenario The flow to simulate. + */ +export function simulateUpdate(scenario: UpdaterSimulation) { + if (app.isPackaged) return + + stopSimulationTimer() + simulation = scenario + + // A fresh simulated version is never treated as already dismissed + clearDismissedVersion() + + setUpdaterState(buildSimulatedState(scenario, new Date().toISOString())) +} + +/** + * Configures the auto-updater to hand every decision to the user, then schedules + * the periodic background checks in packaged builds. + */ +export function initUpdater() { + autoUpdater.autoDownload = false + autoUpdater.autoInstallOnAppQuit = true + + setUpdaterState({ currentVersion: app.getVersion() }) + attachAutoUpdaterEvents() + + if (!app.isPackaged) return + + setTimeout(() => checkForUpdates(), UPDATER_CONFIG.startupCheckDelayMs) + setInterval(() => checkForUpdates(), UPDATER_CONFIG.checkIntervalMs) +} diff --git a/src/main/lib/updater/simulation.ts b/src/main/lib/updater/simulation.ts new file mode 100644 index 0000000..3384764 --- /dev/null +++ b/src/main/lib/updater/simulation.ts @@ -0,0 +1,67 @@ +import UPDATER_CONFIG from "@main/config/updater" +import { CLEARED_RELEASE_FIELDS } from "@main/lib/updater/store" +import type { UpdaterDownloadProgress, UpdaterSimulation, UpdaterState } from "@/main/types/updater" + +/** + * The release notes shown by the simulated flows, standing in for a GitHub release + * body. + */ +const SIMULATED_RELEASE_NOTES = [ + "Added a proper update dialog with download progress and a restart prompt.", + "Fixed renamed files not appearing inside the tree view.", + "Streamed per-file lock progress to the status bar.", +].join("\n") + +/** + * Builds the progress a simulated download reports at a given point, so the bar and + * its byte counters move like a real one. + * @param percent How far along the fake download is. + * @returns The progress snapshot. + */ +export function buildSimulatedProgress(percent: number): UpdaterDownloadProgress { + const total = UPDATER_CONFIG.simulatedDownload.totalBytes + + return { + percent, + transferred: Math.round((total * percent) / 100), + total, + bytesPerSecond: total / 20, + } +} + +/** + * Builds the state a simulated scenario lands on, so the dialog can be driven + * through each of its phases from the `Dev Tools` menu. + * @param scenario The flow to simulate. + * @param releaseDate The timestamp to stamp an available release with. + * @returns The transition to apply. + */ +export function buildSimulatedState(scenario: UpdaterSimulation, releaseDate: string): Partial { + if (scenario === "up-to-date") { + return { + status: "not-available", + ...CLEARED_RELEASE_FIELDS, + progress: null, + error: null, + } + } + + if (scenario === "error") { + return { + status: "error", + ...CLEARED_RELEASE_FIELDS, + progress: null, + error: "net::ERR_INTERNET_DISCONNECTED (simulated)", + } + } + + return { + status: "available", + canAutoInstall: scenario === "download", + version: UPDATER_CONFIG.simulatedDownload.version, + releaseNotes: SIMULATED_RELEASE_NOTES, + releaseDate, + progress: null, + error: null, + } +} diff --git a/src/main/lib/updater/store.ts b/src/main/lib/updater/store.ts new file mode 100644 index 0000000..4cf5b01 --- /dev/null +++ b/src/main/lib/updater/store.ts @@ -0,0 +1,121 @@ +import UPDATER_CONFIG from "@main/config/updater" +import type { UpdaterState } from "@/main/types/updater" + +/** + * The fields describing the pending release, cleared whenever the updater leaves + * that release behind, so a dismissed version cannot leave its notes attached to a + * later result. + */ +export const CLEARED_RELEASE_FIELDS = { + version: null, + releaseNotes: null, + releaseDate: null, +} satisfies Partial + +/** + * Builds the state the updater starts from, before anything has been checked. The + * running version is filled in by `initUpdater`, since this runs while the module + * is still being evaluated and `app` is not available yet. + * @returns The initial updater state. + */ +function createInitialState(): UpdaterState { + return { + status: "idle", + canAutoInstall: UPDATER_CONFIG.autoInstallPlatforms.includes(process.platform), + currentVersion: "", + ...CLEARED_RELEASE_FIELDS, + releaseUrl: UPDATER_CONFIG.releasesUrl, + progress: null, + error: null, + } +} + +/** + * The single source of truth for the updater, owned by the main process so the + * dialog can be closed and reopened without losing where the update got to. + */ +let state: UpdaterState = createInitialState() + +/** + * The subscribers notified on every state transition. + */ +const listeners = new Set<(next: UpdaterState) => void>() + +/** + * The version whose dialog the user closed without acting, so the periodic checks + * stop reopening it. Cleared implicitly once the feed offers a different version. + */ +let dismissedVersion: string | null = null + +/** + * Reads the current updater state. + * @returns The current state. + */ +export function getUpdaterState(): UpdaterState { + return state +} + +/** + * Notifies every subscriber of the current state, without changing it. + */ +export function emitUpdaterState() { + listeners.forEach(listener => { + listener(state) + }) +} + +/** + * Applies a partial transition to the updater state and notifies subscribers. + * @param patch The fields to change. + */ +export function setUpdaterState(patch: Partial) { + state = { ...state, ...patch } + emitUpdaterState() +} + +/** + * Subscribes to updater state transitions. + * @param listener The callback invoked with each new state. + * @returns A function that removes the subscription. + */ +export function subscribeToUpdaterState(listener: (next: UpdaterState) => void): () => void { + listeners.add(listener) + + return () => { + listeners.delete(listener) + } +} + +/** + * Whether a check or a download is already under way, in which case starting + * another one would stack dialogs and race two writers onto the same partial + * installer. + * @returns `true` while the updater is mid-flight. + */ +export function isUpdaterBusy(): boolean { + return state.status === "checking" || state.status === "downloading" || state.status === "downloaded" +} + +/** + * Records the pending version as dismissed, so the periodic checks stop reopening + * the dialog for it. Only meaningful while an update is merely `available`, since a + * download that already ran is surfaced by the status bar instead. + */ +export function dismissAvailableVersion() { + if (state.status === "available" && state.version) dismissedVersion = state.version +} + +/** + * Whether the pending version is one the user already closed the dialog on. + * @returns `true` when the dialog should not be reopened on its own. + */ +export function isAvailableVersionDismissed(): boolean { + return state.version !== null && state.version === dismissedVersion +} + +/** + * Forgets any dismissal, so the next available version prompts again. + */ +export function clearDismissedVersion() { + dismissedVersion = null +} diff --git a/src/main/lib/uproject/handlers.ts b/src/main/lib/uproject/handlers.ts new file mode 100644 index 0000000..4b2c33c --- /dev/null +++ b/src/main/lib/uproject/handlers.ts @@ -0,0 +1,10 @@ +import CONSTANTS from "@main/lib/constants" +import { safeHandle } from "@main/lib/ipc" +import { openUProject } from "@main/lib/uproject/service" + +/** + * Registers the IPC handler that opens a project's `.uproject` file in Unreal Engine. + */ +export function registerUProjectHandlers() { + safeHandle(CONSTANTS.ipc.uprojectOpen, (_event, dir: string) => openUProject(dir)) +} diff --git a/src/main/lib/uproject/service.ts b/src/main/lib/uproject/service.ts new file mode 100644 index 0000000..ab8f8f5 --- /dev/null +++ b/src/main/lib/uproject/service.ts @@ -0,0 +1,82 @@ +import { readdir, readFile } from "node:fs/promises" +import path from "node:path" +import CONSTANTS from "@main/lib/constants" +import { pathExists } from "@main/lib/utils/fs" +import { shell } from "electron" +import type { UProject, UProjectEngineVersion } from "@/main/types/uproject" + +/** + * Parse an `EngineAssociation` string into a `major.minor` pair. Version-style associations + * like `"5.8"` or `"5.4.4"` return the parsed pair, GUID or opaque associations return `null`. + * @param engineAssociation The raw `EngineAssociation` value from a `.uproject` file. + * @returns Parsed major/minor version, or `null` when the string isn't a plain version. + */ +function parseEngineVersion(engineAssociation: string): UProjectEngineVersion | null { + const match = engineAssociation.match(/^(\d+)\.(\d+)/) + if (!match) return null + + return { major: Number(match[1]), minor: Number(match[2]) } +} + +/** + * Scan a directory for the first file with a `.uproject` extension. + * @param dir Absolute path to the directory to scan. + * @returns The absolute path to the `.uproject` file, or `null` when none is found. + */ +async function findUProjectFile(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }) + + for (const entry of entries) { + if (entry.isFile() && entry.name.toLowerCase().endsWith(".uproject")) { + return path.join(dir, entry.name) + } + } + + return null +} + +/** + * Read the `.uproject` file at the given repository root and return its parsed metadata, + * returns `null` when no `.uproject` file exists at the root (the directory is not a UE + * project). + * @param root Absolute path to the repository root. + * @returns Parsed `UProject` metadata, or `null` when no `.uproject` is present. + * @throws When a `.uproject` file exists but its JSON is malformed. + */ +export async function readUProject(root: string): Promise { + const uprojectPath = await findUProjectFile(root) + if (!uprojectPath) return null + + const raw = await readFile(uprojectPath, "utf-8") + const parsed = JSON.parse(raw) as { EngineAssociation?: unknown } + + const engineAssociation = typeof parsed.EngineAssociation === "string" ? parsed.EngineAssociation : "" + const name = path.basename(uprojectPath, path.extname(uprojectPath)) + + return { + path: uprojectPath, + name, + engineAssociation, + engineVersion: parseEngineVersion(engineAssociation), + } +} + +/** + * Hand the project's `.uproject` file to the OS so it opens in whichever Unreal Engine + * build is registered for it. + * @param root Absolute path to the repository root. + * @returns The `UProject` metadata as it was resolved at launch time. + * @throws When the folder is gone, holds no `.uproject` file, or the OS has no handler for it. + */ +export async function openUProject(root: string): Promise { + if (!(await pathExists(root))) throw new Error(CONSTANTS.uproject.missingDirectoryMessage) + + const uproject = await readUProject(root) + if (!uproject) throw new Error(CONSTANTS.uproject.missingFileMessage) + + // Returns an empty string if it worked, or an error message if it didn't + const failure = await shell.openPath(uproject.path) + if (failure) throw new Error(`${CONSTANTS.uproject.openFailureMessage}\n\n${failure}`) + + return uproject +} diff --git a/src/main/lib/utils/async.ts b/src/main/lib/utils/async.ts new file mode 100644 index 0000000..7bc5c2a --- /dev/null +++ b/src/main/lib/utils/async.ts @@ -0,0 +1,35 @@ +/** + * Runs `worker` over every item with at most `limit` in flight at once, + * preserving input order in the returned array. + * @param items The items to process. + * @param limit The maximum number of concurrent worker calls. + * @param worker The per-item async worker. + * @returns The per-item results. + */ +export async function parallelLimit( + items: T[], + limit: number, + worker: (item: T, index: number) => Promise, +): Promise { + const results: R[] = new Array(items.length) + let cursor = 0 + + /** + * Runs the worker on the next item in the queue. + * @returns A promise that resolves when the worker is done. + */ + const run = async (): Promise => { + while (true) { + const currentIndex = cursor + cursor += 1 + + if (currentIndex >= items.length) return + results[currentIndex] = await worker(items[currentIndex], currentIndex) + } + } + + const workers = Array.from({ length: Math.min(limit, items.length) }, run) + await Promise.all(workers) + + return results +} diff --git a/src/main/lib/utils/errors.ts b/src/main/lib/utils/errors.ts new file mode 100644 index 0000000..278f4a3 --- /dev/null +++ b/src/main/lib/utils/errors.ts @@ -0,0 +1,9 @@ +/** + * Reduces an unknown thrown value to a displayable message, so callers can pass a + * raw `catch` binding without narrowing it first. + * @param error The thrown value, typically the argument of a `catch` clause. + * @returns The message to surface. + */ +export function convertErrorToMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/src/main/lib/windows/index.ts b/src/main/lib/windows/index.ts index 8184651..622ad2f 100644 --- a/src/main/lib/windows/index.ts +++ b/src/main/lib/windows/index.ts @@ -2,6 +2,31 @@ import CONSTANTS from "@main/lib/constants" import type { WindowState } from "@preload/index" import { BrowserWindow, ipcMain } from "electron" +/** + * The main application window, tracked so app-global features (such as the + * updater) can parent their own windows to it. + */ +let mainWindow: BrowserWindow | null = null + +/** + * Registers the main application window and forgets it once it is closed. + * @param window The main `BrowserWindow` instance. + */ +export function setMainWindow(window: BrowserWindow) { + mainWindow = window + window.on("closed", () => { + if (mainWindow === window) mainWindow = null + }) +} + +/** + * Reads the main application window. + * @returns The main window, or `null` when it is closed. + */ +export function getMainWindow(): BrowserWindow | null { + return mainWindow && !mainWindow.isDestroyed() ? mainWindow : null +} + /** * Reads the current state of a `BrowserWindow`. * @param window The `BrowserWindow` instance to read the state from. diff --git a/src/main/types/dialogs.d.ts b/src/main/types/dialogs.d.ts index 59d37bd..5c38f1c 100644 --- a/src/main/types/dialogs.d.ts +++ b/src/main/types/dialogs.d.ts @@ -1,8 +1,7 @@ /** - * The kind of dialog window to render: a two-button confirmation or a - * single-button error notice. + * The kind of dialog window to render. */ -export type DialogVariant = "confirm" | "error" +export type DialogVariant = "confirm" | "message" | "error" | "error-with-details" | "update" /** * The options for a confirmation dialog. @@ -10,15 +9,27 @@ export type DialogVariant = "confirm" | "error" export type ConfirmDialogOptions = { title: string message: string - detail?: string + details?: string confirmLabel?: string cancelLabel?: string isDestructive?: boolean } /** - * The full option set passed to the dialog window renderer, including its variant. + * The options for the update dialog, whose body is driven by the updater state + * rather than by the caller. */ -export type DialogOptions = ConfirmDialogOptions & { - variant: DialogVariant +export type UpdateDialogOptions = { + title: string } + +/** + * The full option set passed to the dialog window renderer, including its variant. + */ +export type DialogOptions = + | ({ + variant: "confirm" | "message" | "error" | "error-with-details" + } & ConfirmDialogOptions) + | ({ + variant: "update" + } & UpdateDialogOptions) diff --git a/src/main/types/projects.d.ts b/src/main/types/projects.d.ts index 6480ac7..cac701a 100644 --- a/src/main/types/projects.d.ts +++ b/src/main/types/projects.d.ts @@ -1,3 +1,5 @@ +import type { UProject } from "@/main/types/uproject" + /** * A project definition. */ @@ -5,6 +7,7 @@ export type Project = { path: string name: string lastOpenedAt: string + uproject: UProject } /** @@ -12,8 +15,9 @@ export type Project = { * - `cancelled`: the user dismissed the folder picker. * - `not-found`: the path no longer exists on disk. * - `not-a-repository`: the path exists but is not inside a Git repository. + * - `not-a-ue-project`: the repository has no `.uproject` file at its root. */ -export type OpenProjectFailureReason = "cancelled" | "not-found" | "not-a-repository" +export type OpenProjectFailureReason = "cancelled" | "not-found" | "not-a-repository" | "not-a-ue-project" /** * The outcome of attempting to open or add a project. diff --git a/src/main/types/uassets.d.ts b/src/main/types/uassets.d.ts deleted file mode 100644 index e0fd1ff..0000000 --- a/src/main/types/uassets.d.ts +++ /dev/null @@ -1,658 +0,0 @@ -/** - * Numeric value + engine's inline doc comment for a single enum entry. - * */ -export type UAssetEnumEntry = { - value: number - comment: string -} - -/** - * One entry from the summary's custom version container: subsystem GUID + version index. - */ -export interface UAssetCustomVersion { - /** - * 32-char upper-hex GUID identifying the subsystem (Blueprints, Framework, Niagara, ...). - */ - key: string - - /** - * Subsystem-specific version index the package was saved with. - */ - version: number -} - -/** - * Decoded `FEngineVersion` (semantic version + branch string). - */ -export interface UAssetEngineVersion { - /** - * Major version number, e.g. `5` for UE 5.x. - */ - major: number - - /** - * Minor version number, e.g. `8` for UE 5.8. - */ - minor: number - - /** - * Patch/hotfix version number. - */ - patch: number - - /** - * Perforce/git changelist number the engine was built at. - */ - changelist: number - - /** - * Release branch name, e.g. `++UE5+Release-5.8`. - */ - branch: string -} - -/** - * One entry in the summary's generation list: counts snapshot at a prior save. - */ -export interface UAssetGenerationInfo { - /** - * Number of exports the package had at this generation. - */ - exportCount: number - - /** - * Number of names the package had at this generation. - */ - nameCount: number -} - -/** - * Decoded `FPackageFileSummary`, fields not useful to Layer 1 extraction are still advanced past - * (so the reader lands at the correct offset) but exposed as-is here for downstream inspection, - * offsets serialized as `int64` are surfaced as `bigint`, the rest are `int32`, sections that - * don't exist in the file typically use `count = 0` and `offset = -1`. - * @see /Engine/Source/Runtime/CoreUObject/Public/UObject/PackageFileSummary.h - */ -export interface UAssetPackageFileSummary { - /** - * Magic number identifying the file as a UE package, matches `PACKAGE_FILE_TAG` after - * any endian swap has been applied by the reader. - */ - tag: number - - /** - * Summary format revision, always negative for modern packages, `-9` at the time of writing. - */ - legacyFileVersion: number - - /** - * Vestigial UE3 engine version field, kept in the wire format for backward compat, unused today. - */ - legacyUE3Version: number - - /** - * UE4 object format version the package was saved with, compare against `UAssetObjectVersionUE4`. - */ - fileVersionUE4: number - - /** - * UE5 object format version (0 for packages saved before UE5), compare against `UAssetObjectVersionUE5`. - */ - fileVersionUE5: number - - /** - * Custom licensee-specific format version, typically 0 for stock Epic builds. - */ - fileVersionLicenseeUE: number - - /** - * Per-subsystem version table (Blueprint, Niagara, Framework, ...) the package was saved against. - */ - customVersions: UAssetCustomVersion[] - - /** - * Hex-encoded `FIoHash` (post-`PACKAGE_SAVED_HASH`) or legacy `FGuid` identifying the saved bytes. - */ - savedHash: string - - /** - * Total on-disk size of the summary plus name table plus import & export maps combined. - */ - totalHeaderSize: number - - /** - * Original package path at last save, e.g. `/Game/Characters/BP_Enemy`. - */ - packageName: string - - /** - * `EPackageFlags` bitmask, test against entries in `UAssetPackageFlags`. - */ - packageFlags: number - - /** - * Number of entries in the name table. - */ - nameCount: number - - /** - * Byte offset from the start of the file to the name table. - */ - nameOffset: number - - /** - * Number of soft object path entries (present when `ADD_SOFTOBJECTPATH_LIST` or newer). - */ - softObjectPathsCount: number - - /** - * Byte offset to the soft object path list, `-1` when the section is absent. - */ - softObjectPathsOffset: number - - /** - * Editor-only localization identifier for this package. - */ - localizationId: string - - /** - * Number of gatherable localizable text fragments in this package. - */ - gatherableTextDataCount: number - - /** - * Byte offset to the gatherable text data section, `-1` when absent. - */ - gatherableTextDataOffset: number - - /** - * Number of objects defined inside this package. - */ - exportCount: number - - /** - * Byte offset to the export map (list of `FObjectExport` entries). - */ - exportOffset: number - - /** - * Number of external objects this package references. - */ - importCount: number - - /** - * Byte offset to the import map (list of `FObjectImport` entries). - */ - importOffset: number - - /** - * Number of Verse cell exports (present when `VERSE_CELLS` or newer). - */ - cellExportCount: number - - /** - * Byte offset to the cell export map, `-1` when absent. - */ - cellExportOffset: number - - /** - * Number of Verse cell imports. - */ - cellImportCount: number - - /** - * Byte offset to the cell import map, `-1` when absent. - */ - cellImportOffset: number - - /** - * Byte offset to the metadata section (present when `METADATA_SERIALIZATION_OFFSET` or newer). - */ - metaDataOffset: number - - /** - * Byte offset to the export dependency table (per-export import indices). - */ - dependsOffset: number - - /** - * Number of soft package references stored in this package. - */ - softPackageReferencesCount: number - - /** - * Byte offset to the soft package references section, `-1` when absent. - */ - softPackageReferencesOffset: number - - /** - * Byte offset to the searchable names map, `-1` when absent. - */ - searchableNamesOffset: number - - /** - * Byte offset to the editor thumbnail table, `-1` in cooked packages. - */ - thumbnailTableOffset: number - - /** - * Number of import type hierarchy entries (present when `IMPORT_TYPE_HIERARCHIES` or newer). - */ - importTypeHierarchiesCount: number - - /** - * Byte offset to the import type hierarchy map, `-1` when absent. - */ - importTypeHierarchiesOffset: number - - /** - * Editor-only stable identity for this package, empty when the file was saved without editor data. - */ - persistentGuid: string - - /** - * Snapshot counts for each prior save of this package, latest last. - */ - generations: UAssetGenerationInfo[] - - /** - * Engine build that wrote this file to disk. - */ - savedByEngineVersion: UAssetEngineVersion - - /** - * Oldest engine build that can still load this file (matters for hotfix releases). - */ - compatibleWithEngineVersion: UAssetEngineVersion - - /** - * Legacy package-level compression flags, should be `0` for source (editor-side) `.uasset` files. - */ - compressionFlags: number - - /** - * Value UE uses to distinguish official saves from modder-built packages. - */ - packageSource: number - - /** - * Byte offset to the asset registry tag data (thumbnails, class info for the editor browser). - */ - assetRegistryDataOffset: number - - /** - * Byte offset where bulk data (mip levels, sound waves, geometry buffers) begins, `int64`. - */ - bulkDataStartOffset: bigint - - /** - * Byte offset to `FWorldTileInfo`, `-1` for non-map assets. - */ - worldTileInfoDataOffset: number - - /** - * Streaming-install chunk IDs this package is assigned to. - */ - chunkIds: number[] - - /** - * Number of preload dependency graph entries (cooked-only, `-1` on editor-side files). - */ - preloadDependencyCount: number - - /** - * Byte offset to the preload dependency graph. - */ - preloadDependencyOffset: number - - /** - * Number of leading name entries actually referenced by serialized export data - * (present when `NAMES_REFERENCED_FROM_EXPORT_DATA` or newer, defaults to `nameCount`). - */ - namesReferencedFromExportDataCount: number - - /** - * Byte offset to the payload table of contents (present when `PAYLOAD_TOC` or newer), `-1` when absent, `int64`. - */ - payloadTocOffset: bigint - - /** - * Byte offset to the bulk/data resource table (present when `DATA_RESOURCES` or newer), `-1` when absent. - */ - dataResourceOffset: number -} - -/** - * Decoded `FObjectImport`, one entry in the import map, represents an external object this - * package references, FName fields are pre-resolved to their display strings using the name - * table, `FPackageIndex` fields (outerIndex) are kept as raw signed integers: `0` means null, - * positive `N` points at export `N - 1`, negative `-N` points at import `N - 1`. - * @see /Engine/Source/Runtime/CoreUObject/Public/UObject/ObjectResource.h `FObjectImport` - */ -export interface UAssetImport { - /** - * Class's outer package, e.g. `"/Script/CoreUObject"` or `"/Script/Engine"`. - */ - classPackage: string - - /** - * Class type of the imported object, e.g. `"Class"`, `"Function"`, `"BlueprintGeneratedClass"`, `"Texture2D"`. - */ - className: string - - /** - * `FPackageIndex` of this import's outer (containing) object, or `0` for a top-level import. - */ - outerIndex: number - - /** - * Name of the imported object itself, e.g. `"BP_Enemy_C"` or `"T_Rock_Diffuse"`. - */ - objectName: string - - /** - * Containing package path of this import (post-`VER_UE4_NON_OUTER_PACKAGE_IMPORT`), empty - * string on older packages that inferred the package from the outer chain instead. - */ - packageName: string - - /** - * True when this import is optional and may not be present at load time - * (post-`OPTIONAL_RESOURCES`). - */ - bImportOptional: boolean -} - -/** - * Decoded `FObjectExport`, one entry in the export map, represents an object defined inside - * this package, FName fields are pre-resolved to strings, `FPackageIndex` fields are raw - * signed integers with the same convention as `UAssetImport.outerIndex`. - * @see /Engine/Source/Runtime/CoreUObject/Public/UObject/ObjectResource.h `FObjectExport` - */ -export interface UAssetExport { - /** - * Package index of the object's class (typically a negative value pointing at an import). - */ - classIndex: number - - /** - * Package index of the object's parent (super) class, or `0` when none. - */ - superIndex: number - - /** - * Package index of the object's archetype template, `0` when pre-`VER_UE4_TemplateIndex_IN_COOKED_EXPORTS`. - */ - templateIndex: number - - /** - * Package index of the object's outer (containing) object, `0` when this is a top-level export. - */ - outerIndex: number - - /** - * Name of the exported object itself. - */ - objectName: string - - /** - * `EObjectFlags` bitmask (masked to `RF_Load` on save). - */ - objectFlags: number - - /** - * Serialized byte size of this export's property data, `int64` on modern packages, - * `int32` pre-`VER_UE4_64BIT_EXPORTMAP_SERIALSIZES`. - */ - serialSize: bigint - - /** - * Byte offset to this export's serialized property data, absolute from file start. - */ - serialOffset: bigint - - /** - * True if this export was force-included by the cooker. - */ - bForcedExport: boolean - - /** - * True if this export is stripped in client-only builds. - */ - bNotForClient: boolean - - /** - * True if this export is stripped in server-only builds. - */ - bNotForServer: boolean - - /** - * True when the export was inherited from a parent generation (post-`TRACK_OBJECT_EXPORT_IS_INHERITED`). - */ - bIsInheritedInstance: boolean - - /** - * `EPackageFlags` bitmask associated with this specific export (usually mirrors summary flags). - */ - packageFlags: number - - /** - * True when the export should not always be loaded for editor games (post-`VER_UE4_LOAD_FOR_EDITOR_GAME`). - */ - bNotAlwaysLoadedForEditorGame: boolean - - /** - * True when this export represents a top-level asset (post-`VER_UE4_COOKED_ASSETS_IN_EDITOR_SUPPORT`). - */ - bIsAsset: boolean - - /** - * True when a public hash should be generated for this export (post-`OPTIONAL_RESOURCES`). - */ - bGeneratePublicHash: boolean - - /** - * Starting index into the preload dependency array - * (post-`VER_UE4_PRELOAD_DEPENDENCIES_IN_COOKED_EXPORTS`). - */ - firstExportDependency: number - - /** - * Number of "serialize before serialize" preload dependencies. - */ - serializationBeforeSerializationDependencies: number - - /** - * Number of "create before serialize" preload dependencies. - */ - createBeforeSerializationDependencies: number - - /** - * Number of "serialize before create" preload dependencies. - */ - serializationBeforeCreateDependencies: number - - /** - * Number of "create before create" preload dependencies. - */ - createBeforeCreateDependencies: number - - /** - * Start of the script serialization block (post-`SCRIPT_SERIALIZATION_OFFSET`, - * absent when using unversioned property serialization). - */ - scriptSerializationStartOffset: bigint - - /** - * End of the script serialization block. - */ - scriptSerializationEndOffset: bigint -} - -/** - * Header data for one tagged property inside an export's property stream, the value payload - * itself is not decoded here, use `valueOffset` + `size` to locate the raw bytes when a - * downstream module needs them (a type-specific extractor, a diff tool, etc.). - * @see /Engine/Source/Runtime/CoreUObject/Private/UObject/PropertyTag.cpp `operator<<(FStructuredArchive::FSlot, FPropertyTag&)` - */ -export interface UAssetPropertyTag { - /** - * Property name, e.g. `"Damage"` or `"MeshComponent"`. - */ - name: string - - /** - * Rendered property type, simple types read as the class name (`"IntProperty"`, - * `"NameProperty"`), containers include their inner type (`"StructProperty(Vector)"`, - * `"ArrayProperty(StructProperty(Vector))"`, `"MapProperty(NameProperty,ObjectProperty)"`). - */ - typeName: string - - /** - * Serialized byte size of the value payload that follows the header, `0` for skipped tags - * and `BoolProperty` tags whose value is baked into the flag bits. - */ - size: number - - /** - * Position within a static array, `0` for scalar (non-array) properties. - */ - arrayIndex: number - - /** - * True when the tag represents a `BoolProperty` set to `true`, false encodes both `false` - * booleans and non-boolean tags. - */ - boolValue: boolean - - /** - * True when the tag was marked as skipped during save, the value payload is empty. - */ - skipped: boolean - - /** - * True when the value payload uses the property's binary/native `Serialize` method instead - * of the property system's default serialization. - */ - binaryOrNativeSerialize: boolean - - /** - * Optional per-property GUID (used by properties that migrate their name across engine - * versions), empty string when not present. - */ - propertyGuid: string - - /** - * Byte offset (absolute from file start) to the start of the value payload. - */ - valueOffset: bigint -} - -/** - * Full Layer 1 decode of a `.uasset` file: header summary, resolved name table, import map, - * and export map. - */ -export interface UAssetPackage { - /** - * Decoded `FPackageFileSummary` at the top of the file. - */ - summary: UAssetPackageFileSummary - - /** - * Name table indexed by FName index. - */ - names: string[] - - /** - * External object references (`FObjectImport` entries) with FNames pre-resolved. - */ - imports: UAssetImport[] - - /** - * Objects defined inside this package (`FObjectExport` entries) with FNames pre-resolved. - */ - exports: UAssetExport[] -} - -/** - * The "main" asset of a package: the top-level export a `.uasset` was created to hold - * (a Blueprint, a Material, a Texture2D, etc.). - */ -export interface UAssetMainAsset { - /** - * Zero-based index of the main export in `pkg.exports`. - */ - index: number - - /** - * The main export entry itself. - */ - export: UAssetExport - - /** - * Resolved class name, e.g. `"Blueprint"`, `"Material"`, `"Texture2D"`, `"AnimSequence"`. - */ - className: string - - /** - * UE package path of the class, e.g. `"/Script/Engine"` or `"/Script/CoreUObject"`. - */ - classPackage: string -} - -/** - * One Blueprint SCS (Simple Construction Script) component template, extracted from an export - * whose name ends in `_GEN_VARIABLE`. - */ -export interface UAssetBlueprintComponent { - /** - * Variable name assigned in the editor, e.g. `"MeshComponent"` or `"Arrow"`. - */ - name: string - - /** - * Component class type, e.g. `"StaticMeshComponent"` or `"ArrowComponent"`. - */ - className: string -} - -/** - * Shaper output for a `UBlueprint` asset, the Layer 2 view a commit-message generator or diff - * tool consumes for Blueprint packages. - */ -export interface UAssetBlueprintShape { - /** - * Discriminator tag, always `"blueprint"` for this shape. - */ - kind: "blueprint" - - /** - * Blueprint asset name, e.g. `"BP_Enemy"`. - */ - name: string - - /** - * Class the Blueprint inherits from, e.g. `"Actor"` or `"Character"`, empty when unresolved. - */ - parentClass: string - - /** - * SCS component templates declared on the Blueprint. - */ - components: UAssetBlueprintComponent[] - - /** - * Names of user-defined function graphs (exports whose class resolves to `"Function"`). - */ - functions: string[] - - /** - * `/Game/`-rooted asset paths referenced by this Blueprint's import table. - */ - referencedAssets: string[] - - /** - * Compact one-line summary suitable for feeding into an AI prompt. - */ - renderedSummary: string -} diff --git a/src/main/types/updater.d.ts b/src/main/types/updater.d.ts new file mode 100644 index 0000000..30e0982 --- /dev/null +++ b/src/main/types/updater.d.ts @@ -0,0 +1,42 @@ +/** + * The lifecycle phase of the auto-updater: + * - `idle`: nothing has been checked yet, or the last result was dismissed. + * - `checking`: a check against the release feed is in flight. + * - `not-available`: the running version is the latest one published. + * - `available`: a newer version was found and is waiting on the user. + * - `downloading`: the installer is being fetched. + * - `downloaded`: the installer is staged and waiting for a restart. + * - `error`: the last check or download failed. + */ +export type UpdaterStatus = "idle" | "checking" | "not-available" | "available" | "downloading" | "downloaded" | "error" + +/** + * A snapshot of the installer download, mirroring electron-updater's `ProgressInfo`. + */ +export type UpdaterDownloadProgress = { + percent: number + transferred: number + total: number + bytesPerSecond: number +} + +/** + * The full updater state, broadcast to every open window on each transition. + */ +export type UpdaterState = { + status: UpdaterStatus + canAutoInstall: boolean // Only true on Windows + currentVersion: string + version: string | null + releaseNotes: string | null + releaseDate: string | null + releaseUrl: string + progress: UpdaterDownloadProgress | null + error: string | null +} + +/** + * The fake updater flows that the `Dev Tools` menu can drive, so the dialog can + * be previewed without a packaged build behind a real release feed. + */ +export type UpdaterSimulation = "download" | "link-only" | "up-to-date" | "error" diff --git a/src/main/types/uproject.d.ts b/src/main/types/uproject.d.ts new file mode 100644 index 0000000..9e2cb2e --- /dev/null +++ b/src/main/types/uproject.d.ts @@ -0,0 +1,34 @@ +/** + * Parsed major/minor version pair extracted from `EngineAssociation`, `null` when the + * association is a GUID (custom engine build) or an opaque identifier we can't parse. + */ +export interface UProjectEngineVersion { + major: number + minor: number +} + +/** + * Metadata read from a `.uproject` file at the root of a UE project's git repo. + */ +export interface UProject { + /** + * Absolute path to the `.uproject` file on disk. + */ + path: string + + /** + * Project short name, the `.uproject` filename without its extension. + */ + name: string + + /** + * Raw `EngineAssociation` string as it appears in the `.uproject` JSON, may be a version + * (`"5.8"`), a full version (`"5.4.4"`), or a GUID for custom engine builds. + */ + engineAssociation: string + + /** + * Parsed major/minor version, `null` when `engineAssociation` is a GUID/opaque identifier. + */ + engineVersion: UProjectEngineVersion | null +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 3e54dc0..ca5de60 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,10 +1,13 @@ import appApiRoutes from "@preload/routes/app" +import audioApiRoutes from "@preload/routes/audio" import dialogsApiRoutes from "@preload/routes/dialogs" import fileTreeApiRoutes from "@preload/routes/fileTree" import gitCommandsApiRoutes from "@preload/routes/gitCommands" import lfsCommandsApiRoutes from "@preload/routes/lfsCommands" import projectsApiRoutes from "@preload/routes/projects" import shellsApiRoutes from "@preload/routes/shells" +import updaterApiRoutes from "@preload/routes/updater" +import uprojectApiRoutes from "@preload/routes/uproject" import windowsApiRoutes from "@preload/routes/windows" import { contextBridge } from "electron" import type { ConfirmDialogOptions, DialogOptions } from "@/main/types/dialogs" @@ -13,6 +16,8 @@ import type { GitBranch, GitCommit, GitStatus } from "@/main/types/gitCommands" import type { LfsLock, LfsLockMigration, LfsLockResult } from "@/main/types/lfsCommands" import type { OpenProjectResult, Project } from "@/main/types/projects" import type { AppPreferences } from "@/main/types/store" +import type { UpdaterSimulation, UpdaterState } from "@/main/types/updater" +import type { UProject } from "@/main/types/uproject" /** * A snapshot of the current state of the application window. @@ -38,9 +43,14 @@ export type GitgameApi = { app: { version: string } + audio: { + playError: () => void + } dialogs: { confirm: (options: ConfirmDialogOptions) => Promise - error: (title: string, message: string, detail?: string) => void + message: (title: string, message: string) => void + error: (title: string, message: string) => void + errorWithDetails: (title: string, message: string, details: string) => void getOptions: () => Promise respond: (result: boolean) => void } @@ -83,6 +93,20 @@ export type GitgameApi = { } shells: { openExternal: (url: string) => void + showFolder: (dir: string) => Promise + openTerminal: (dir: string) => Promise + } + updater: { + getState: () => Promise + onStateChange: (callback: (state: UpdaterState) => void) => () => void + check: (isManualCheck: boolean) => Promise + download: () => Promise + install: () => void + openDialog: () => void + simulate: (scenario: UpdaterSimulation) => void + } + uproject: { + open: (dir: string) => Promise } windows: { getState: () => Promise @@ -104,12 +128,15 @@ const api: GitgameApi = { isMacOS: process.platform === "darwin", }, app: appApiRoutes, + audio: audioApiRoutes, dialogs: dialogsApiRoutes, fileTree: fileTreeApiRoutes, gitCommands: gitCommandsApiRoutes, lfsCommands: lfsCommandsApiRoutes, projects: projectsApiRoutes, shells: shellsApiRoutes, + updater: updaterApiRoutes, + uproject: uprojectApiRoutes, windows: windowsApiRoutes, } as const diff --git a/src/preload/routes/audio.ts b/src/preload/routes/audio.ts new file mode 100644 index 0000000..53d0876 --- /dev/null +++ b/src/preload/routes/audio.ts @@ -0,0 +1,12 @@ +import type { GitgameApi } from "@preload/index" +import { getErrorAudio } from "@preload/lib/audio" + +const audioApiRoutes: GitgameApi["audio"] = { + playError: () => { + const audio = getErrorAudio() + audio.currentTime = 0 + audio.play().catch(() => null) + }, +} + +export default audioApiRoutes diff --git a/src/preload/routes/dialogs.ts b/src/preload/routes/dialogs.ts index b0432a5..2146416 100644 --- a/src/preload/routes/dialogs.ts +++ b/src/preload/routes/dialogs.ts @@ -1,17 +1,13 @@ import CONSTANTS from "@main/lib/constants" import type { GitgameApi } from "@preload/index" -import { getErrorAudio } from "@preload/lib/audio" import { ipcRenderer } from "electron" const dialogsApiRoutes: GitgameApi["dialogs"] = { confirm: options => ipcRenderer.invoke(CONSTANTS.ipc.dialogsConfirm, options), - error: (title, message, detail) => { - const audio = getErrorAudio() - audio.currentTime = 0 - audio.play().catch(() => null) - - ipcRenderer.send(CONSTANTS.ipc.dialogsError, title, message, detail) - }, + message: (title, message) => ipcRenderer.send(CONSTANTS.ipc.dialogsMessage, title, message), + error: (title, message) => ipcRenderer.send(CONSTANTS.ipc.dialogsError, title, message), + errorWithDetails: (title, message, details) => + ipcRenderer.send(CONSTANTS.ipc.dialogsErrorWithDetails, title, message, details), getOptions: () => ipcRenderer.invoke(CONSTANTS.ipc.dialogsGetOptions), respond: result => ipcRenderer.send(CONSTANTS.ipc.dialogsRespond, result), } diff --git a/src/preload/routes/shells.ts b/src/preload/routes/shells.ts index 5c8a58b..f67c8a0 100644 --- a/src/preload/routes/shells.ts +++ b/src/preload/routes/shells.ts @@ -1,9 +1,12 @@ import CONSTANTS from "@main/lib/constants" import type { GitgameApi } from "@preload/index" +import { safeInvoke } from "@preload/lib/ipc" import { ipcRenderer } from "electron" const shellsApiRoutes: GitgameApi["shells"] = { openExternal: url => ipcRenderer.send(CONSTANTS.ipc.shellsOpenExternal, url), + showFolder: dir => safeInvoke(CONSTANTS.ipc.shellsShowFolder, dir), + openTerminal: dir => safeInvoke(CONSTANTS.ipc.shellsOpenTerminal, dir), } export default shellsApiRoutes diff --git a/src/preload/routes/updater.ts b/src/preload/routes/updater.ts new file mode 100644 index 0000000..2089936 --- /dev/null +++ b/src/preload/routes/updater.ts @@ -0,0 +1,23 @@ +import CONSTANTS from "@main/lib/constants" +import type { GitgameApi } from "@preload/index" +import { safeInvoke } from "@preload/lib/ipc" +import { ipcRenderer } from "electron" +import type { UpdaterState } from "@/main/types/updater" + +const updaterApiRoutes: GitgameApi["updater"] = { + getState: () => safeInvoke(CONSTANTS.ipc.updaterGetState), + onStateChange: callback => { + const listener = (_: unknown, state: UpdaterState) => callback(state) + + ipcRenderer.on(CONSTANTS.ipc.updaterStateChanged, listener) + + return () => ipcRenderer.off(CONSTANTS.ipc.updaterStateChanged, listener) + }, + check: isManualCheck => safeInvoke(CONSTANTS.ipc.updaterCheck, isManualCheck), + download: () => safeInvoke(CONSTANTS.ipc.updaterDownload), + install: () => ipcRenderer.send(CONSTANTS.ipc.updaterInstall), + openDialog: () => ipcRenderer.send(CONSTANTS.ipc.updaterOpenDialog), + simulate: scenario => ipcRenderer.send(CONSTANTS.ipc.updaterSimulate, scenario), +} + +export default updaterApiRoutes diff --git a/src/preload/routes/uproject.ts b/src/preload/routes/uproject.ts new file mode 100644 index 0000000..7263f26 --- /dev/null +++ b/src/preload/routes/uproject.ts @@ -0,0 +1,10 @@ +import CONSTANTS from "@main/lib/constants" +import type { GitgameApi } from "@preload/index" +import { safeInvoke } from "@preload/lib/ipc" +import type { UProject } from "@/main/types/uproject" + +const uprojectApiRoutes: GitgameApi["uproject"] = { + open: dir => safeInvoke(CONSTANTS.ipc.uprojectOpen, dir), +} + +export default uprojectApiRoutes diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index b22654c..2a5e13c 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -2,24 +2,29 @@ import computerIcon from "@react95-icons/Computer3_16x16_4.png" import FileTreeProvider from "@renderer/components/contexts/FileTree" import ProjectProvider, { useProjectContext } from "@renderer/components/contexts/Project" import StatusProvider from "@renderer/components/contexts/Status" -import TreeViewProvider from "@renderer/components/contexts/TreeView" +import TreeViewProvider, { useTreeViewContext } from "@renderer/components/contexts/TreeView" +import useMenuActions from "@renderer/hooks/useMenuActions" import useMenuShortcuts from "@renderer/hooks/useMenuShortcuts" -import { useCallback, useEffect, useMemo } from "react" +import useUpdaterStatusTask from "@renderer/hooks/useUpdaterStatusTask" +import { useEffect, useMemo } from "react" import MenuBar from "@/renderer/components/bars/Menu" import StatusBar from "@/renderer/components/bars/Status" import TitleBar from "@/renderer/components/bars/Title" import StatusBarField from "@/renderer/components/fields/StatusBar" import StatusTaskField from "@/renderer/components/fields/StatusTask" +import StatusUpdateField from "@/renderer/components/fields/StatusUpdate" import AppRoot from "@/renderer/components/layouts/AppRoot" import MainLayout from "@/renderer/components/layouts/main" import Workspace from "@/renderer/components/spaces/Workspace" +import Welcome from "@/renderer/components/views/Welcome" import APP_CONFIG from "@/renderer/config/app" -import { buildTopLevelMenus, type MenuAction } from "@/renderer/config/menus" +import { buildTopLevelMenus } from "@/renderer/config/menus" import { toBrowsableRemoteUrl } from "@/renderer/lib/utils/git" function AppShell() { - const { currentProject, recentProjects, remoteUrl, addLocalProject, openProject, clearRecentProjects } = - useProjectContext() + const { isRegex, isAdvancedOpen, isShowingMyLocksOnly } = useTreeViewContext() + const { currentProject, recentProjects, remoteUrl } = useProjectContext() + const { handleMenuAction } = useMenuActions() /** * The browsable HTTPS URL of the current project's `origin` remote, or @@ -36,80 +41,25 @@ function AppShell() { }, [currentProject]) /** - * The menus of the application, rebuilt when the recent projects change. + * The menus of the application, rebuilt when the recent projects or any + * checkable toggle state changes so the check indicators stay in sync. */ const menus = useMemo( - () => buildTopLevelMenus(recentProjects, currentProject, remoteBrowsableUrl), - [recentProjects, currentProject, remoteBrowsableUrl], - ) - - /** - * Dispatches a menu action to the matching application state handler. - * @param action The action selected in the menu bar. - */ - const handleMenuAction = useCallback( - async (action: MenuAction) => { - switch (action.type) { - case "project:add-local": - addLocalProject() - break - case "project:open": - openProject(action.path) - break - case "project:clear-recent": { - const confirmed = await window.api.dialogs.confirm({ - title: "Clear recent projects", - message: "Clear the entire recent projects list?", - detail: "This only forgets the entries here, your project folders on disk are left untouched.", - confirmLabel: "Clear", - isDestructive: true, - }) - - if (confirmed) clearRecentProjects() - break - } - case "window:close": - window.api.windows.close() - break - case "view:reload": - window.location.reload() - break - case "shell:open-external": - window.api.shells.openExternal(action.url) - break - case "devtools:test-confirm": - window.api.dialogs.confirm({ - title: "Test confirm", - message: "This is a test confirm dialog.", - detail: "Use it to preview the Win95 confirm styling from the Dev Tools menu.", - confirmLabel: "Sure", - cancelLabel: "Nope", - }) - break - case "devtools:test-error": - window.api.dialogs.error("Test error", "This is a test error message.") - break - case "devtools:test-error-with-detail": - window.api.dialogs.error( - "Test error with detail", - "5 files could not be updated.", - [ - "Content/Characters/Hero/BP_Hero.uasset: locked by john", - "Content/Characters/Hero/SK_Hero.uasset: locked by jane", - "Content/Maps/MainMenu.umap: locked by bob", - "Content/UI/HUD/WBP_HUD.uasset: locked by alice", - "Content/VFX/P_Explosion.uasset", - ].join("\n"), - ) - break - } - }, - [addLocalProject, openProject, clearRecentProjects], + () => + buildTopLevelMenus(recentProjects, currentProject, remoteBrowsableUrl, { + isRegex, + isAdvancedOpen, + isShowingMyLocksOnly, + }), + [recentProjects, currentProject, remoteBrowsableUrl, isRegex, isAdvancedOpen, isShowingMyLocksOnly], ) // Bind the menu accelerators (Ctrl+O, Ctrl+Q, ...) to their actions useMenuShortcuts(menus, handleMenuAction) + // Surface a running update download in the status bar + useUpdaterStatusTask() + // Keep the OS window/taskbar caption in sync with the visible title bar useEffect(() => { document.title = windowTitle @@ -121,10 +71,11 @@ function AppShell() { -
{currentProject && }
+
{currentProject ? : }
{currentProject ? currentProject.path : "No project open"} + diff --git a/src/renderer/DialogApp.tsx b/src/renderer/DialogApp.tsx index 105d7b6..126a274 100644 --- a/src/renderer/DialogApp.tsx +++ b/src/renderer/DialogApp.tsx @@ -1,86 +1,32 @@ -import errorIcon from "@react95-icons/Hand_32x32_4.png" import AppRoot from "@renderer/components/layouts/AppRoot" import MainLayout from "@renderer/components/layouts/main" -import { useCallback, useEffect, useState } from "react" -import { Button, Frame } from "react95" +import { useEffect, useState } from "react" import type { DialogOptions } from "@/main/types/dialogs" import TitleBar from "@/renderer/components/bars/Title" +import MessageDialog from "@/renderer/components/dialogs/Message" +import UpdateDialog from "@/renderer/components/dialogs/Update" export default function DialogApp() { const [options, setOptions] = useState(null) - /** - * Responds to the dialog with a confirmation, closing the window. - */ - const handleConfirm = useCallback(() => { - window.api.dialogs.respond(true) - }, []) - - /** - * Responds to the dialog with a cancellation, closing the window. - */ - const handleCancel = useCallback(() => { - window.api.dialogs.respond(false) - }, []) - - // Fetch the options for this dialog window once, on mount. + // Fetch the options for this dialog window once, on mount useEffect(() => { window.api.dialogs.getOptions().then(setOptions) }, []) - // Confirm on Enter, cancel on Escape - useEffect(() => { - /** - * Confirms on Enter, cancels on Escape. - * @param event The keyboard event. - */ - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") handleCancel() - if (event.key === "Enter") handleConfirm() - } - - document.addEventListener("keydown", handleKeyDown) - return () => document.removeEventListener("keydown", handleKeyDown) - }, [handleCancel, handleConfirm]) - return ( - - -
-
- {options?.variant === "error" && ( - - )} - -
-

{options?.message}

- - {options?.detail && ( - -

{options.detail}

- - )} -
-
+ {options === null && } -
- {options?.variant === "confirm" && ( - - )} + {options?.variant === "update" && } - -
-
+ {(options?.variant === "confirm" || + options?.variant === "message" || + options?.variant === "error" || + options?.variant === "error-with-details") && ( + + )}
) diff --git a/src/renderer/components/contexts/FileTree.tsx b/src/renderer/components/contexts/FileTree.tsx index 52e6d0a..f38539b 100644 --- a/src/renderer/components/contexts/FileTree.tsx +++ b/src/renderer/components/contexts/FileTree.tsx @@ -50,8 +50,8 @@ export default function FileTreeProvider({ children }: FileTreeProviderProps) { try { setLocksByPath(indexLocks(await window.api.lfsCommands.listLocks(currentProject.path))) - } catch (err) { - reportError("Failed to load LFS locks", err) + } catch (error) { + reportError("Failed to load LFS locks", error) } }, [currentProject?.path]) @@ -81,7 +81,7 @@ export default function FileTreeProvider({ children }: FileTreeProviderProps) { const treePromise = window.api.fileTree .get(currentProject.path) .then(setFileTree) - .catch(err => reportError("Failed to load file tree", err)) + .catch(error => reportError("Failed to load file tree", error)) await Promise.all([treePromise, refreshLocks()]) @@ -92,8 +92,8 @@ export default function FileTreeProvider({ children }: FileTreeProviderProps) { if (migrations.some(m => m.status === "migrated" || m.status === "failed-unlock")) { await refreshLocks() } - } catch (err) { - reportError("Failed to migrate LFS locks", err) + } catch (error) { + reportError("Failed to migrate LFS locks", error) } setIsLoading(false) @@ -126,8 +126,8 @@ export default function FileTreeProvider({ children }: FileTreeProviderProps) { await refreshLocks() return results }) - } catch (err) { - reportError("Failed to lock files", err) + } catch (error) { + reportError("Failed to lock files", error) return [] } }, @@ -164,8 +164,8 @@ export default function FileTreeProvider({ children }: FileTreeProviderProps) { await refreshLocks() return results }) - } catch (err) { - reportError(force ? "Failed to force-unlock files" : "Failed to unlock files", err) + } catch (error) { + reportError(force ? "Failed to force-unlock files" : "Failed to unlock files", error) return [] } }, diff --git a/src/renderer/components/contexts/Project.tsx b/src/renderer/components/contexts/Project.tsx index ae4cdef..a0bbf9f 100644 --- a/src/renderer/components/contexts/Project.tsx +++ b/src/renderer/components/contexts/Project.tsx @@ -1,6 +1,8 @@ import type { ReactNode } from "react" import { createContext, useCallback, useContext, useEffect, useState } from "react" import type { OpenProjectResult, Project } from "@/main/types/projects" +import CONSTANTS from "@/renderer/lib/constants" +import { reportError } from "@/renderer/lib/utils/errors" /** * The type for the project context. @@ -10,12 +12,12 @@ export type ProjectContextType = { recentProjects: Project[] remoteUrl: string | null isLoading: boolean - error: Error | null addLocalProject: () => Promise openProject: (dir: string) => Promise removeRecentProject: (dir: string) => Promise clearRecentProjects: () => Promise closeProject: () => void + openInUnrealEditor: () => Promise } /** @@ -40,7 +42,6 @@ export default function ProjectProvider({ children }: ProjectProviderProps) { const [recentProjects, setRecentProjects] = useState([]) const [remoteUrl, setRemoteUrl] = useState(null) const [isLoading, setIsLoading] = useState(true) - const [error, setError] = useState(null) /** * Refreshes the recent projects list from the store. @@ -50,8 +51,9 @@ export default function ProjectProvider({ children }: ProjectProviderProps) { }, []) /** - * Runs an open action, reflecting its outcome in the current project and - * error state, and refreshing the recent projects list afterwards. + * Runs an open action, reflecting its outcome in the current project and surfacing any + * failure through the native error-with-details dialog, and refreshing the recent + * projects list afterwards. * @param action The open action to run (add local or open by path). */ const runOpen = useCallback( @@ -61,14 +63,24 @@ export default function ProjectProvider({ children }: ProjectProviderProps) { if (result.ok) { setCurrentProject(result.project) - setError(null) } else if (result.reason !== "cancelled") { - setError(new Error(result.message ?? "Failed to open the project.")) + const message = + CONSTANTS.PROJECT_OPEN_FAILURE_MESSAGES[result.reason] ?? "The project couldn't be opened." + window.api.dialogs.errorWithDetails( + "Can't open project", + message, + result.message ?? "No further details available.", + ) } await refreshRecentProjects() } catch (err) { - setError(err instanceof Error ? err : new Error(String(err))) + const details = err instanceof Error ? err.message : String(err) + window.api.dialogs.errorWithDetails( + "Can't open project", + "An unexpected error occurred while opening the project.", + details, + ) } }, [refreshRecentProjects], @@ -107,6 +119,24 @@ export default function ProjectProvider({ children }: ProjectProviderProps) { */ const closeProject = useCallback(() => setCurrentProject(null), []) + /** + * Opens the current project in Unreal Engine, replacing its cached `.uproject` + * metadata with the copy the main process resolved at launch time. + */ + const openInUnrealEditor = useCallback(async () => { + const root = currentProject?.path + if (!root) return + + try { + const uproject = await window.api.uproject.open(root) + + // Guards against the project being closed or swapped while the editor was launching + setCurrentProject(current => (current?.path === root ? { ...current, uproject } : current)) + } catch (error) { + reportError("Can't open the project in Unreal Editor", error) + } + }, [currentProject?.path]) + // Fetches the current project's origin URL whenever the open project // changes, so downstream consumers (e.g. the menu bar) can enable/disable // remote-scoped actions accordingly @@ -152,7 +182,12 @@ export default function ProjectProvider({ children }: ProjectProviderProps) { await openProject(projects[0].path) } } catch (err) { - setError(err instanceof Error ? err : new Error(String(err))) + const details = err instanceof Error ? err.message : String(err) + window.api.dialogs.errorWithDetails( + "Failed to load recent projects", + "The recent projects list couldn't be loaded on startup.", + details, + ) } setIsLoading(false) @@ -168,12 +203,12 @@ export default function ProjectProvider({ children }: ProjectProviderProps) { recentProjects, remoteUrl, isLoading, - error, addLocalProject, openProject, removeRecentProject, clearRecentProjects, closeProject, + openInUnrealEditor, }} > {children} diff --git a/src/renderer/components/contexts/TreeView.tsx b/src/renderer/components/contexts/TreeView.tsx index 0dec225..15cbcf4 100644 --- a/src/renderer/components/contexts/TreeView.tsx +++ b/src/renderer/components/contexts/TreeView.tsx @@ -364,7 +364,7 @@ export default function TreeViewProvider({ children }: TreeViewProviderProps) { const confirmed = await window.api.dialogs.confirm({ title: "Force unlock", message: `Force unlock ${menu.othersPaths.length} file${menu.othersPaths.length === 1 ? "" : "s"} locked by other users?`, - detail: "Forcing may discard work the lock owner has not pushed yet.", + details: "Forcing may discard work the lock owner has not pushed yet.", confirmLabel: "Force unlock", isDestructive: true, }) diff --git a/src/renderer/components/dialogs/Message.tsx b/src/renderer/components/dialogs/Message.tsx new file mode 100644 index 0000000..1e8f48b --- /dev/null +++ b/src/renderer/components/dialogs/Message.tsx @@ -0,0 +1,95 @@ +import errorIcon from "@react95-icons/Hand_32x32_4.png" +import messageIcon from "@react95-icons/InfoBubble_32x32_4.png" +import { useCallback, useEffect, useMemo } from "react" +import { Button, Frame } from "react95" +import type { ConfirmDialogOptions } from "@/main/types/dialogs" +import TitleBar from "@/renderer/components/bars/Title" + +type MessageDialogProps = { + options: ConfirmDialogOptions + variant: "confirm" | "message" | "error" | "error-with-details" +} + +export default function MessageDialog({ options, variant }: MessageDialogProps) { + /** + * The icon standing in for the kind of message, with the plain confirm variant + * carrying none. + */ + const icon = useMemo(() => { + if (variant === "confirm") return null + return variant === "message" ? messageIcon : errorIcon + }, [variant]) + + /** + * Responds to the dialog with a confirmation, closing the window. + */ + const handleConfirm = useCallback(() => { + window.api.dialogs.respond(true) + }, []) + + /** + * Responds to the dialog with a cancellation, closing the window. + */ + const handleCancel = useCallback(() => { + window.api.dialogs.respond(false) + }, []) + + // Chime alongside the dialog rather than from the caller, so an error opened by + // the main process (the updater) sounds the same as one raised by the renderer + useEffect(() => { + if (variant === "error" || variant === "error-with-details") window.api.audio.playError() + }, [variant]) + + // Confirm on enter, cancel on escape + useEffect(() => { + /** + * Confirms on enter, cancels on escape. + * @param event The keyboard event. + */ + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") handleCancel() + if (event.key === "Enter") handleConfirm() + } + + document.addEventListener("keydown", handleKeyDown) + return () => document.removeEventListener("keydown", handleKeyDown) + }, [handleCancel, handleConfirm]) + + return ( + <> + + +
+
+ {icon && ( + + )} + +
+

{options.message}

+ + {options.details && ( + +

{options.details}

+ + )} +
+
+ +
+ {variant === "confirm" && } + + +
+
+ + ) +} diff --git a/src/renderer/components/dialogs/Update.tsx b/src/renderer/components/dialogs/Update.tsx new file mode 100644 index 0000000..b51e06b --- /dev/null +++ b/src/renderer/components/dialogs/Update.tsx @@ -0,0 +1,184 @@ +import errorIcon from "@react95-icons/Hand_32x32_4.png" +import upToDateIcon from "@react95-icons/InfoBubble_32x32_4.png" +import updateIcon from "@react95-icons/Qfecheck111_32x32_4.png" +import { useCallback, useEffect, useMemo, useState } from "react" +import { Button, Frame, ProgressBar } from "react95" +import type { UpdaterState } from "@/main/types/updater" +import TitleBar from "@/renderer/components/bars/Title" +import { reportError } from "@/renderer/lib/utils/errors" +import { + buildDownloadLabel, + buildUpdaterDetails, + buildUpdaterHeadline, + convertReleaseBodyToReleaseNotes, +} from "@/renderer/lib/utils/updater" + +type UpdateDialogProps = { + title: string +} + +export default function UpdateDialog({ title }: UpdateDialogProps) { + const [state, setState] = useState(null) + + /** + * The icon standing in for the current phase of the update. + */ + const icon = useMemo(() => { + if (state?.status === "error") return errorIcon + if (state?.status === "not-available") return upToDateIcon + return updateIcon + }, [state?.status]) + + /** + * The headline of the current state. + */ + const headline = useMemo(() => (state ? buildUpdaterHeadline(state) : ""), [state]) + + /** + * The details of the current state. + */ + const details = useMemo(() => (state ? buildUpdaterDetails(state) : null), [state]) + + /** + * The release notes of the current state. + */ + const releaseNotes = useMemo( + () => convertReleaseBodyToReleaseNotes(state?.releaseNotes ?? null), + [state?.releaseNotes], + ) + + /** + * The label of the primary button, which is hidden while a check or a download + * is running and there is nothing to act on yet. + */ + const primaryLabel = useMemo(() => { + if (state?.status === "downloaded") return "Restart Now" + if (state?.status === "available") return state.canAutoInstall ? "Download Update" : "Open Releases Page" + return "OK" + }, [state?.status, state?.canAutoInstall]) + + /** + * Closes the dialog, leaving any running check or download to carry on in the + * background. + */ + const handleClose = useCallback(() => { + window.api.windows.close() + }, []) + + /** + * Runs the action the current state offers: starting the download, opening the + * releases page on the platforms that cannot install in place, restarting into + * the staged version, or simply acknowledging the result. + */ + const handlePrimary = useCallback(() => { + if (!state) return + + if (state.status === "available") { + if (state.canAutoInstall) { + window.api.updater.download().catch(error => reportError("Failed to download the update", error)) + } else { + window.api.shells.openExternal(state.releaseUrl) + handleClose() + } + + return + } + + if (state.status === "downloaded") { + window.api.updater.install() + return + } + + handleClose() + }, [state, handleClose]) + + // Follow the updater from the main process, subscribing before the first read + // so a transition landing mid-mount cannot be missed + useEffect(() => { + let hasReceivedPush = false + + const unsubscribe = window.api.updater.onStateChange(next => { + hasReceivedPush = true + setState(next) + }) + + window.api.updater.getState().then(initial => { + if (!hasReceivedPush) setState(initial) + }) + + return unsubscribe + }, []) + + // Run the primary action on Enter, close on Escape + useEffect(() => { + /** + * Runs the primary action on Enter, closes on Escape. + * @param event The keyboard event. + */ + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") handleClose() + if (event.key === "Enter") handlePrimary() + } + + document.addEventListener("keydown", handleKeyDown) + return () => document.removeEventListener("keydown", handleKeyDown) + }, [handleClose, handlePrimary]) + + return ( + <> + + +
+
+ + +
+

{headline}

+ {details &&

{details}

} +
+
+ + {releaseNotes && ( + +

{releaseNotes}

+ + )} + + {state?.status === "downloading" && ( +
+ + + + {state.progress ? buildDownloadLabel(state.progress) : "Starting the download..."} + +
+ )} + +
+ {(state?.status === "available" || state?.status === "downloaded") && ( + + )} + + {state?.status === "checking" && } + {state?.status === "downloading" && } + + {state && state.status !== "checking" && state.status !== "downloading" && ( + + )} +
+
+ + ) +} diff --git a/src/renderer/components/dropdowns/Menu.tsx b/src/renderer/components/dropdowns/Menu.tsx index 44963b8..aa3b413 100644 --- a/src/renderer/components/dropdowns/Menu.tsx +++ b/src/renderer/components/dropdowns/Menu.tsx @@ -1,4 +1,5 @@ import { cn } from "@cybearl/cypack/frontend" +import tickIcon from "@react95-icons/Tick_16x16_4.png" import { useCallback, useState } from "react" import { MenuList, MenuListItem, Separator } from "react95" import type { MenuAction, TopLevelMenuEntry } from "@/renderer/config/menus" @@ -40,7 +41,22 @@ export default function MenuDropdown({ items, onAction, onDismiss, className }: onDismiss() }} > - {entry.label} + + {entry.isChecked && ( + + + + )} + + {entry.label} + + {entry.accelerator && {entry.accelerator}} ) diff --git a/src/renderer/components/fields/StatusUpdate.tsx b/src/renderer/components/fields/StatusUpdate.tsx new file mode 100644 index 0000000..1858e51 --- /dev/null +++ b/src/renderer/components/fields/StatusUpdate.tsx @@ -0,0 +1,49 @@ +import { cn } from "@cybearl/cypack/frontend" +import Tooltip from "@renderer/components/ui/Tooltip" +import useUpdaterState from "@renderer/hooks/useUpdaterState" +import { useCallback, useMemo } from "react" +import { Frame } from "react95" +import { buildUpdaterStatusLabel } from "@/renderer/lib/utils/updater" + +type StatusUpdateFieldProps = { + className?: string +} + +export default function StatusUpdateField({ className }: StatusUpdateFieldProps) { + const state = useUpdaterState() + + /** + * The pending update advertised in the status bar, or `null` when there is + * nothing waiting on the user. + */ + const label = useMemo(() => (state ? buildUpdaterStatusLabel(state) : null), [state]) + + /** + * Brings the update dialog back up, without starting another check. + */ + const handleClick = useCallback(() => { + window.api.updater.openDialog() + }, []) + + if (!label) return null + + return ( + + + + {label} + + + + ) +} diff --git a/src/renderer/components/panes/Details.tsx b/src/renderer/components/panes/Details.tsx index 4b1d8a2..2a7f3eb 100644 --- a/src/renderer/components/panes/Details.tsx +++ b/src/renderer/components/panes/Details.tsx @@ -103,7 +103,7 @@ export default function DetailsPane({ className }: DetailsPaneProps) { const confirmed = await window.api.dialogs.confirm({ title: "Force unlock", message: `Force unlock ${othersPaths.length} file${othersPaths.length === 1 ? "" : "s"} locked by other users?`, - detail: "Forcing may discard work the lock owner has not pushed yet.", + details: "Forcing may discard work the lock owner has not pushed yet.", confirmLabel: "Force unlock", isDestructive: true, }) diff --git a/src/renderer/components/views/Welcome.tsx b/src/renderer/components/views/Welcome.tsx new file mode 100644 index 0000000..29a606e --- /dev/null +++ b/src/renderer/components/views/Welcome.tsx @@ -0,0 +1,35 @@ +import computerIcon from "@react95-icons/Computer3_16x16_4.png" +import { useProjectContext } from "@renderer/components/contexts/Project" +import { Button } from "react95" + +/** + * Welcome / empty-state view shown when no project is open, prompts the user to open a UE + * project folder. Open failures (missing folder, non-UE folder, ...) surface through the + * native error-with-details dialog fired by `ProjectContext`, not inline here. + */ +export default function Welcome() { + const projectContext = useProjectContext() + + if (projectContext.isLoading) return null + + return ( +
+
+
+ + +

Welcome to GitGame

+ +

+ A Git LFS companion for Unreal Engine projects, open a folder with a{" "} + .uproject file at its root to get started. +

+ + +
+
+
+ ) +} diff --git a/src/renderer/config/menus.ts b/src/renderer/config/menus.ts index 0694682..bc05028 100644 --- a/src/renderer/config/menus.ts +++ b/src/renderer/config/menus.ts @@ -1,4 +1,6 @@ import type { Project } from "@/main/types/projects" +import type { UpdaterSimulation } from "@/main/types/updater" +import CONSTANTS from "@/renderer/lib/constants" /** * A dispatchable menu action. @@ -6,21 +8,22 @@ import type { Project } from "@/main/types/projects" export type MenuAction = | { type: "project:add-local" } | { type: "project:open"; path: string } + | { type: "project:close" } | { type: "project:clear-recent" } + | { type: "uproject:open" } | { type: "window:close" } | { type: "view:reload" } | { type: "shell:open-external"; url: string } + | { type: "shell:show-folder"; path: string } + | { type: "shell:open-terminal"; path: string } + | { type: "search:toggle-regex" } + | { type: "search:toggle-advanced" } + | { type: "lfs:toggle-show-my-locks" } + | { type: "updater:check" } | { type: "devtools:test-confirm" } | { type: "devtools:test-error" } - | { type: "devtools:test-error-with-detail" } - -/** - * External resources opened from the `Help` menu. - */ -const EXTERNAL_LINKS = { - documentation: "https://github.com/cybearl/gitgame/blob/main/README.md", - reportIssue: "https://github.com/cybearl/gitgame/issues", -} as const + | { type: "devtools:test-error-with-details" } + | { type: "devtools:simulate-update"; scenario: UpdaterSimulation } /** * A single entry inside a menu dropdown: @@ -34,6 +37,7 @@ export type TopLevelMenuEntry = label: string accelerator?: string isDisabled?: boolean + isChecked?: boolean action?: MenuAction } | { type: "separator" } @@ -89,18 +93,31 @@ function buildRecentProjectsItems(recentProjects: Project[], currentProject: Pro ] } +/** + * The current on/off state of the search and lock toggles shown as checkable + * menu items, so the dropdown can render the check indicator in sync with the + * live tree-view state. + */ +export type MenuToggleState = { + isRegex: boolean + isAdvancedOpen: boolean + isShowingMyLocksOnly: boolean +} + /** * Builds the top-level application menus, injecting the dynamic recent projects * and enabling the repository-scoped items only when a project is open. * @param recentProjects The recent projects, most recently opened first. * @param currentProject The currently opened project, if any. * @param remoteBrowsableUrl The browsable HTTPS URL of the current project's remote, if any. + * @param toggles The on/off state of the search and lock toggle items. * @returns The top-level menus. */ export function buildTopLevelMenus( recentProjects: Project[], currentProject: Project | null, remoteBrowsableUrl: string | null, + toggles: MenuToggleState, ): TopLevelMenu[] { const devToolsMenu: TopLevelMenu = { label: "Dev Tools", @@ -117,8 +134,29 @@ export function buildTopLevelMenus( }, { type: "item", - label: "Test Error Dialog with Detail", - action: { type: "devtools:test-error-with-detail" }, + label: "Test Error Dialog with Details", + action: { type: "devtools:test-error-with-details" }, + }, + { type: "separator" }, + { + type: "item", + label: "Test Update Dialog (Download)", + action: { type: "devtools:simulate-update", scenario: "download" }, + }, + { + type: "item", + label: "Test Update Dialog (Link Only)", + action: { type: "devtools:simulate-update", scenario: "link-only" }, + }, + { + type: "item", + label: "Test Update Dialog (Up To Date)", + action: { type: "devtools:simulate-update", scenario: "up-to-date" }, + }, + { + type: "item", + label: "Test Update Dialog (Error)", + action: { type: "devtools:simulate-update", scenario: "error" }, }, ], } @@ -151,6 +189,14 @@ export function buildTopLevelMenus( items: buildRecentProjectsItems(recentProjects, currentProject), }, { type: "separator" }, + { + type: "item", + label: "Close Project", + accelerator: "Ctrl+W", + isDisabled: !currentProject, + action: { type: "project:close" }, + }, + { type: "separator" }, { type: "item", label: "Options...", @@ -203,6 +249,19 @@ export function buildTopLevelMenus( isDisabled: true, }, { type: "separator" }, + { + type: "item", + label: "Use Regular Expression", + isChecked: toggles.isRegex, + action: { type: "search:toggle-regex" }, + }, + { + type: "item", + label: "Show Include/Exclude Filters", + isChecked: toggles.isAdvancedOpen, + action: { type: "search:toggle-advanced" }, + }, + { type: "separator" }, { type: "item", label: "Reload", @@ -235,18 +294,23 @@ export function buildTopLevelMenus( { type: "separator" }, { type: "item", - label: "Show in Explorer", - isDisabled: true, + label: + CONSTANTS.FILE_MANAGER_LABELS[window.api.platform.value] ?? + CONSTANTS.DEFAULT_FILE_MANAGER_LABEL, + isDisabled: !currentProject, + action: currentProject ? { type: "shell:show-folder", path: currentProject.path } : undefined, }, { type: "item", label: "Open in Unreal Editor", - isDisabled: true, + isDisabled: !currentProject, + action: { type: "uproject:open" }, }, { type: "item", label: "Open in Terminal", - isDisabled: true, + isDisabled: !currentProject, + action: currentProject ? { type: "shell:open-terminal", path: currentProject.path } : undefined, }, { type: "item", @@ -345,8 +409,9 @@ export function buildTopLevelMenus( }, { type: "item", - label: "View Locks...", - isDisabled: true, + label: "Show My Locks Only", + isChecked: toggles.isShowingMyLocksOnly, + action: { type: "lfs:toggle-show-my-locks" }, }, { type: "separator" }, { @@ -374,7 +439,7 @@ export function buildTopLevelMenus( type: "item", label: "Documentation", accelerator: "F1", - action: { type: "shell:open-external", url: EXTERNAL_LINKS.documentation }, + action: { type: "shell:open-external", url: CONSTANTS.EXTERNAL_LINKS.documentation }, }, { type: "item", @@ -386,9 +451,14 @@ export function buildTopLevelMenus( { type: "item", label: "Report an Issue...", - action: { type: "shell:open-external", url: EXTERNAL_LINKS.reportIssue }, + action: { type: "shell:open-external", url: CONSTANTS.EXTERNAL_LINKS.reportIssue }, }, { type: "separator" }, + { + type: "item", + label: "Check for Updates...", + action: { type: "updater:check" }, + }, { type: "item", label: "About GitGame", diff --git a/src/renderer/hooks/useMenuActions.tsx b/src/renderer/hooks/useMenuActions.tsx new file mode 100644 index 0000000..28497cd --- /dev/null +++ b/src/renderer/hooks/useMenuActions.tsx @@ -0,0 +1,131 @@ +import { useProjectContext } from "@renderer/components/contexts/Project" +import { useTreeViewContext } from "@renderer/components/contexts/TreeView" +import { useCallback } from "react" +import type { MenuAction } from "@/renderer/config/menus" +import { reportError } from "@/renderer/lib/utils/errors" +import { buildDevToolsLockFailures } from "@/renderer/lib/utils/menus" + +/** + * The value returned by the `useMenuActions` hook. + */ +type UseMenuActionsResult = { + handleMenuAction: (action: MenuAction) => Promise +} + +/** + * Builds the dispatcher mapping every menu action onto the application state that + * handles it. + * @returns The handler passed to the menu bar and to the accelerator bindings. + */ +export default function useMenuActions(): UseMenuActionsResult { + const { addLocalProject, openProject, closeProject, clearRecentProjects, openInUnrealEditor } = useProjectContext() + const { isRegex, setIsRegex, isAdvancedOpen, setIsAdvancedOpen, isShowingMyLocksOnly, setIsShowingMyLocksOnly } = + useTreeViewContext() + + /** + * Dispatches a menu action to the matching application state handler. + * @param action The action selected in the menu bar. + */ + const handleMenuAction = useCallback( + async (action: MenuAction) => { + switch (action.type) { + case "project:add-local": + addLocalProject() + break + case "project:open": + openProject(action.path) + break + case "project:close": + closeProject() + break + case "project:clear-recent": { + const confirmed = await window.api.dialogs.confirm({ + title: "Clear recent projects", + message: "Clear the entire recent projects list?", + details: "This only forgets the entries here, your project folders on disk are left untouched.", + confirmLabel: "Clear", + isDestructive: true, + }) + + if (confirmed) clearRecentProjects() + break + } + case "uproject:open": + openInUnrealEditor() + break + case "window:close": + window.api.windows.close() + break + case "view:reload": + window.location.reload() + break + case "shell:open-external": + window.api.shells.openExternal(action.url) + break + case "shell:show-folder": + window.api.shells + .showFolder(action.path) + .catch(error => reportError("Can't open the project folder", error)) + + break + case "shell:open-terminal": + window.api.shells + .openTerminal(action.path) + .catch(error => reportError("Can't open a terminal", error)) + + break + case "search:toggle-regex": + setIsRegex(!isRegex) + break + case "search:toggle-advanced": + setIsAdvancedOpen(!isAdvancedOpen) + break + case "lfs:toggle-show-my-locks": + setIsShowingMyLocksOnly(!isShowingMyLocksOnly) + break + case "updater:check": + window.api.updater.check(true).catch(error => reportError("Failed to check for updates", error)) + break + case "devtools:test-confirm": + window.api.dialogs.confirm({ + title: "Test confirm", + message: "This is a test confirm dialog.", + details: "Use it to preview the Win95 confirm styling from the Dev Tools menu.", + confirmLabel: "Sure", + cancelLabel: "Nope", + }) + + break + case "devtools:test-error": + window.api.dialogs.error("Test error", "This is a test error message.") + break + case "devtools:test-error-with-details": + window.api.dialogs.errorWithDetails( + "Test error with details", + "5 files could not be updated.", + buildDevToolsLockFailures(), + ) + + break + case "devtools:simulate-update": + window.api.updater.simulate(action.scenario) + break + } + }, + [ + addLocalProject, + openProject, + closeProject, + clearRecentProjects, + openInUnrealEditor, + isRegex, + setIsRegex, + isAdvancedOpen, + setIsAdvancedOpen, + isShowingMyLocksOnly, + setIsShowingMyLocksOnly, + ], + ) + + return { handleMenuAction } +} diff --git a/src/renderer/hooks/useMenuShortcuts.tsx b/src/renderer/hooks/useMenuShortcuts.tsx index 8fed9d0..30cf66b 100644 --- a/src/renderer/hooks/useMenuShortcuts.tsx +++ b/src/renderer/hooks/useMenuShortcuts.tsx @@ -1,115 +1,16 @@ import { useEffect } from "react" -import type { MenuAction, TopLevelMenu, TopLevelMenuEntry } from "@/renderer/config/menus" - -/** - * The parts of a keyboard combination, where `primary` is the platform's primary - * modifier (Cmd on macOS, Ctrl elsewhere). - */ -type KeyCombo = { - primary: boolean - shift: boolean - alt: boolean - key: string -} - -/** - * Builds the canonical signature for a key combination (e.g. `"mod+shift+o"`), - * used to match menu accelerators against keyboard events regardless of platform. - * @param combo The key combination parts. - * @returns The canonical signature string. - */ -function comboSignature(combo: KeyCombo): string { - const segments: string[] = [] - - if (combo.primary) segments.push("mod") - if (combo.shift) segments.push("shift") - if (combo.alt) segments.push("alt") - segments.push(combo.key.toLowerCase()) - - return segments.join("+") -} - -/** - * Parses a menu accelerator string (e.g. `"Ctrl+Shift+O"`) into a canonical - * signature. `Ctrl`/`Cmd` both map to the primary modifier. - * @param accelerator The accelerator string. - * @returns The canonical signature string. - */ -function parseAccelerator(accelerator: string): string { - const combo: KeyCombo = { primary: false, shift: false, alt: false, key: "" } - - for (const token of accelerator.split("+")) { - switch (token.trim().toLowerCase()) { - case "ctrl": - case "cmd": - case "cmdorctrl": - case "mod": - combo.primary = true - break - case "shift": - combo.shift = true - break - case "alt": - case "option": - combo.alt = true - break - default: - combo.key = token.trim() - } - } - - return comboSignature(combo) -} - -/** - * Collects the accelerator-to-action map from the menu tree, including only - * enabled items that have both an accelerator and an action. - * @param menus The menus to walk. - * @returns A map from canonical signature to the action to dispatch. - */ -function collectShortcuts(menus: TopLevelMenu[]): Map { - const shortcuts = new Map() - - /** - * Recursively collects shortcuts from the menu entries. - * @param entries The menu entries to walk. - */ - const visit = (entries: TopLevelMenuEntry[]) => { - for (const entry of entries) { - if (entry.type === "submenu") { - visit(entry.items) - } else if (entry.type === "item" && entry.action && entry.accelerator && !entry.isDisabled) { - shortcuts.set(parseAccelerator(entry.accelerator), entry.action) - } - } - } - - for (const menu of menus) visit(menu.items) - - return shortcuts -} - -/** - * Returns whether the event target is an editable field that should keep its own - * key handling (so shortcuts do not hijack typing). - * @param target The event target. - * @returns True if the target is an editable field. - */ -function isEditableTarget(target: EventTarget | null): boolean { - if (!(target instanceof HTMLElement)) return false - return target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable -} +import type { MenuAction, TopLevelMenu } from "@/renderer/config/menus" +import { buildComboSignature, collectMenuShortcuts, isEditableTarget } from "@/renderer/lib/utils/menus" /** * Binds the menu accelerators to their actions, dispatching the matching action - * on the corresponding key combination. The `Ctrl` modifier matches Cmd on macOS - * and Ctrl elsewhere. + * on the corresponding key combination. * @param menus The menus to derive shortcuts from. * @param onAction The dispatcher invoked with the matched action. */ export default function useMenuShortcuts(menus: TopLevelMenu[], onAction: (action: MenuAction) => void) { useEffect(() => { - const shortcuts = collectShortcuts(menus) + const shortcuts = collectMenuShortcuts(menus) /** * Matches a keydown event against the collected shortcuts and dispatches. @@ -118,7 +19,7 @@ export default function useMenuShortcuts(menus: TopLevelMenu[], onAction: (actio const handleKeyDown = (event: KeyboardEvent) => { if (event.repeat || isEditableTarget(event.target)) return - const signature = comboSignature({ + const signature = buildComboSignature({ primary: window.api.platform.isMacOS ? event.metaKey : event.ctrlKey, shift: event.shiftKey, alt: event.altKey, diff --git a/src/renderer/hooks/useUpdaterState.tsx b/src/renderer/hooks/useUpdaterState.tsx new file mode 100644 index 0000000..db97479 --- /dev/null +++ b/src/renderer/hooks/useUpdaterState.tsx @@ -0,0 +1,28 @@ +import { useEffect, useState } from "react" +import type { UpdaterState } from "@/main/types/updater" + +/** + * Follows the updater state broadcast by the main process, subscribing before the + * first read so a transition landing mid-mount cannot be missed. + * @returns The current updater state, or `null` until the first read resolves. + */ +export default function useUpdaterState(): UpdaterState | null { + const [state, setState] = useState(null) + + useEffect(() => { + let hasReceivedPush = false + + const unsubscribe = window.api.updater.onStateChange(next => { + hasReceivedPush = true + setState(next) + }) + + window.api.updater.getState().then(initial => { + if (!hasReceivedPush) setState(initial) + }) + + return unsubscribe + }, []) + + return state +} diff --git a/src/renderer/hooks/useUpdaterStatusTask.tsx b/src/renderer/hooks/useUpdaterStatusTask.tsx new file mode 100644 index 0000000..942d2d1 --- /dev/null +++ b/src/renderer/hooks/useUpdaterStatusTask.tsx @@ -0,0 +1,41 @@ +import type { StatusTaskHandle } from "@renderer/components/contexts/Status" +import { useStatusContext } from "@renderer/components/contexts/Status" +import { useEffect, useRef } from "react" + +/** + * Mirrors a running update download onto the status bar, so its progress stays + * visible once the update dialog has been dismissed. + */ +export default function useUpdaterStatusTask() { + const { startTask } = useStatusContext() + + /** + * The status bar task standing for the running download, if any. + */ + const taskRef = useRef(null) + + // Depends on `startTask` alone, which is stable, since the context value itself + // changes on every task update and would otherwise tear the task down mid-download + useEffect(() => { + const unsubscribe = window.api.updater.onStateChange(state => { + if (state.status === "downloading") { + if (!taskRef.current) taskRef.current = startTask(`Downloading GitGame ${state.version}`) + taskRef.current.setProgress(Math.round(state.progress?.percent ?? 0)) + + return + } + + if (taskRef.current) { + taskRef.current.finish() + taskRef.current = null + } + }) + + return () => { + unsubscribe() + + taskRef.current?.finish() + taskRef.current = null + } + }, [startTask]) +} diff --git a/src/renderer/lib/constants.ts b/src/renderer/lib/constants.ts new file mode 100644 index 0000000..919390c --- /dev/null +++ b/src/renderer/lib/constants.ts @@ -0,0 +1,44 @@ +/** + * The constants for the renderer. + */ +const CONSTANTS = { + /** + * The unit suffixes used when formatting a byte count. + */ + BYTE_UNITS: ["B", "KB", "MB", "GB"], + + /** + * External resources opened from the `Help` menu. + */ + EXTERNAL_LINKS: { + documentation: "https://github.com/cybearl/gitgame/blob/main/README.md", + reportIssue: "https://github.com/cybearl/gitgame/issues", + }, + + /** + * The label of the menu item revealing the project folder, per platform, so the + * wording names the file manager the user actually gets, platforms without an entry + * fall back to `DEFAULT_FILE_MANAGER_LABEL`. + */ + FILE_MANAGER_LABELS: { + win32: "Show in Explorer", + darwin: "Reveal in Finder", + } as Record, + + /** + * The label used where the platform's file manager has no established name of its own. + */ + DEFAULT_FILE_MANAGER_LABEL: "Show in File Manager", + + /** + * Short human-readable messages for each `OpenProjectFailureReason`, shown as the + * message line of the error-with-details dialog when opening a project fails. + */ + PROJECT_OPEN_FAILURE_MESSAGES: { + "not-found": "The folder no longer exists on disk.", + "not-a-repository": "That folder isn't a Git repository.", + "not-a-ue-project": "That folder isn't an Unreal Engine project.", + }, +} as const + +export default CONSTANTS diff --git a/src/renderer/lib/utils/errors.ts b/src/renderer/lib/utils/errors.ts index 7a2f5b5..ed01d8c 100644 --- a/src/renderer/lib/utils/errors.ts +++ b/src/renderer/lib/utils/errors.ts @@ -3,9 +3,9 @@ * a plain message from an unknown thrown value so callers can pass the raw * `catch` binding without narrowing it first. * @param title The dialog title, describing what the app was trying to do. - * @param err The thrown value, typically the argument of a `catch` clause. + * @param error The thrown value, typically the argument of a `catch` clause. */ -export function reportError(title: string, err: unknown) { - const message = err instanceof Error ? err.message : String(err) +export function reportError(title: string, error: unknown) { + const message = error instanceof Error ? error.message : String(error) window.api.dialogs.error(title, message) } diff --git a/src/renderer/lib/utils/menus.ts b/src/renderer/lib/utils/menus.ts new file mode 100644 index 0000000..d2de921 --- /dev/null +++ b/src/renderer/lib/utils/menus.ts @@ -0,0 +1,115 @@ +import type { MenuAction, TopLevelMenu, TopLevelMenuEntry } from "@/renderer/config/menus" + +/** + * The parts of a keyboard combination, where `primary` is the platform's primary + * modifier (Cmd on macOS, Ctrl elsewhere). + */ +type KeyCombo = { + primary: boolean + shift: boolean + alt: boolean + key: string +} + +/** + * Builds the canonical signature for a key combination (e.g. `"mod+shift+o"`), + * used to match menu accelerators against keyboard events regardless of platform. + * @param combo The key combination parts. + * @returns The canonical signature string. + */ +export function buildComboSignature(combo: KeyCombo): string { + const segments: string[] = [] + + if (combo.primary) segments.push("mod") + if (combo.shift) segments.push("shift") + if (combo.alt) segments.push("alt") + segments.push(combo.key.toLowerCase()) + + return segments.join("+") +} + +/** + * Parses a menu accelerator string (e.g. `"Ctrl+Shift+O"`) into a canonical + * signature. `Ctrl`/`Cmd` both map to the primary modifier. + * @param accelerator The accelerator string. + * @returns The canonical signature string. + */ +function parseAccelerator(accelerator: string): string { + const combo: KeyCombo = { primary: false, shift: false, alt: false, key: "" } + + for (const token of accelerator.split("+")) { + switch (token.trim().toLowerCase()) { + case "ctrl": + case "cmd": + case "cmdorctrl": + case "mod": + combo.primary = true + break + case "shift": + combo.shift = true + break + case "alt": + case "option": + combo.alt = true + break + default: + combo.key = token.trim() + } + } + + return buildComboSignature(combo) +} + +/** + * Collects the accelerator-to-action map from the menu tree, including only + * enabled items that have both an accelerator and an action. + * @param menus The menus to walk. + * @returns A map from canonical signature to the action to dispatch. + */ +export function collectMenuShortcuts(menus: TopLevelMenu[]): Map { + const shortcuts = new Map() + + /** + * Recursively collects shortcuts from the menu entries. + * @param entries The menu entries to walk. + */ + const visit = (entries: TopLevelMenuEntry[]) => { + for (const entry of entries) { + if (entry.type === "submenu") { + visit(entry.items) + } else if (entry.type === "item" && entry.action && entry.accelerator && !entry.isDisabled) { + shortcuts.set(parseAccelerator(entry.accelerator), entry.action) + } + } + } + + for (const menu of menus) visit(menu.items) + + return shortcuts +} + +/** + * Returns whether the event target is an editable field that should keep its own + * key handling (so shortcuts do not hijack typing). + * @param target The event target. + * @returns True if the target is an editable field. + */ +export function isEditableTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false + return target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable +} + +/** + * Builds the fake per-file lock failures shown by the `Dev Tools` error dialog, so + * the details pane can be previewed with realistic content. + * @returns The failures as one line each. + */ +export function buildDevToolsLockFailures(): string { + return [ + "Content/Characters/Hero/BP_Hero.uasset: locked by john", + "Content/Characters/Hero/SK_Hero.uasset: locked by jane", + "Content/Maps/MainMenu.umap: locked by bob", + "Content/UI/HUD/WBP_HUD.uasset: locked by alice", + "Content/VFX/P_Explosion.uasset", + ].join("\n") +} diff --git a/src/renderer/lib/utils/treeView.tsx b/src/renderer/lib/utils/treeView.tsx index 16c2c01..d3ce9fa 100644 --- a/src/renderer/lib/utils/treeView.tsx +++ b/src/renderer/lib/utils/treeView.tsx @@ -17,9 +17,9 @@ export function reportLockFailures(results: LfsLockResult[]) { if (failed.length === 0) return const message = `${failed.length} file${failed.length === 1 ? "" : "s"} could not be updated.` - const detail = failed.map(result => (result.error ? `${result.path}: ${result.error}` : result.path)).join("\n") + const details = failed.map(result => (result.error ? `${result.path}: ${result.error}` : result.path)).join("\n") - window.api.dialogs.error("Some files could not be updated", message, detail) + window.api.dialogs.errorWithDetails("Some files could not be updated", message, details) } /** diff --git a/src/renderer/lib/utils/updater.ts b/src/renderer/lib/utils/updater.ts new file mode 100644 index 0000000..6d9a725 --- /dev/null +++ b/src/renderer/lib/utils/updater.ts @@ -0,0 +1,119 @@ +import type { UpdaterDownloadProgress, UpdaterState } from "@/main/types/updater" +import CONSTANTS from "@/renderer/lib/constants" + +/** + * Formats a byte count into a human-readable size. + * @param bytes The number of bytes. + * @returns The formatted size, e.g. `42.3 MB`. + */ +function formatBytes(bytes: number): string { + let value = Math.max(0, bytes) + let unitIndex = 0 + + while (value >= 1024 && unitIndex < CONSTANTS.BYTE_UNITS.length - 1) { + value /= 1024 + unitIndex += 1 + } + + return `${value.toFixed(unitIndex === 0 ? 0 : 1)} ${CONSTANTS.BYTE_UNITS[unitIndex]}` +} + +/** + * Converts the release body served by GitHub into plain text notes, since the feed + * hands out HTML that would otherwise render as literal tags. + * @param body The raw release body, if any. + * @returns The notes as plain text, or `null` when there are none. + */ +export function convertReleaseBodyToReleaseNotes(body: string | null): string | null { + if (!body) return null + + const notes = body + .replace(/<\s*br\s*\/?\s*>/gi, "\n") + .replace(/<\s*\/\s*(p|li|h[1-6]|div)\s*>/gi, "\n") + .replace(/<\s*li\s*>/gi, "- ") + .replace(/<[^>]+>/g, "") + .replace(/ /g, " ") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/\n{3,}/g, "\n\n") + .trim() + + return notes.length > 0 ? notes : null +} + +/** + * Builds the headline shown at the top of the update dialog. + * @param state The current updater state. + * @returns The headline for the state. + */ +export function buildUpdaterHeadline(state: UpdaterState): string { + switch (state.status) { + case "checking": + return "Checking for updates..." + case "not-available": + return "GitGame is up to date" + case "available": + return `GitGame ${state.version} is available` + case "downloading": + return `Downloading GitGame ${state.version}...` + case "downloaded": + return `GitGame ${state.version} is ready to install` + case "error": + return "Could not check for updates" + default: + return "Software update" + } +} + +/** + * Builds the sentence shown under the headline, explaining what happens next. + * @param state The current updater state. + * @returns The supporting line, or `null` when the headline says it all. + */ +export function buildUpdaterDetails(state: UpdaterState): string | null { + switch (state.status) { + case "not-available": + return `You are running version ${state.currentVersion}, the latest one published.` + case "available": + return state.canAutoInstall + ? `You are running version ${state.currentVersion}, the update downloads in the background and installs when you restart.` + : `You are running version ${state.currentVersion}. Automatic updates are not available on this platform, so grab the installer from the releases page.` + case "downloaded": + return "Restart now to apply it, or keep working and it installs the next time you quit GitGame." + case "error": + return state.error + default: + return null + } +} + +/** + * Builds the label of the status bar's update indicator, which stands in for the + * dialog once it has been dismissed. + * @param state The current updater state. + * @returns The label, or `null` when there is nothing pending to advertise. + */ +export function buildUpdaterStatusLabel(state: UpdaterState): string | null { + if (state.status === "downloaded") return `Restart to update to ${state.version}` + if (state.status === "available") return `Update to ${state.version} available` + + return null +} + +/** + * Formats the transferred and total bytes of a running download, alongside its + * current speed. + * @param progress The download progress snapshot. + * @returns The label shown under the progress bar. + */ +export function buildDownloadLabel(progress: UpdaterDownloadProgress): string { + if (progress.total <= 0) return "Starting the download..." + + const transferred = `${formatBytes(progress.transferred)} of ${formatBytes(progress.total)}` + if (progress.bytesPerSecond <= 0) return transferred + + return `${transferred} (${formatBytes(progress.bytesPerSecond)}/s)` +} diff --git a/tests/unit/uassets/parser.test.ts b/tests/unit/uassets/parser.test.ts deleted file mode 100644 index 864ceb1..0000000 --- a/tests/unit/uassets/parser.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { existsSync, readFileSync } from "node:fs" -import { resolve } from "node:path" -import { parseUAsset } from "@main/lib/uassets/parseUAsset" -import { resolveMainAsset } from "@main/lib/uassets/resolveMainAsset" -import { describe, test } from "vitest" - -/** - * Path to the fixture UAsset file. - */ -const FIXTURE_PATH = resolve(process.cwd(), ".ue5/UAssets/Blueprints/BP_Paticle_Wind_Settings.uasset") - -describe.skipIf(!existsSync(FIXTURE_PATH))("parseUAsset: BP_Paticle_Wind_Settings.uasset (UE 5.8)", () => { - const buffer = readFileSync(FIXTURE_PATH) - const pkg = parseUAsset(buffer) - - test("summary tag and version fields match a modern UE 5.8 file", ({ expect }) => { - expect(pkg.summary.tag).toBe(0x9e2a83c1) - expect(pkg.summary.legacyFileVersion).toBe(-9) - expect(pkg.summary.fileVersionUE4).toBeGreaterThan(0) - expect(pkg.summary.fileVersionUE5).toBeGreaterThanOrEqual(1000) - }) - - test("summary section counts and total header size are positive", ({ expect }) => { - expect(pkg.summary.nameCount).toBeGreaterThan(0) - expect(pkg.summary.importCount).toBeGreaterThan(0) - expect(pkg.summary.exportCount).toBeGreaterThan(0) - expect(pkg.summary.totalHeaderSize).toBeGreaterThan(0) - }) - - test("summary packageName looks like a UE package path", ({ expect }) => { - expect(pkg.summary.packageName).toMatch(/^\/(Game|Engine|Script|Temp)/) - }) - - test("name table decodes to summary.nameCount entries and includes None", ({ expect }) => { - expect(pkg.names).toHaveLength(pkg.summary.nameCount) - expect(pkg.names).toContain("None") - }) - - test("import map exposes at least one /Script/CoreUObject import", ({ expect }) => { - expect(pkg.imports).toHaveLength(pkg.summary.importCount) - const coreImport = pkg.imports.find(i => i.classPackage === "/Script/CoreUObject") - expect(coreImport).toBeDefined() - }) - - test("every export has a non-empty name plus positive serial size and offset", ({ expect }) => { - expect(pkg.exports).toHaveLength(pkg.summary.exportCount) - - for (const entry of pkg.exports) { - expect(entry.objectName.length).toBeGreaterThan(0) - expect(entry.serialSize).toBeGreaterThan(0n) - expect(entry.serialOffset).toBeGreaterThan(0n) - } - }) - - test("resolveMainAsset identifies the Blueprint export and its class", ({ expect }) => { - const main = resolveMainAsset(pkg) - expect(main.export.objectName).toBe("BP_Paticle_Wind_Settings") - expect(main.className).toBe("Blueprint") - expect(main.classPackage).toBe("/Script/Engine") - expect(main.index).toBeGreaterThanOrEqual(0) - }) -}) diff --git a/tests/unit/uassets/propertyTag.test.ts b/tests/unit/uassets/propertyTag.test.ts deleted file mode 100644 index 31e9fa1..0000000 --- a/tests/unit/uassets/propertyTag.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { UAssetBufferReader } from "@main/lib/uassets/bufferReader" -import { readUAssetPropertyTags } from "@main/lib/uassets/parser/propertyTag" -import { describe, test } from "vitest" -import type { UAssetPackageFileSummary } from "@/main/types/uassets" - -/** - * Bare-minimum `UAssetPackageFileSummary` shape used by `readUAssetPropertyTags`, the reader - * only touches `packageFlags` (unversioned check) and `fileVersionUE5` (format gate), so we - * cast a partial object rather than filling every field. - */ -const SUMMARY = { - packageFlags: 0, - fileVersionUE5: 1012, // PROPERTY_TAG_COMPLETE_TYPE_NAME -} as unknown as UAssetPackageFileSummary - -/** - * Synthetic name table used across the buffers constructed below. Index 0 is `"None"` so the - * reader's stream terminator is a simple zero-filled FName pair. - */ -const NAMES = ["None", "MyIntProp", "IntProperty", "MyBoolProp", "BoolProperty"] - -describe("readUAssetPropertyTags: synthetic streams", () => { - test("reads one IntProperty tag and stops at the None terminator", ({ expect }) => { - const buffer = Buffer.from([ - // "name": FName(index=1 -> "MyIntProp", number=0) - 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - // "typeName": 1 node, FName(index=2 -> "IntProperty", number=0), innerCount=0 - 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - // "size": int32(4) - 0x04, 0x00, 0x00, 0x00, - // "flags": uint8(0), no optional fields - 0x00, - // "value": int32(42) - 0x2a, 0x00, 0x00, 0x00, - // Stream terminator: FName(index=0 -> "None", number=0) - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - ]) - const reader = new UAssetBufferReader(buffer) - - const tags = readUAssetPropertyTags(reader, SUMMARY, NAMES, 0) - - expect(tags).toHaveLength(1) - expect(tags[0].name).toBe("MyIntProp") - expect(tags[0].typeName).toBe("IntProperty") - expect(tags[0].size).toBe(4) - expect(tags[0].arrayIndex).toBe(0) - expect(tags[0].boolValue).toBe(false) - expect(tags[0].skipped).toBe(false) - expect(tags[0].binaryOrNativeSerialize).toBe(false) - expect(tags[0].propertyGuid).toBe("") - }) - - test("BoolTrue flag surfaces via boolValue with a zero-size payload", ({ expect }) => { - const buffer = Buffer.from([ - // "name": FName(index=3 -> "MyBoolProp", number=0) - 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - // "typeName": 1 node, FName(index=4 -> "BoolProperty", number=0), innerCount=0 - 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - // "size": int32(0), value lives in the flag bits - 0x00, 0x00, 0x00, 0x00, - // "flags": uint8(0x10 = BoolTrue) - 0x10, - // Stream terminator: FName(index=0 -> "None", number=0) - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - ]) - const reader = new UAssetBufferReader(buffer) - - const tags = readUAssetPropertyTags(reader, SUMMARY, NAMES, 0) - - expect(tags).toHaveLength(1) - expect(tags[0].name).toBe("MyBoolProp") - expect(tags[0].typeName).toBe("BoolProperty") - expect(tags[0].size).toBe(0) - expect(tags[0].boolValue).toBe(true) - }) - - test("empty stream (immediate None terminator) returns no tags", ({ expect }) => { - // Stream terminator only: FName(index=0 -> "None", number=0) - const buffer = Buffer.from([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]) - const reader = new UAssetBufferReader(buffer) - - const tags = readUAssetPropertyTags(reader, SUMMARY, NAMES, 0) - - expect(tags).toHaveLength(0) - }) - - test("throws on unversioned property serialization", ({ expect }) => { - const unversioned = { - packageFlags: 0x2000, // PKG_UnversionedProperties - fileVersionUE5: 1012, - } as unknown as UAssetPackageFileSummary - const reader = new UAssetBufferReader(Buffer.alloc(8)) - - expect(() => readUAssetPropertyTags(reader, unversioned, NAMES, 0)).toThrow(/unversioned/) - }) - - test("throws on legacy pre-PROPERTY_TAG_COMPLETE_TYPE_NAME files", ({ expect }) => { - const legacy = { - packageFlags: 0, - fileVersionUE5: 1011, - } as unknown as UAssetPackageFileSummary - const reader = new UAssetBufferReader(Buffer.alloc(8)) - - expect(() => readUAssetPropertyTags(reader, legacy, NAMES, 0)).toThrow(/legacy/i) - }) -}) diff --git a/tests/unit/uassets/shapers/blueprint.test.ts b/tests/unit/uassets/shapers/blueprint.test.ts deleted file mode 100644 index f7f5445..0000000 --- a/tests/unit/uassets/shapers/blueprint.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { existsSync, readFileSync } from "node:fs" -import { resolve } from "node:path" -import { parseUAsset } from "@main/lib/uassets/parseUAsset" -import { resolveMainAsset } from "@main/lib/uassets/resolveMainAsset" -import { shapeBlueprint } from "@main/lib/uassets/shapers/blueprint" -import { describe, test } from "vitest" - -/** - * Path to the fixture UAsset file. - */ -const FIXTURE_PATH = resolve(process.cwd(), ".ue5/UAssets/Blueprints/BP_Paticle_Wind_Settings.uasset") - -describe.skipIf(!existsSync(FIXTURE_PATH))("shapeBlueprint: BP_Paticle_Wind_Settings.uasset (UE 5.8)", () => { - const buffer = readFileSync(FIXTURE_PATH) - const pkg = parseUAsset(buffer) - const main = resolveMainAsset(pkg) - const shape = shapeBlueprint(pkg, main) - - test("shape is tagged as a Blueprint and mirrors the fixture asset name", ({ expect }) => { - expect(shape.kind).toBe("blueprint") - expect(shape.name).toBe("BP_Paticle_Wind_Settings") - }) - - test("parentClass resolves to a non-empty class name", ({ expect }) => { - expect(shape.parentClass.length).toBeGreaterThan(0) - }) - - test("components include Arrow (ArrowComponent) and Billboard (BillboardComponent)", ({ expect }) => { - const arrow = shape.components.find(c => c.name === "Arrow") - const billboard = shape.components.find(c => c.name === "Billboard") - expect(arrow?.className).toBe("ArrowComponent") - expect(billboard?.className).toBe("BillboardComponent") - }) - - test("functions and referencedAssets are arrays (possibly empty)", ({ expect }) => { - expect(Array.isArray(shape.functions)).toBe(true) - expect(Array.isArray(shape.referencedAssets)).toBe(true) - }) - - test("renderedSummary embeds the asset name, parent class, and section counts", ({ expect }) => { - expect(shape.renderedSummary).toContain("BP_Paticle_Wind_Settings") - expect(shape.renderedSummary).toContain(`parent=${shape.parentClass}`) - expect(shape.renderedSummary).toContain(`components=${shape.components.length}`) - expect(shape.renderedSummary).toContain(`functions=${shape.functions.length}`) - expect(shape.renderedSummary).toContain(`refs=${shape.referencedAssets.length}`) - }) -}) diff --git a/vitest.config.ts b/vitest.config.ts index 5414010..78563c1 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,6 +8,7 @@ const vitestConfig = defineConfig({ plugins: [tsconfigPaths()], test: { include: ["./tests/unit/**/*.test.ts"], + passWithNoTests: true, }, })