fix(release): stop shipping native modules built for the wrong runtime
Some checks failed
deploy-site / deploy (push) Failing after 14m16s

The released installer could not start: it carried a better-sqlite3 build for the
host Node runtime instead of Electron, so the app died immediately with a module
version mismatch when it opened its database.

Packaging now proves the Electron build of every runtime-sensitive native module
before an installer or archive exists, and installers are produced only from that
verified tree, so the mistake cannot pass silently. The release pipelines run the
same check.

The default local model also pointed at a retired model: a *.gguf name that
Ollama cannot serve, while the settings, onboarding, and guide screens
recommended an older model. All of them now use the model the service code
already preferred.
This commit is contained in:
Yun Chan 2026-09-18 15:45:03 +09:00
parent 0fbbbc1756
commit 1af3cf75c7
42 changed files with 473 additions and 83 deletions

View file

@ -64,6 +64,7 @@ jobs:
npm run build --workspace=@d3ro/desktop
Push-Location apps/desktop
npx electron-builder --win --x64 --config electron-builder.yml --publish never
node scripts/ci/verify-native-abi.mjs
Pop-Location
- name: Windows 산출물 검증

View file

@ -138,6 +138,7 @@ jobs:
Push-Location apps/desktop
try {
npx electron-builder --win --x64 --config electron-builder.yml
node scripts/ci/verify-native-abi.mjs
if ($LASTEXITCODE -ne 0) { throw "electron-builder failed with exit code $LASTEXITCODE." }
}
finally {
@ -201,6 +202,7 @@ jobs:
run: |
cd apps/desktop
npx electron-builder --mac --arm64 --config electron-builder.yml
node scripts/ci/verify-native-abi.mjs
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CSC_LINK: ${{ secrets.MAC_CSC_LINK }}

View file

@ -251,6 +251,7 @@ package-windows:
- npm run build --workspace=@d3ro/desktop
- cd apps/desktop
- npx electron-builder --win --x64 --config electron-builder.yml
- node scripts/ci/verify-native-abi.mjs
- cd ../..
- $releaseVersion = node -p "require('./release/product-version.json').version"
- '& scripts/ci/verify-windows-release-artifact.ps1 -ExpectedVersion $releaseVersion -ExpectedSignerSubject $env:WIN_CSC_EXPECTED_SIGNER_SUBJECT -ReleaseDirectory "apps/desktop/release/$releaseVersion"'
@ -282,6 +283,7 @@ package-macos:
- npm run build --workspace=@d3ro/desktop
- cd apps/desktop
- npx electron-builder --mac --arm64 --config electron-builder.yml
- node scripts/ci/verify-native-abi.mjs
artifacts:
name: "d3ro-voice-macos-$CI_COMMIT_TAG"
paths:

View file

@ -13,6 +13,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Cloud-optional backup (encrypted, opt-in)
- Plugin system for custom pipelines
## [1.3.3] - 2026-09-18
### Fixed
- **The released installer could not start**: it carried a `better-sqlite3` build for the
host Node runtime instead of Electron, so the app failed immediately with a
`NODE_MODULE_VERSION` mismatch when opening its database. Packaging now guarantees and
verifies the Electron build of every runtime-sensitive native module before an installer
or archive is produced, and publishes only from that verified tree.
- **The local language model default still pointed at a retired model**: the stored default
was a `*.gguf` file name that Ollama cannot serve, and the settings, onboarding, and
Ollama guide still recommended `gemma2:2b` while the service code already preferred
`gemma4:e4b`. All surfaces now default to `gemma4:e4b`.
## [1.3.2] - 2026-09-18
### Changed

View file

@ -3,7 +3,7 @@
"info": {
"title": "D3RO-VOICE Admin API",
"description": "Admin CRM Edge Functions for user management, subscription CRUD, payment history, and audit logs.",
"version": "1.3.2"
"version": "1.3.3"
},
"servers": [
{

View file

@ -1,6 +1,6 @@
{
"name": "@d3ro/admin",
"version": "1.3.2",
"version": "1.3.3",
"private": true,
"description": "D3RO Voice Admin CRM — SaaS 관리 도구",
"scripts": {

View file

@ -2,7 +2,7 @@
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Version>1.3.2</Version>
<Version>1.3.3</Version>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

View file

@ -20,6 +20,8 @@ files:
- "!out/**/*.map"
# ffmpeg 정적 바이너리(61MB)는 설치본에 넣지 않는다 — 필요할 때 런타임으로 내려받는다
- "!node_modules/@ffmpeg-installer/**"
# ffmpeg 정적 바이너리(61MB)는 설치본에 넣지 않는다 — 필요할 때 런타임으로 내려받는다
- "!node_modules/@ffmpeg-installer/**"
# ────────────────────────────────────────────────────────────────────
# 자동 업데이트 feed — 이 설정이 있어야 electron-builder가

View file

@ -1,6 +1,6 @@
{
"name": "@d3ro/desktop",
"version": "1.3.2",
"version": "1.3.3",
"productName": "d3ro-voice",
"description": "로컬 AI 음성 어시스턴트 (Electron)",
"main": "./out/main/index.js",

View file

@ -42,7 +42,7 @@ const CONFIG_DEFAULTS: AppConfig = {
ttsSpeed: 1.0,
onlineApiUrl: 'http://127.0.0.1:5000',
localModelsDir: '',
llmModelId: 'gemma-2-2b-it.Q4_K_M.gguf',
llmModelId: 'gemma4:e4b',
ollamaServerUrl: 'http://127.0.0.1:11434',
appUsageMode: null,
authToken: null,

View file

@ -663,7 +663,7 @@ class LocalLLMService extends EventEmitter {
return {
connectionState,
serverUrl: getOllamaServerUrl(),
activeModel: configGet('llmModelId') || 'gemma2:2b',
activeModel: configGet('llmModelId') || 'gemma4:e4b',
serverVersion: this._serverVersion
}
}
@ -686,7 +686,7 @@ class LocalLLMService extends EventEmitter {
}
const serverUrl = getOllamaServerUrl()
const model = options?.model ?? configGet('llmModelId') ?? 'gemma2:2b'
const model = options?.model ?? configGet('llmModelId') ?? 'gemma4:e4b'
this._abortController = new AbortController()
this._state = LLMState.Generating

View file

@ -146,7 +146,7 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
const { t } = useI18n()
const [llmStatus, setLlmStatus] = useState<LLMStatus | null>(null)
const [installedModels, setInstalledModels] = useState<LLMModel[]>([])
const [activeModel, setActiveModel] = useState<string>('gemma2:2b')
const [activeModel, setActiveModel] = useState<string>('gemma4:e4b')
const [checking, setChecking] = useState(false)
const [starting, setStarting] = useState(false)
const [startMessage, setStartMessage] = useState<string | null>(null)
@ -582,7 +582,7 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
<Typography sx={{ fontSize: '11px', color: d3roPalette.text.dimLabel }}>
:
</Typography>
<CodeBlock code="ollama pull gemma2:2b" />
<CodeBlock code="ollama pull gemma4:e4b" />
</Box>
</Box>

View file

@ -49,7 +49,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
// Local Ollama State
const [ollamaStatus, setOllamaStatus] = useState<LLMStatus | null>(null)
const [installedModels, setInstalledModels] = useState<LLMModel[]>([])
const [activeModel, setActiveModel] = useState<string>('gemma2:2b')
const [activeModel, setActiveModel] = useState<string>('gemma4:e4b')
const [checkingOllama, setCheckingOllama] = useState(false)
const [startingOllama, setStartingOllama] = useState(false)
const [ollamaMsg, setOllamaMsg] = useState<string | null>(null)
@ -498,7 +498,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
<Button
variant="contained"
startIcon={<Download size={14} />}
onClick={() => handlePullModel('gemma2:2b')}
onClick={() => handlePullModel('gemma4:e4b')}
disabled={!isOllamaConnected}
sx={{
fontWeight: 500,

View file

@ -723,7 +723,7 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
: 'Ollama 오프라인 (서버 실행 필요)'}
</Typography>
<Typography sx={{ fontSize: '11px', color: d3roPalette.text.secondary }}>
: {ollamaModels.length} | : {config.llmModelId ?? 'gemma2:2b'}
: {ollamaModels.length} | : {config.llmModelId ?? 'gemma4:e4b'}
</Typography>
</Box>
</Box>
@ -800,7 +800,7 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
<InputLabel> Ollama </InputLabel>
<Select
label="활성 Ollama 모델"
value={config.llmModelId ?? 'gemma2:2b'}
value={config.llmModelId ?? 'gemma4:e4b'}
onChange={(e) => {
const val = e.target.value
updateConfig('llmModelId', val)
@ -815,7 +815,7 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
))}
{/* 설치된 모델 목록이 없거나 기본 추천 추가 */}
{ollamaModels.length === 0 && (
<MenuItem value="gemma2:2b">gemma2:2b ()</MenuItem>
<MenuItem value="gemma4:e4b">gemma4:e4b ()</MenuItem>
)}
{!ollamaModels.some((m) => m.name === 'llama3.2:3b') && (
<MenuItem value="llama3.2:3b">llama3.2:3b</MenuItem>

View file

@ -151,8 +151,8 @@ def versionSettingsValid = configuredVersionName != null &&
configuredVersionName ==~ strictSemver &&
configuredVersionCodeValue != null &&
configuredVersionCodeValue <= 2100000000L
def resolvedVersionName = versionSettingsValid ? configuredVersionName : "1.3.2"
def resolvedVersionCode = versionSettingsValid ? configuredVersionCodeValue.toInteger() : 1031002
def resolvedVersionName = versionSettingsValid ? configuredVersionName : "1.3.3"
def resolvedVersionCode = versionSettingsValid ? configuredVersionCodeValue.toInteger() : 1031003
def requiredReleaseSettings = [
D3RO_RELEASE_STORE_FILE: releaseStoreFilePath,

View file

@ -257,7 +257,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 1031002;
CURRENT_PROJECT_VERSION = 1031003;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = D3ROVoice/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
@ -265,7 +265,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.3.2;
MARKETING_VERSION = 1.3.3;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@ -287,14 +287,14 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 1031002;
CURRENT_PROJECT_VERSION = 1031003;
INFOPLIST_FILE = D3ROVoice/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.3.2;
MARKETING_VERSION = 1.3.3;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",

View file

@ -0,0 +1 @@
Fixed an installer that could not start, and updated the default local model to the current one.

View file

@ -0,0 +1 @@
실행이 안 되던 설치 문제를 고쳤습니다. 로컬 모델 기본값도 최신 모델로 정리했습니다.

View file

@ -1,12 +1,12 @@
{
"name": "@d3ro/mobile-rn",
"version": "1.3.2",
"version": "1.3.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@d3ro/mobile-rn",
"version": "1.3.2",
"version": "1.3.3",
"dependencies": {
"@d3ro/api-client": "file:../../packages/api-client",
"@d3ro/core": "file:../../packages/core",
@ -62,7 +62,7 @@
},
"../..": {
"name": "d3ro-voice-monorepo",
"version": "1.3.2",
"version": "1.3.3",
"license": "MIT",
"workspaces": [
"apps/desktop",
@ -81,7 +81,7 @@
},
"../../packages/api-client": {
"name": "@d3ro/api-client",
"version": "1.3.2",
"version": "1.3.3",
"license": "MIT",
"dependencies": {
"@d3ro/core": "*",
@ -98,7 +98,7 @@
},
"../../packages/core": {
"name": "@d3ro/core",
"version": "1.3.2",
"version": "1.3.3",
"license": "MIT",
"dependencies": {
"docx": "^9.6.1"
@ -109,7 +109,7 @@
},
"../../packages/i18n": {
"name": "@d3ro/i18n",
"version": "1.3.2",
"version": "1.3.3",
"license": "MIT",
"devDependencies": {
"@types/react": "^19.0.0"
@ -120,7 +120,7 @@
},
"../../packages/ui-native": {
"name": "@d3ro/ui-native",
"version": "1.3.2",
"version": "1.3.3",
"license": "MIT",
"devDependencies": {
"@types/react": "*"

View file

@ -1,6 +1,6 @@
{
"name": "@d3ro/mobile-rn",
"version": "1.3.2",
"version": "1.3.3",
"private": true,
"scripts": {
"android": "react-native run-android",

View file

@ -1,6 +1,6 @@
{
"name": "@d3ro/web",
"version": "1.3.2",
"version": "1.3.3",
"private": true,
"description": "D3RO Voice 웹 앱 — Next.js 기반 SaaS 인터페이스",
"scripts": {

View file

@ -139,7 +139,7 @@ export function Sidebar(): React.ReactElement {
</PhosphorText>
</Box>
<Box sx={{ fontSize: '10px', fontFamily: 'ui-monospace, monospace', color: d3roPalette.text.muted }}>
v1.3.2
v1.3.3
</Box>
</Box>

View file

@ -1,15 +1,15 @@
{
"version": "1.3.2",
"version": "1.3.3",
"description": "로컬 AI 음성 어시스턴트 (faster-whisper + Ollama, 100% 오프라인 지원)",
"homepage": "https://d3ro.chanpaca.net",
"license": "MIT",
"architecture": {
"64bit": {
"url": [
"https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/portable-1.3.2/D3RO-Voice-1.3.2-x64-portable.7z.001"
"https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/portable-1.3.3/D3RO-Voice-1.3.3-x64-portable.7z.001"
],
"hash": [
"62c1fab862c542839cd64c795e5714fda3e5332f0be22a051137f42ceb400324"
"b4bb847a7f25732cf0abf8516f6c708d6d9e6ab7d1fd813abf7795a0466c17b0"
]
}
},
@ -27,7 +27,7 @@
"architecture": {
"64bit": {
"url": [
"https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/portable-$version/D3RO-Voice-1.3.2-x64-portable.7z.001"
"https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/portable-$version/D3RO-Voice-1.3.3-x64-portable.7z.001"
]
}
}

View file

@ -156,6 +156,11 @@ npm run release:updater -- --ack-unsigned # (인증서 없을 때만)
- 서명 인증서가 있으면 `--ack-unsigned` 없이 게시한다(권장). 무서명 게시는 명시적 예외이며
스크립트가 플래그 없이는 즉시 실패한다(GAP-REL-06에 기록).
- **네이티브 ABI 게이트 필수**: 패키징 후 `node scripts/ci/verify-native-abi.mjs`로 확인한다.
개발 PC에서 `npm install`을 돌리면 `better-sqlite3`가 Node ABI로 재빌드되어 설치본이
시작조차 못 한다(실측: 1.3.2). 로컬에서 실행 중인 Electron이 모듈을 잠그면
`node scripts/ci/fix-native-abi.mjs --dir <win-unpacked>`로 Electron ABI를 주입한 뒤
`--prepackaged`로 설치본을 만든다(검증된 트리에서만 패키징).
- 런타임은 `runtime-latest/runtime.json`이 정본이고 앱이 처음 필요할 때 내려받는다.
런타임을 바꾸면 반드시 `release:portable`로 먼저 게시한 뒤 설치본을 게시한다.

View file

@ -195,6 +195,7 @@ Status quick-reference: `[x]` done+verified · `[~]` partial/unverified · `[ ]`
| INFRA-16 | Desktop STT engine packaging | [x] | `apps/desktop/scripts/setup-sidecar.mjs` + `build-sidecar.mjs`, `electron-builder.yml` `extraResources` (`sidecar-dist/sidecar``resources/sidecar`, `resources/ffmpeg`), and `scripts/ci/verify-sidecar-bundle.mjs` run in `package-windows`/`package-macos` before electron-builder. Verified on the real bundle: `sidecar.exe` + `_internal` including `faster_whisper/assets/silero_vad_v6.onnx`, plus a packaged-engine transcription round-trip on GPU. |
| INFRA-17 | 서명 없는 배포 채널 (portable + Scoop) | [x] | `scripts/ci/build-portable.mjs` (95MiB 7z 분할 볼륨 + Scoop 매니페스트), `scripts/ci/publish-portable-release.mjs`, `scripts/local/install-d3ro-voice.ps1`, `bucket/` 버킷, `.forgejo/workflows/portable.yml`; 7z 분할 볼륨(Scoop, 162MiB) + zip 분할 부품(수동 설치, 243MiB, 7-Zip 불필요); updater feed와 분리. 2026-09-18 `portable-1.3.1` 게시 + 실제 설치 검증. |
| INFRA-18 | 로컬 런타임 온디맨드 설치 | [x] | `RuntimeProvisioner`(부품 다운로드 + SHA-256 검증 + tar 해제, `%APPDATA%/d3ro-voice/runtime`), `POST runtime:ensure` / `runtime:progress` IPC, 설정 > STT 상태/내려받기 UI. 설치본에서 엔진/ffmpeg를 분리해 189MB → 90.6MiB, 업데이트 피드 게시 복구. 2026-09-18 실제 feed 통합 검증(엔진 94.4MiB/17초, ffmpeg 21.7MiB/5초). |
| INFRA-19 | 네이티브 ABI 패키징 게이트 | [x] | `scripts/ci/verify-native-abi.mjs`(패키징된 `better_sqlite3.node`가 Electron ABI인지 호스트 Node 로드 거부로 판별) + `scripts/ci/fix-native-abi.mjs`(로컬 잠금 우회용 주입). GitLab/Forgejo/GitHub 패키징 단계에 검증 삽입. 2026-09-18: Node ABI 모듈이 설치본에 들어가 앱이 시작 즉시 죽은 사고의 재발 방지. |
---

View file

@ -24,6 +24,7 @@ Legend: `[ ]` open · `[~]` in progress · `[!]` blocked externally · `[x]` res
| GAP-REL-01 | Release | Official release publication to Forgejo and active public download center deployment. | `scripts/ci/publish-forgejo-release.mjs`, `apps/web/src/app/download/page.tsx`, `site/src/sections/Download.tsx`, `apps/web/e2e/red_team_cycle4_web.spec.ts` | `[~]` 2026-09-15: v1.1.0 release assets (`D3RO-Voice-Setup-1.1.0-x64.exe`, `.blockmap`, `latest.yml`, `update-policy.json`) published to canonical Forgejo registry and release hub. 2026-09-16: the published 1.1.0 installer carries no Authenticode signature, so it does not satisfy the release policy; product version moved to `1.2.0` and publication must come from CI with the signing gate GREEN. Download centers in `apps/web` (`/download`) and `site` (`#download`) link the canonical Forgejo feed. |
| GAP-REL-02 | Release | Windows stable publication needs an external public-trust Authenticode PFX, its password, the exact signer subject, and a Forgejo token, none of which live in the repository. | `.forgejo/workflows/release.yml`, `.gitlab-ci.yml`, `scripts/ci/set-forgejo-secrets.mjs`, `scripts/ci/verify-windows-release-artifact.ps1` | `[!]` 2026-09-18 measured: the Forgejo repo had **zero** Actions secrets; `FORGEJO_TOKEN` is registered now (2026-09-18) but `WIN_CSC_*` still have no values, so `v1.2.0` (run 49) and `v1.3.0` (run 51) both failed at the signing guard and **no updater-feed release has been published since `1.1.0`**. Inject the four secrets (`WIN_CSC_LINK`, `WIN_CSC_KEY_PASSWORD`, `WIN_CSC_EXPECTED_SIGNER_SUBJECT`, `FORGEJO_TOKEN`) with `npm run release:secrets` (check: `npm run release:secrets:check`), then re-run `release.yml` for the `v1.3.0` tag via `workflow_dispatch` (tags are immutable). |
| GAP-REL-06 | Release | 서명 인증서가 없어 stable(`latest`) 채널에 **무서명** 설치본을 게시했다. electron-updater는 `app-update.yml``publisherName`이 없으면 서명 검증을 건너뛰므로 설치는 동작하지만, SmartScreen 평판은 버전마다 0부터 시작한다. | `scripts/ci/publish-updater-release.mjs`, `release/update-policy.json`, `.forgejo/workflows/release.yml` | `[!]` 2026-09-18: `1.3.2``--ack-unsigned`(명시적 승인 플래그)로 게시. 인증서 확보 시 더 높은 버전으로 서명 게시하여 대체하고, 이 예외를 제거한다. |
| GAP-REL-07 | Release | 패키징된 `better-sqlite3`가 호스트 Node ABI여서 `1.3.2` 설치본이 시작 즉시 죽었다(NODE_MODULE_VERSION 131 vs 130). 원인: 로컬 `npm install`이 네이티브 모듈을 Node용으로 재빌드했고 패키징이 재빌드를 건너었다. | `scripts/ci/verify-native-abi.mjs`, `scripts/ci/fix-native-abi.mjs`, `.gitlab-ci.yml`/`.forgejo`/`.github` 패키징 단계 | `[x]` 2026-09-18: 패키징 후 Electron ABI를 검증하고, 검증된 트리에서만 설치본을 생성(`--prepackaged`)한다. `1.3.3`은 설치본에서 추출한 바이너리로 재검증 GREEN. |
| GAP-ADS-01 | Ads | 9 of 10 desktop ad adapters still extend `UnavailableAdAdapter` (`provider_not_integrated`). | `apps/desktop/src/main/services/ads/*` | `[~]` 2026-09-13: `DirectHouseSponsorAdapter` is now a real configurable REST adapter (bid/impression/click/reward via `endpointUrl`; fail-closed when unconfigured; 22 unit tests GREEN). Remaining 9 need official SDKs/authenticated endpoints. |
| GAP-ADS-02 | Ads | Desktop mediation reward accounting is not wired to license quota (`claimReward` still returns no tokens). | `AdMediationEngine.ts`, `AppLayout.tsx` | Wire verified `reportRewardCompletion` to `LicenseService` quota after the direct sponsor endpoint exists. |
| GAP-ID-01 | Identity | Supabase, .NET JWT/SQLite, and the desktop offline license each had their own tier/role shape. | `@d3ro/core/entitlement`, `LicenseService`, `entitlement-context` | `[~]` 2026-09-13: canonical `EntitlementSnapshot` + `resolveEntitlement` added with tests; desktop tier normalization + `isPro` fixed. Full adoption tracked as GAP-ID-02. |

View file

@ -1,5 +1,24 @@
# D3RO-VOICE 프로젝트 현황
## v1.3.3 — 배포본 시작 실패(네이티브 ABI) + gemma4 기본값 (2026-09-18) 🛠️
배포한 1.3.2 설치본이 **시작 즉시 죽었다**: `NODE_MODULE_VERSION 131 ... requires 130`.
- 원인: 로컬에서 `npm install`(ffmpeg/tar)을 돌리면서 `better-sqlite3`가 **Node ABI(131)**로 재빌드됐고,
패키징이 네이티브 재빌드를 건너뛰어(npmRebuild=false) 그 모듈이 설치본에 들어갔다. 또 그 설치는
예전 경로(`Programs\@d3rodesktop`)를 덮어써 1.0.0을 망가뜨렸다.
- 대책: `scripts/ci/verify-native-abi.mjs`(패키징된 모듈이 Electron ABI인지 호스트 Node 로드 거부로
판별) + `fix-native-abi.mjs`(로컬 잠금 우회 주입). 설치본은 **검증된 트리에서만** 생성(`--prepackaged`).
GitLab/Forgejo/GitHub 패키징 단계에 게이트 삽입. 검증: 1.3.3 설치본을 7za로 풀어 그 안의 바이너리로
재검증 → Electron ABI GREEN, 피드 sha512가 로컬 빌드와 일치.
- 함께 수정: 로컬 모델 기본값이 레거시 `*.gguf`였고 UI가 `gemma2:2b`를 추천하던 것을 **gemma4:e4b**로
통일(ConfigService 기본값, LocalLLMService 폴백/상태, 설정·온보딩·Ollama 가이드).
- `publish-*`의 "동일 파일 건너뛰기"가 **크기만 비교**해 버전만 바뀐 `latest.yml`을 놓쳤다(피드가 1.3.2로
남는 사고) → 1MiB 이하는 내용까지 비교하도록 수정.
- 배포: `1.3.3` 업데이터 피드 게시 완료(`latest.yml` = 1.3.3). 기존 설치본은 1.3.3을 1회 수동 설치하면 이후 자동.
---
## v1.3.2 — 자동 업데이트 복구: 런타임을 설치본에서 분리 (2026-09-18) 🔄
자동 업데이트가 왜 안 되는지 끝까지 추적했다. 원인은 서명만이 아니라 **업로드 크기 한도**였다.

20
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "d3ro-voice-monorepo",
"version": "1.3.2",
"version": "1.3.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "d3ro-voice-monorepo",
"version": "1.3.2",
"version": "1.3.3",
"license": "MIT",
"workspaces": [
"apps/desktop",
@ -25,7 +25,7 @@
},
"apps/admin": {
"name": "@d3ro/admin",
"version": "1.3.2",
"version": "1.3.3",
"dependencies": {
"@d3ro/api-client": "*",
"@d3ro/core": "*",
@ -109,7 +109,7 @@
},
"apps/desktop": {
"name": "@d3ro/desktop",
"version": "1.3.2",
"version": "1.3.3",
"license": "MIT",
"dependencies": {
"@d3ro/core": "*",
@ -158,7 +158,7 @@
},
"apps/web": {
"name": "@d3ro/web",
"version": "1.3.2",
"version": "1.3.3",
"dependencies": {
"@d3ro/api-client": "*",
"@d3ro/core": "*",
@ -16861,7 +16861,7 @@
},
"packages/api-client": {
"name": "@d3ro/api-client",
"version": "1.3.2",
"version": "1.3.3",
"license": "MIT",
"dependencies": {
"@d3ro/core": "*",
@ -16878,7 +16878,7 @@
},
"packages/core": {
"name": "@d3ro/core",
"version": "1.3.2",
"version": "1.3.3",
"license": "MIT",
"dependencies": {
"docx": "^9.6.1"
@ -16889,7 +16889,7 @@
},
"packages/i18n": {
"name": "@d3ro/i18n",
"version": "1.3.2",
"version": "1.3.3",
"license": "MIT",
"devDependencies": {
"@types/react": "^19.0.0"
@ -16900,7 +16900,7 @@
},
"packages/ui": {
"name": "@d3ro/ui",
"version": "1.3.2",
"version": "1.3.3",
"license": "MIT",
"dependencies": {
"@d3ro/core": "*"
@ -16920,7 +16920,7 @@
},
"packages/ui-native": {
"name": "@d3ro/ui-native",
"version": "1.3.2",
"version": "1.3.3",
"license": "MIT",
"devDependencies": {
"@types/react": "*"

View file

@ -1,6 +1,6 @@
{
"name": "d3ro-voice-monorepo",
"version": "1.3.2",
"version": "1.3.3",
"private": true,
"description": "D3RO Voice — 멀티플랫폼 AI 음성 어시스턴트 (Monorepo)",
"author": "D3RO",

View file

@ -1,6 +1,6 @@
{
"name": "@d3ro/api-client",
"version": "1.3.2",
"version": "1.3.3",
"private": true,
"description": "D3RO Voice API 클라이언트 — Supabase 래퍼 (web/desktop/mobile 공유)",
"license": "MIT",

View file

@ -1,6 +1,6 @@
{
"name": "@d3ro/core",
"version": "1.3.2",
"version": "1.3.3",
"private": true,
"description": "D3RO Voice 공유 비즈니스 로직 — 타입, 에러, IPC 채널, 상수, 유틸",
"license": "MIT",

View file

@ -1,6 +1,6 @@
{
"name": "@d3ro/i18n",
"version": "1.3.2",
"version": "1.3.3",
"private": true,
"description": "D3RO Voice 공유 i18n — 12개 locale, 타입 안전 키, Context, 포맷 유틸",
"license": "MIT",

View file

@ -1,6 +1,6 @@
{
"name": "@d3ro/ui-native",
"version": "1.3.2",
"version": "1.3.3",
"private": true,
"description": "D3RO Voice React Native DS — MetalCard/PhosphorText/Led/WaveBars etc.",
"license": "MIT",

View file

@ -1,6 +1,6 @@
{
"name": "@d3ro/ui",
"version": "1.3.2",
"version": "1.3.3",
"private": true,
"description": "D3RO Voice 공유 UI — 디자인 시스템 컴포넌트 + 테마 토큰 + CSS 변수 맵",
"license": "MIT",

View file

@ -1,8 +1,8 @@
{
"schemaVersion": 1,
"version": "1.3.2",
"androidVersionCode": 1031002,
"iosBuildNumber": 1031002,
"version": "1.3.3",
"androidVersionCode": 1031003,
"iosBuildNumber": 1031003,
"releaseDate": "2026-09-18",
"desktopLicensePublicKeyId": "5c52b765135ee2531c681b53cd4dc0e96fff8737f496c0b04ba8334fc81a887f"
}

View file

@ -78,6 +78,20 @@ if (!existsSync(join(desktopDir, 'sidecar-dist', 'sidecar'))) {
process.exit(1)
}
/** 현재 스크립트와 같은 Node로 CI 스크립트를 실행한다 */
function runNodeScript(relativePath, scriptArgs) {
console.log(`[portable] $ node ${relativePath} ${scriptArgs.join(' ')}`)
const result = spawnSync(
process.execPath,
[join(root, relativePath), ...scriptArgs],
{ cwd: root, stdio: 'inherit' },
)
if (result.status !== 0) {
console.error(`[portable] ${relativePath} 실패 (exit ${result.status ?? 'null'})`)
process.exit(result.status ?? 1)
}
}
function resolve7za() {
// electron-builder가 의존하는 7zip-bin이 플랫폼별 7za 실행 파일을 제공한다.
const platformDir =
@ -128,6 +142,11 @@ if (!existsSync(appDir)) {
process.exit(1)
}
// 네이티브 모듈 ABI 사고 방지: 호스트 Node ABI로 빌드된 모듈이 섞이면 설치본이 시작조차 못 한다.
// (실측 사고: better_sqlite3.node가 NODE_MODULE_VERSION 131 → Electron 130 요구)
runNodeScript('scripts/ci/fix-native-abi.mjs', ['--dir', appDir])
runNodeScript('scripts/ci/verify-native-abi.mjs', ['--dir', appDir])
// 이전 볼륨 정리 (같은 버전 재드 시 잔여 볼륨이 섞이지 않도록)
for (const name of readdirSync(releaseDir)) {
if (name.startsWith(ARCHIVE_BASE)) {
@ -251,6 +270,9 @@ const zipBuild = spawnSync(
'npx',
[
'electron-builder',
// 검증된 트리에서 바로 패키징한다(ne ABI가 확실한 디렉토리만 사용).
'--prepackaged',
appDir,
'--win',
'zip',
'--x64',

View file

@ -0,0 +1,119 @@
// scripts/ci/fix-native-abi.mjs
// 패키징된 앱 트리에 Electron ABI 네이티브 모듈을 보장한다.
//
// 필요한가:
// - better-sqlite3는 V8 내부 API에 의존해 런타임별 ABI(NODE_MODULE_VERSION)가 다르다.
// 개발 PC에서 `npm install`을 돌리면 Node ABI로 재빌드되고, 그 상태로 패키징하면
// 설치본이 시작하자마자 "NODE_MODULE_VERSION 131 ... requires 130"으로 죽는다(실측 사고).
// - CI에서는 electron-builder의 npmRebuild가 이를 처리하지만, 로컬에서는 실행 중인 Electron이
// node_modules 파일을 잠그고 있어 재빌드가 EPERM으로 실패할 수 있다.
// 그래서 "패키징된 트리"에 정확한 ABI 바이너리를 직접 넣는다(원본 node_modules는 건드리지 않는다).
//
// 동작: 임시 사본에서 prebuild-install로 Electron용 프리빌드를 받아 패키징 트리에 복사한 뒤,
// 호스트 Node가 그 모듈을 거부하는지(= Electron ABI) 확인한다.
//
// 사용:
// node scripts/ci/fix-native-abi.mjs --dir <packagedDir>
import { spawnSync } from 'node:child_process'
import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
const dirFlagIndex = process.argv.indexOf('--dir')
const packagedDir = dirFlagIndex >= 0 ? process.argv[dirFlagIndex + 1] : null
if (!packagedDir || !existsSync(packagedDir)) {
console.error('사용: node scripts/ci/fix-native-abi.mjs --dir <packagedDir>')
process.exit(1)
}
const sourcePackageJson = JSON.parse(
readFileSync(join(root, 'apps', 'desktop', 'package.json'), 'utf8'),
)
const electronVersion =
sourcePackageJson.devDependencies?.electron?.replace(/[^0-9.]/g, '') ?? '33.4.11'
const arch = process.arch === 'arm64' ? 'arm64' : 'x64'
const relativeBinary = join(
'node_modules',
'better-sqlite3',
'build',
'Release',
'better_sqlite3.node',
)
const targetBinary = join(packagedDir, 'resources', 'app.asar.unpacked', relativeBinary)
if (!existsSync(targetBinary)) {
console.error(`[native-abi] 패키징 트리에 better-sqlite3 바이너리가 없습니다: ${targetBinary}`)
process.exit(1)
}
/** 호스트 Node로 로드되면 Electron ABI가 아니다 */
function hostNodeLoads(binaryPath) {
const probe = spawnSync(process.execPath, ['-e', `require(${JSON.stringify(binaryPath)})`], {
encoding: 'utf8',
})
return probe.status === 0
}
if (!hostNodeLoads(targetBinary)) {
console.log('[native-abi] 이미 Electron ABI 바이너리입니다 — 수정 불필요')
process.exit(0)
}
console.log('[native-abi] 호스트 Node ABI로 빌드된 모듈을 찾았습니다 → Electron ABI로 교체합니다')
const scratchRoot = join(root, '.tmp', 'native-abi')
const scratchModule = join(scratchRoot, 'better-sqlite3')
// 임시 사본 준비 (원본 node_modules는 잠겨 있을 수 있으므로 복사해서 작업)
rmSync(scratchRoot, { recursive: true, force: true })
mkdirSync(scratchRoot, { recursive: true })
const copy = spawnSync(
process.platform === 'win32' ? 'cmd' : 'cp',
process.platform === 'win32'
? ['/c', 'xcopy', join(root, 'node_modules', 'better-sqlite3'), scratchModule, '/E', '/I', '/Q', '/Y']
: ['-r', join(root, 'node_modules', 'better-sqlite3'), scratchModule],
{ stdio: 'inherit' },
)
if (copy.status !== 0) {
console.error('[native-abi] better-sqlite3 사본 생성 실패')
process.exit(copy.status ?? 1)
}
const prebuild = spawnSync(
process.platform === 'win32' ? 'npx.cmd' : 'npx',
[
'--no-install',
'prebuild-install',
`--runtime=electron`,
`--target=${electronVersion}`,
`--arch=${arch}`,
'--force',
],
{ cwd: scratchModule, stdio: 'inherit', shell: process.platform === 'win32' },
)
const scratchBinary = join(scratchModule, 'build', 'Release', 'better_sqlite3.node')
if (prebuild.status !== 0 || !existsSync(scratchBinary)) {
console.error(
[
`[native-abi] Electron ${electronVersion} 프리빌드를 받지 못했습니다.`,
' 대안: CI에서 electron-builder의 npmRebuild=true로 빌드하세요(권장).',
' 로컬에서 계속하려면 실행 중인 Electron을 모두 종료한 뒤 다음을 실행하세요:',
` npx @electron/rebuild -v ${electronVersion} -m better-sqlite3`,
].join('\n'),
)
process.exit(1)
}
if (hostNodeLoads(scratchBinary)) {
console.error('[native-abi] 받은 프리빌드가 Electron ABI가 아닙니다(prebuild-install 결과를 확인).')
process.exit(1)
}
copyFileSync(scratchBinary, targetBinary)
console.log(`[native-abi] 교체 완료: ${targetBinary}`)
rmSync(scratchRoot, { recursive: true, force: true })

View file

@ -133,28 +133,51 @@ async function forgejoFetch(url, init = {}) {
})
}
/**
* 원격 파일이 로컬 바이트와 같은지 판단한다.
* Forgejo generic registry는 HEAD를 405 거부하고 해시도 주지 않으므로,
* Range GET으로 크기를 1MiB 이하는 실제 바이트까지 비교한다.
* (크기만 비교하면 버전 문자열만 바뀐 latest.yml 같은 메타데이터를 놓친다 실측 사고.)
*/
async function remoteIsIdentical(url, body, fetchImpl) {
const probe = await fetchImpl(url, { headers: { Range: 'bytes=0-0' } }).catch(() => null)
if (!probe?.ok) return false
const contentRange = probe.headers.get('content-range')
const remoteSize = contentRange ? Number(contentRange.split('/')[1]) : NaN
if (!Number.isFinite(remoteSize) || remoteSize !== body.length) return false
if (body.length > 1024 * 1024) return true
const full = await fetchImpl(url).catch(() => null)
if (!full?.ok) return false
const remoteBytes = Buffer.from(await full.arrayBuffer())
return remoteBytes.length === body.length && remoteBytes.equals(body)
}
async function upload(url, body, contentType) {
if (check) {
console.log(`[portable] (check) PUT ${url} (${body.length} bytes)`)
return
}
// Forgejo의 generic registry는 HEAD를 405로 거부한다(실측) → Range GET으로 크기만 읽는다.
const probe = await forgejoFetch(url, { headers: { Range: 'bytes=0-0' } }).catch(() => null)
const contentRange = probe?.headers.get('content-range')
const remoteLength = contentRange ? Number(contentRange.split('/')[1]) : NaN
if (probe?.ok && Number.isFinite(remoteLength)) {
if (remoteLength === body.length) {
console.log(`[portable] 이미 동일한 파일이 있습니다(건너뜀): ${url}`)
return
}
// 볼륨은 불변 자산이다 — 같은 버전 경로에 다른 바이트가 있으면 덮어쓰지 않고 중단한다.
if (url.includes(`/portable-${version}/`) && url.includes('.7z.')) {
// 메타데이터는 크기가 아니라 내용까지 비교해야 한다.
// 크기만 보면 버전 문자열만 바뀐 latest.yml/json을 "동일"로 오판한다 — 실측 사고.
if (await remoteIsIdentical(url, body, forgejoFetch)) {
console.log(`[portable] 이미 동일한 파일이 있습니다(건너): ${url}`)
return
}
const existing = await forgejoFetch(url, { headers: { Range: 'bytes=0-0' } }).catch(() => null)
if (existing?.ok) {
// 볼륨/부품은 불변 자산이다 — 같은 버전 경로에 다른 바이트가 있으면 덮어쓰지 않고 중단한다.
const isImmutableAsset =
url.includes(`/portable-${version}/`) && (url.includes('.7z.') || url.includes('.zip.'))
if (isImmutableAsset) {
console.error(
`[portable] ${version} 볼륨에 다른 바이트가 이미 있습니다: ${url}\n` +
' 이미 게시된 버전은 덮어쓰지 않습니다(불변). 새 버전으로 게시하세요.',
`[portable] ${version} 자산에 다른 바이트가 이미 있습니다: ${url}\n` +
' 이미 게시된 버전은 어쓰지 않습니다(불변). 새 버전으로 게시하세요.',
)
process.exit(1)
}
// 메타데이터와 latest 별칭은 최신을 반영해야 하므로 지우고 쓴다.
await forgejoFetch(url, { method: 'DELETE' }).catch(() => null)
}
const response = await forgejoFetch(url, {

View file

@ -41,13 +41,16 @@ const version = JSON.parse(
const releaseDir = join(desktopDir, 'release', version)
if (build) {
console.log('[updater] electron-builder NSIS 빌드 ( 전용 — 런타임 제외)')
const appDir = join(releaseDir, 'win-unpacked')
// 1) 먼저 unpacked 트리빌드한다.
console.log('[updater] electron-builder --dir (앱 전용 — 런타임 제외)')
const result = spawnSync(
'npx',
[
'electron-builder',
'--win',
'nsis',
'dir',
'--x64',
'--config',
'electron-builder.yml',
@ -62,6 +65,48 @@ if (build) {
console.error(`[updater] 빌드 실패 (exit ${result.status ?? 'null'})`)
process.exit(result.status ?? 1)
}
// 2) 네이티브 모듈 ABI 보장 + 검증 (호스트 Node ABI가 섞이면 이 시작조차 못 한다)
runNodeScript('scripts/ci/fix-native-abi.mjs', ['--dir', appDir])
runNodeScript('scripts/ci/verify-native-abi.mjs', ['--dir', appDir])
// 3) 검증된 트리에서 설치본 생성 (--prepackaged = 재빌드 없이 그대로 패키징)
console.log('[updater] electron-builder --prepackaged (NSIS x64)')
const packageResult = spawnSync(
'npx',
[
'electron-builder',
'--prepackaged',
appDir,
'--win',
'nsis',
'--x64',
'--config',
'electron-builder.yml',
'--publish',
'never',
'-c.win.forceCodeSigning=false',
'-c.npmRebuild=false',
],
{ cwd: desktopDir, stdio: 'inherit', shell: process.platform === 'win32' },
)
if (packageResult.status !== 0) {
console.error(`[updater] 설치본 생성 실패 (exit ${packageResult.status ?? 'null'})`)
process.exit(packageResult.status ?? 1)
}
}
/** 이 스크립트와 같은 Node로 CI 스크립트를 실행한다 */
function runNodeScript(relativePath, scriptArgs) {
console.log(`[updater] $ node ${relativePath} ${scriptArgs.join(' ')}`)
const result = spawnSync(process.execPath, [join(root, relativePath), ...scriptArgs], {
cwd: root,
stdio: 'inherit',
})
if (result.status !== 0) {
console.error(`[updater] ${relativePath} 실패 (exit ${result.status ?? 'null'})`)
process.exit(result.status ?? 1)
}
}
const metadataPath = join(releaseDir, 'latest.yml')
@ -143,6 +188,25 @@ if (!ackUnsigned) {
const authorization = forgejoAuthorization()
/**
* 원격 파일이 로컬 바이트와 같은지 판단한다.
* Forgejo generic registry는 HEAD를 405 거부하고 해시도 주지 않으므로,
* Range GET으로 크기를 1MiB 이하는 실제 바이트까지 비교한다.
* (크기만 비교하면 버전 문자열만 바뀐 latest.yml 같은 메타데이터를 놓친다 실측 사고.)
*/
async function remoteIsIdentical(url, body, fetchImpl) {
const probe = await fetchImpl(url, { headers: { Range: 'bytes=0-0' } }).catch(() => null)
if (!probe?.ok) return false
const contentRange = probe.headers.get('content-range')
const remoteSize = contentRange ? Number(contentRange.split('/')[1]) : NaN
if (!Number.isFinite(remoteSize) || remoteSize !== body.length) return false
if (body.length > 1024 * 1024) return true
const full = await fetchImpl(url).catch(() => null)
if (!full?.ok) return false
const remoteBytes = Buffer.from(await full.arrayBuffer())
return remoteBytes.length === body.length && remoteBytes.equals(body)
}
async function forgejoFetch(url, init = {}) {
return fetch(url, {
...init,
@ -164,19 +228,16 @@ for (const target of targets) {
const url = `${target}/${encodeURIComponent(payload.name)}`
const body = await readFile(payload.path)
// Forgejo generic registry는 같은 경로에 다른 바이트가 있으면 409를 돌려준다.
// 메타데이터(latest.yml / update-policy.json)는 최신을 가리켜야 하므로 먼저 지운다.
// (설치본/블록맵은 파일명에 버전이 있어 충돌하지 않는다.)
const existing = await forgejoFetch(url, { headers: { Range: 'bytes=0-0' } }).catch(
() => null,
)
// Forgejo generic registry는 HEAD를 405로 거부하고 해시도 주지 않는다.
// 크기만 비교하면 버전 문자열만 바뀐 latest.yml을 "동일"로 오판한다 — 내용까지 비교한다.
if (await remoteIsIdentical(url, body, forgejoFetch)) {
console.log(`[updater] 이미 동일한 파일이 있습니다(건너): ${url}`)
continue
}
// 메타데이터는 최신을 가리켜야 하므로 기존 파일을 지우고 쓴다(PUT은 409를 돌려준다).
const existing = await forgejoFetch(url, { headers: { Range: 'bytes=0-0' } }).catch(() => null)
if (existing?.ok) {
const contentRange = existing.headers.get('content-range')
const remoteSize = contentRange ? Number(contentRange.split('/')[1]) : NaN
if (remoteSize === body.length) {
console.log(`[updater] 이미 동일한 파일이 있습니다(건너뜀): ${url}`)
continue
}
await forgejoFetch(url, { method: 'DELETE' }).catch(() => null)
}

View file

@ -0,0 +1,117 @@
// scripts/ci/verify-native-abi.mjs
// 패키징된 Electron 앱의 네이티브 모이 "Electron ABI"로 빌드됐는지 검증한다.
//
// 배경(실측 사고): 설치본에 Node ABI(131)로 빌드된 better_sqlite3.node가 들어가
// 앱이 시작하자마자 "NODE_MODULE_VERSION 131 ... requires 130"으로 죽었다.
// 원인은 패키징에서 네이티브 재빌드를 건너뛴 것(npmRebuild=false)이었고, 조용히 지나갔다.
//
// 검증 방법: 호스트 Node로 모듈을 로드해 본다.
// - 로드 성공 → 호스트 Node ABI로 빌드된 것 = Electron용이 아님 → 실패
// - NODE_MODULE_VERSION 불일치로 거부 → 다른 런타임(Electron)용 = 통과
// - 파일 없음 → 실패
//
// 사용:
// node scripts/ci/verify-native-abi.mjs # release/<version>/win-unpacked 자동 탐색
// node scripts/ci/verify-native-abi.mjs --dir <packagedDir>
import { spawnSync } from 'node:child_process'
import { existsSync, readFileSync, readdirSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
const dirFlagIndex = process.argv.indexOf('--dir')
let packagedDir = dirFlagIndex >= 0 ? process.argv[dirFlagIndex + 1] : null
if (!packagedDir) {
const version = JSON.parse(
readFileSync(join(root, 'release', 'product-version.json'), 'utf8'),
).version
const releaseDir = join(root, 'apps', 'desktop', 'release', version)
if (existsSync(releaseDir)) {
const candidates = readdirSync(releaseDir).filter((name) =>
/unpacked$/.test(name) || name === 'win-unpacked' || name === 'mac-arm64',
)
if (candidates.length > 0) {
packagedDir = join(releaseDir, candidates[0])
}
}
}
if (!packagedDir || !existsSync(packagedDir)) {
console.error(
'패키징 산출물 디렉토리를 찾을 수 없습니다. --dir로 지정하세요 (예: apps/desktop/release/1.3.3/win-unpacked).',
)
process.exit(1)
}
const unpackedRoot = join(packagedDir, 'resources', 'app.asar.unpacked', 'node_modules')
/** Electron ABI(V8 내부 API)에 의존해 재빌드가 반드시 필요한 모듈 */
const REQUIRED_ELECTRON_ABI = [
{
name: 'better-sqlite3',
binary: join('better-sqlite3', 'build', 'Release', 'better_sqlite3.node'),
},
]
/** N-API 기반이라 타임 무관 — 존재만 확인 */
const NAPI_MODULES = [
{ name: 'uiohook-napi', binary: join('uiohook-napi', 'build', 'Release', 'uiohook_napi.node') },
]
const failures = []
const notes = []
for (const module of REQUIRED_ELECTRON_ABI) {
const binaryPath = join(unpackedRoot, module.binary)
if (!existsSync(binaryPath)) {
failures.push(`${module.name}: 패키징된 네이티브 바이너리가 없습니다 → ${binaryPath}`)
continue
}
// 호스트 Node로 로드 시도: 성공하면 Electron ABI가 아니다.
const probe = spawnSync(
process.execPath,
['-e', `require(${JSON.stringify(binaryPath.replace(/\\/g, '\\\\'))})`],
{ encoding: 'utf8' },
)
const output = `${probe.stdout ?? ''}${probe.stderr ?? ''}`
if (probe.status === 0) {
failures.push(
[
`${module.name}: 호스트 Node에서 로드됩니다 = Electron ABI가 아니다.`,
' 패키징 전에 Electron용으로 재빌드해야 합니다 (electron-builder npmRebuild=true,',
' 또는 `npx @electron/rebuild -v <electronVersion>`).',
].join('\n'),
)
continue
}
if (/NODE_MODULE_VERSION/.test(output)) {
notes.push(`${module.name}: Electron ABI 확인 (${output.split('\n')[0].slice(0, 80)})`)
} else {
failures.push(`${module.name}: 알 수 없는 오류로 로드 실패 → ${output.split('\n')[0]}`)
}
}
for (const module of NAPI_MODULES) {
const binaryPath = join(unpackedRoot, module.binary)
if (!existsSync(binaryPath)) {
notes.push(`${module.name}: 바이너리 없음(선택) — ${binaryPath}`)
} else {
notes.push(`${module.name}: 존재 확인 (N-API)`)
}
}
for (const note of notes) console.log(`[native-abi] ${note}`)
if (failures.length > 0) {
console.error('[native-abi] 검증 실패:')
for (const failure of failures) console.error(` - ${failure}`)
process.exit(1)
}
console.log('[native-abi] GREEN — 패키징된 네이티브 모듈이 Electron에서 실행 가능한 ABI입니다.')

View file

@ -1,12 +1,12 @@
{
"name": "d3ro-voice-site",
"version": "1.3.2",
"version": "1.3.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "d3ro-voice-site",
"version": "1.3.2",
"version": "1.3.3",
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"

View file

@ -1,7 +1,7 @@
{
"name": "d3ro-voice-site",
"private": true,
"version": "1.3.2",
"version": "1.3.3",
"type": "module",
"scripts": {
"dev": "vite --port 5199 --host",