diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 03de2be26..f713ceeca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,9 @@ jobs: - name: TypeScript type check run: pnpm run typecheck:ts + - name: Pack helper tests + run: pnpm run scripts:test + test-python: name: Python Tests (${{ matrix.os }}) runs-on: ${{ matrix.runner }} diff --git a/electron-builder.yml b/electron-builder.yml index b6d1e0783..17b983ae6 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -16,7 +16,13 @@ files: asarUnpack: - dist/splash/** - - node_modules/koffi/** + # koffi 3.x loads ../../@koromix/koffi-${platform}-${arch} from its JS. + # Unpack that package so require() can load the .node from disk, not asar. + - node_modules/@koromix/**/* + +# Also copy the .node to resources/koffi/${platform}_${arch}/ (Koffi's Electron +# lookup). Pack fails if the optional native package is missing on the builder. +afterPack: scripts/copy-koffi-native.cjs extraResources: - from: backend diff --git a/electron/win-dll-search.ts b/electron/win-dll-search.ts index bb17a25a5..ea6de3ced 100644 --- a/electron/win-dll-search.ts +++ b/electron/win-dll-search.ts @@ -9,15 +9,21 @@ export function removeCwdFromDllSearchPath(): void { return } - const require = createRequire(import.meta.url) - const koffi = require('koffi') as { - load: (name: string) => { func: (sig: string) => (...args: unknown[]) => unknown } - } - const kernel32 = koffi.load('kernel32.dll') - const setDllDirectoryW = kernel32.func('int SetDllDirectoryW(str16)') - const ok = setDllDirectoryW('') - if (!ok) { - console.error('[LTX Desktop] SetDllDirectoryW("") failed') + try { + const require = createRequire(import.meta.url) + const koffi = require('koffi') as { + load: (name: string) => { func: (sig: string) => (...args: unknown[]) => unknown } + } + const kernel32 = koffi.load('kernel32.dll') + const setDllDirectoryW = kernel32.func('int SetDllDirectoryW(str16)') + const ok = setDllDirectoryW('') + if (!ok) { + console.error('[LTX Desktop] SetDllDirectoryW("") failed') + } + } catch (err) { + // Missing/blocked koffi.node must not take down the app. Python still + // clears CWD from its own DLL search via PY_REMOVE_CWD_FROM_DLL_SEARCH. + console.error('[LTX Desktop] Failed to clear the Windows DLL search path', err) } } diff --git a/package.json b/package.json index b60c0685d..5a2247709 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ltx-desktop", - "version": "1.2.1", + "version": "1.2.2", "description": "LTX-2 Video Generation - Desktop App", "type": "module", "main": "dist-electron/main.js", @@ -26,6 +26,7 @@ "openapi:generate": "pnpm openapi:export && pnpm openapi:types", "openapi:check": "pnpm openapi:generate && git diff --exit-code -- frontend/generated/backend-openapi.json frontend/generated/backend-openapi.ts", "backend:test": "cd backend && uv sync --frozen --extra test --extra dev && uv run pytest -v --tb=short", + "scripts:test": "node --test scripts/copy-koffi-native.test.cjs", "build": "node scripts/run-script.js scripts/local-build", "build:skip-python": "node scripts/run-script.js scripts/local-build --skip-python", "build:fast": "node scripts/run-script.js scripts/local-build --unpack --skip-python", diff --git a/scripts/copy-koffi-native.cjs b/scripts/copy-koffi-native.cjs new file mode 100644 index 000000000..5b5356c00 --- /dev/null +++ b/scripts/copy-koffi-native.cjs @@ -0,0 +1,75 @@ +#!/usr/bin/env node +// electron-builder afterPack: copy Koffi's platform .node next to the app so +// packaged Windows (and other OS) builds can load it. +// +// koffi 3.x keeps the native addon in an optional sibling package +// (@koromix/koffi-${platform}-${arch}), not inside node_modules/koffi. Packing +// koffi/** therefore ships JS/headers only. At runtime Koffi searches +// process.resourcesPath/koffi/${platform}_${arch}/koffi.node — copy there. +// electron-builder also asarUnpacks node_modules/@koromix/** as a second path. +'use strict' + +const fs = require('node:fs') +const path = require('node:path') +const { createRequire } = require('node:module') + +const ARCH_NAMES = ['ia32', 'x64', 'armv7l', 'arm64', 'universal'] + +function archName(arch) { + if (typeof arch === 'string') { + return arch + } + return ARCH_NAMES[arch] ?? String(arch) +} + +function koffiNativeDest(resourcesDir, platform, arch) { + const triplet = `${platform}_${archName(arch)}` + return path.join(resourcesDir, 'koffi', triplet, 'koffi.node') +} + +function resourcesDir(context) { + const packager = context.packager + if (typeof packager.getResourcesDir === 'function') { + return packager.getResourcesDir(context.appOutDir) + } + if (context.electronPlatformName === 'darwin') { + const name = packager.appInfo.productFilename + return path.join(context.appOutDir, `${name}.app`, 'Contents', 'Resources') + } + return path.join(context.appOutDir, 'resources') +} + +function resolveKoffiNativeSrc(projectDir, platform, arch) { + const pkgName = `@koromix/koffi-${platform}-${archName(arch)}` + // koffi's exports map does not include ./package.json — resolve the entry. + const fromProject = createRequire(path.join(projectDir, 'package.json')) + const koffiEntry = fromProject.resolve('koffi') + const fromKoffi = createRequire(koffiEntry) + const nativeEntry = fromKoffi.resolve(pkgName) + const src = path.join(path.dirname(nativeEntry), `${platform}_${archName(arch)}`, 'koffi.node') + if (!fs.existsSync(src)) { + throw new Error(`Koffi native binary missing at ${src} (package ${pkgName})`) + } + return src +} + +async function copyKoffiNative(context) { + const platform = context.electronPlatformName + const arch = archName(context.arch) + const projectDir = context.packager.projectDir + const dest = koffiNativeDest(resourcesDir(context), platform, arch) + const src = resolveKoffiNativeSrc(projectDir, platform, arch) + + fs.mkdirSync(path.dirname(dest), { recursive: true }) + fs.copyFileSync(src, dest) + console.log(`[copy-koffi-native] ${src} → ${dest}`) +} + +module.exports = { + default: copyKoffiNative, + archName, + koffiNativeDest, + resourcesDir, + resolveKoffiNativeSrc, + copyKoffiNative, +} diff --git a/scripts/copy-koffi-native.test.cjs b/scripts/copy-koffi-native.test.cjs new file mode 100644 index 000000000..7894300ec --- /dev/null +++ b/scripts/copy-koffi-native.test.cjs @@ -0,0 +1,54 @@ +'use strict' + +const assert = require('node:assert/strict') +const path = require('node:path') +const { describe, it } = require('node:test') +const { archName, koffiNativeDest, resourcesDir } = require('./copy-koffi-native.cjs') + +describe('archName', () => { + it('passes string arch through', () => { + assert.equal(archName('x64'), 'x64') + }) + + it('maps electron-builder Arch enum integers', () => { + assert.equal(archName(1), 'x64') + assert.equal(archName(3), 'arm64') + }) +}) + +describe('koffiNativeDest', () => { + it('matches Koffi resourcesPath lookup win32_x64/koffi.node', () => { + assert.equal( + koffiNativeDest(path.join('app', 'resources'), 'win32', 'x64'), + path.join('app', 'resources', 'koffi', 'win32_x64', 'koffi.node'), + ) + }) +}) + +describe('resourcesDir', () => { + it('uses packager.getResourcesDir when present', () => { + const dir = resourcesDir({ + appOutDir: '/out', + packager: { getResourcesDir: (appOutDir) => `${appOutDir}/R` }, + }) + assert.equal(dir, '/out/R') + }) + + it('falls back to Contents/Resources on darwin', () => { + const dir = resourcesDir({ + appOutDir: '/out', + electronPlatformName: 'darwin', + packager: { appInfo: { productFilename: 'LTX Desktop' } }, + }) + assert.equal(dir, path.join('/out', 'LTX Desktop.app', 'Contents', 'Resources')) + }) + + it('falls back to resources/ on win32', () => { + const dir = resourcesDir({ + appOutDir: '/out', + electronPlatformName: 'win32', + packager: { appInfo: { productFilename: 'LTX Desktop' } }, + }) + assert.equal(dir, path.join('/out', 'resources')) + }) +})