diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml index 1dc6217..00bf6fb 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -1,3 +1,5 @@ +# appId는 Windows AUMID로 바로가기에 기록된다 — src/main/app-identity.ts APP_USER_MODEL_ID와 반드시 같아야 한다 +# (tests/main/app-identity.test.ts가 검사). appId: com.d3ro.voice productName: D3RO Voice copyright: Copyright © 2026 D3RO diff --git a/apps/desktop/src/main/app-identity.ts b/apps/desktop/src/main/app-identity.ts new file mode 100644 index 0000000..5518c90 --- /dev/null +++ b/apps/desktop/src/main/app-identity.ts @@ -0,0 +1,25 @@ +// src/main/app-identity.ts — 앱 식별자 SSOT +// +// Windows는 AppUserModelID(AUMID)로 토스트 알림 발신자와 작업 표시줄 그룹을 식별한다. +// NSIS 설치본의 시작 메뉴/바탕 화면 바로가기에는 electron-builder `appId`가 AUMID로 기록되므로 +// 런타임 `app.setAppUserModelId`는 반드시 같은 값을 써야 한다. 값이 다르면 +// 알림이 오류 없이 표시되지 않고, 고정 아이콘과 실행 중인 창이 별도 버튼으로 나뉜다. +// electron-builder.yml의 `appId`와의 일치는 tests/main/app-identity.test.ts가 검사한다. + +/** 앱 이름 — userData 경로와 single-instance lock 키를 결정한다. */ +export const APP_NAME = 'd3ro-voice' + +/** Windows AUMID — electron-builder.yml `appId`와 동일해야 한다. */ +export const APP_USER_MODEL_ID = 'com.d3ro.voice' + +/** 앱 식별자를 적용하는 데 필요한 Electron `app`의 최소 표면 (테스트용 포트). */ +export interface AppIdentityTarget { + setName(name: string): void + setAppUserModelId(id: string): void +} + +/** 앱 이름과 AUMID를 선언한다. 다른 어떤 초기화보다 먼저 호출해야 한다. */ +export function applyAppIdentity(target: AppIdentityTarget): void { + target.setName(APP_NAME) + target.setAppUserModelId(APP_USER_MODEL_ID) +} diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 7a8481b..c69dc89 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -3,10 +3,11 @@ import { app } from 'electron' import electronLog from 'electron-log' import path from 'path' +import { applyAppIdentity } from './app-identity' -// 앱 식별자 명시 (기본값 'Electron'과 충돌 방지) -app.setName('d3ro-voice') -app.setAppUserModelId('kr.twentyoz.d3ro-voice') +// 앱 식별자 명시 (기본값 'Electron'과 충돌 방지). +// AUMID는 electron-builder appId와 같아야 토스트 알림·작업 표시줄 그룹이 동작한다. +applyAppIdentity(app) // GPU 하드웨어 가속 충돌(NVIDIA/Razer/Oculus 훅)로 인한 투명/블랭크 윈도우 방지 app.disableHardwareAcceleration() diff --git a/apps/desktop/tests/main/app-identity.test.ts b/apps/desktop/tests/main/app-identity.test.ts new file mode 100644 index 0000000..d9ea0d7 --- /dev/null +++ b/apps/desktop/tests/main/app-identity.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect, vi } from 'vitest' +import { readFileSync } from 'fs' +import { resolve } from 'path' +import { APP_NAME, APP_USER_MODEL_ID, applyAppIdentity } from '../../src/main/app-identity' + +const DESKTOP_ROOT = resolve(__dirname, '../..') + +function readBuilderAppId(): string { + const yml = readFileSync(resolve(DESKTOP_ROOT, 'electron-builder.yml'), 'utf-8') + const match = /^appId:\s*["']?([^"'\s#]+)["']?/m.exec(yml) + if (!match) throw new Error('electron-builder.yml에 top-level appId가 없다') + return match[1] +} + +describe('app identity (AUMID SSOT)', () => { + it('런타임 AUMID가 electron-builder appId(설치본 바로가기 AUMID)와 같다', () => { + expect(APP_USER_MODEL_ID).toBe(readBuilderAppId()) + }) + + it('applyAppIdentity는 앱 이름과 SSOT AUMID를 선언한다', () => { + const target = { setName: vi.fn(), setAppUserModelId: vi.fn() } + applyAppIdentity(target) + expect(target.setName).toHaveBeenCalledWith(APP_NAME) + expect(target.setAppUserModelId).toHaveBeenCalledWith(readBuilderAppId()) + }) + + it('main 진입점은 AUMID 문자열을 하드코딩하지 않고 applyAppIdentity를 쓴다', () => { + const entry = readFileSync(resolve(DESKTOP_ROOT, 'src/main/index.ts'), 'utf-8') + expect(entry).toContain('applyAppIdentity(app)') + expect(entry).not.toMatch(/setAppUserModelId\(\s*['"`]/) + }) +})