fix(suggestion): place overlay in DIPs, size it to content, and harden suggestion sessions

This commit is contained in:
Yun Chan 2026-09-28 02:16:15 +09:00
parent 75e053d72f
commit 9e5b94ced2
18 changed files with 2178 additions and 558 deletions

View file

@ -54,7 +54,7 @@ import {
type KeyStrokeClass,
type PersonalPhrase,
type PhraseSource,
type UiRect
type PhysicalRect
} from '@d3ro/core/input-intelligence'
import { getDatabase } from '../db'
import { inputActivity, personalPhrases, suggestions as suggestionsTable, typingSamples } from '../db/schema'
@ -74,7 +74,11 @@ export interface TypingContext {
prefix: string
fullText: string
caretOffset: number | null
anchor: UiRect | null
/**
* 오버레이 앵커 — UIA 가 준 물리 픽셀 그대로다(사이드카가 DPI-aware 로 동작).
* DIP 변환은 창 배치(WindowManager → ScreenGeometryPort) 한 곳에서만 한다.
*/
anchor: PhysicalRect | null
/** anchor 가 케어렛인지 포커스 요소 전체인지 — 오버레이 배치 전략을 결정한다 */
anchorKind: AnchorKind
isPassword: boolean

View file

@ -28,6 +28,10 @@ export interface SuggestionHistoryEntry {
export interface SuggestionRecordInput {
appName: string | null
/**
* 입력 문맥(사용자가 친 글) — 호출자가 저장 정책(core shouldPersistSuggestionPrefix)을
* 적용한 값이다. 학습 동의가 없거나 학습 제외 앱이면 빈 문자열이 온다. 저장소는 판단하지 않는다.
*/
prefix: string
text: string
model: string

View file

@ -14,26 +14,29 @@ import { EventEmitter } from 'events'
import {
SUGGESTION_DEFAULTS,
SUGGESTION_MAX_OUTPUT_CHARS,
buildLocalSuggestionCandidates,
buildLocalSuggestionCandidateEntries,
decideInFlightGeneration,
decideSuggestion,
decideSuggestionRefresh,
decideSuggestionStep,
isAppExcluded,
joinSuggestion,
matchesSessionPrefix,
normalizeRequestPrefix,
normalizeSessionPrefix,
parseSuggestionCandidates,
sanitizeSuggestionLine,
selectPhraseHints,
shouldPersistSuggestionPrefix,
isTerminalApp,
extractTerminalPromptPrefix,
type AnchorKind,
type PhysicalRect,
type SuggestionCandidate,
type SuggestionPolicyInput,
type SuggestionProvenance,
type SuggestionSkipReason,
type SuggestionState,
type SuggestionStep,
type UiRect
type SuggestionStep
} from '@d3ro/core/input-intelligence'
import { configGet, configSet } from './ConfigService'
import { getLogger } from './LoggerService'
@ -143,7 +146,8 @@ const WARM_UP_FAILURE_BACKOFF_MS = SUGGESTION_DEFAULTS.minIntervalMs
export class SuggestionService extends EventEmitter {
private _candidates: SuggestionCandidate[] = []
private _activeIndex = 0
private _anchor: UiRect | null = null
/** 물리 픽셀 그대로 보관한다 — DIP 변환은 창 배치(WindowManager) 한 곳에서만 한다. */
private _anchor: PhysicalRect | null = null
/** anchor 가 케어렛인지 요소 전체인지 — 오버레이가 배치 전략을 고르는 근거 */
private _anchorKind: AnchorKind = null
private _appName: string | null = null
@ -472,7 +476,9 @@ export class SuggestionService extends EventEmitter {
userDismissedQuiet: now < this._userDismissedUntil,
lastRequestedPrefix: this._lastRequestedPrefix,
modelWarm: this._warmth.isWarm(model, now),
warmUpBackoff: this._warmth.isBackingOff(model, now, WARM_UP_FAILURE_BACKOFF_MS)
warmUpBackoff: this._warmth.isBackingOff(model, now, WARM_UP_FAILURE_BACKOFF_MS),
// 서버가 없을 때 "준비 중" 은 실제 준비 작업(워밍업 루프)이 돌 때만 보여준다.
modelStarting: this._warmUpPromise !== null
})
this._executeStep(step, context, config, now)
}
@ -541,8 +547,9 @@ export class SuggestionService extends EventEmitter {
this._showWarming(context)
break
case 'show-warming':
// 모델이 아직 안 떠 있으면 "준비 중" 을 보여준다 — 아무 반응이 없으면
// 사용자는 기능이 죽었다고 판단한다(첫 실행에서 실제로 그랬다).
// 워밍업 루프가 서버를 기다리는 중이면 "준비 중" 을 보여준다 — 아무 반응이 없으면
// 사용자는 기능이 죽었다고 판단한다(첫 실행에서 실제로 그랬다). 준비 작업이 없으면
// core 가 'record' 를 준다(끝나지 않는 스피너 방지).
this._showWarming(context)
break
}
@ -689,13 +696,16 @@ export class SuggestionService extends EventEmitter {
if (!this._isCurrentFallbackContext(prefix, context, token)) return false
const trimmedPrefix = normalizeSessionPrefix(prefix)
const localMemory = memory ?? this._collectMemoryHints(trimmedPrefix, context.appName)
const candidates = buildLocalSuggestionCandidates(trimmedPrefix, localMemory, maxCandidates, maxChars)
if (candidates.length === 0) return false
const entries = buildLocalSuggestionCandidateEntries(trimmedPrefix, localMemory, maxCandidates, maxChars)
if (entries.length === 0) return false
this._partialText = ''
this._generating = false
this._warmingUp = false
this._candidates = candidates.map((text, index) => ({ text, rank: index }))
// 단어 중간에서 이어지는 나머지(예: "Thank yo" + "u for…")는 수락할 때 공백 없이 붙인다.
this._candidates = entries.map((entry, index) =>
entry.attachToPrefix ? { text: entry.text, rank: index, joint: 'attach' } : { text: entry.text, rank: index }
)
this._activeIndex = 0
this._anchor = context.anchor
this._anchorKind = context.anchorKind
@ -703,7 +713,7 @@ export class SuggestionService extends EventEmitter {
this._windowTitle = context.windowTitle
this._windowHandle = context.windowHandle ?? null
this._generatedForPrefix = trimmedPrefix
this._targetTotal = candidates.length
this._targetTotal = entries.length
this._lastSkipReason = null
this._lastLatencyMs = latencyMs
this._provenance = {
@ -715,15 +725,15 @@ export class SuggestionService extends EventEmitter {
}
this._recordSuggestion({
prefix: trimmedPrefix,
text: candidates[0],
text: entries[0].text,
model: 'local-memory',
latencyMs,
candidateCount: candidates.length
candidateCount: entries.length
})
this._armVisibleTtl()
this.emit('updated', this.getState())
this.emit('state-changed', this.getState())
logger.info(`로컬 기억 제안 ${candidates.length}개 생성`)
logger.info(`로컬 기억 제안 ${entries.length}개 생성`)
return true
}
@ -929,6 +939,18 @@ export class SuggestionService extends EventEmitter {
return
}
// 요청 뒤 사용자가 더 쳤다면 이 결과는 옛 접두용이다 — 게시하면 다음 스냅샷에서 곧바로
// stale 로 닫히며(깜빡임) 채우기 요청만 낭비하고, 그사이 수락하면 방금 친 말이 빠진
// 문장이 들어간다. 세션과 같은 기준(matchesSessionPrefix)으로 최신 문맥과 맞춰 본다.
const latest = this._lastContext
if (latest && !matchesSessionPrefix(trimmedPrefix, latest.prefix)) {
logger.debug('제안 결과 폐기 (요청 뒤 접두가 바뀌었다)')
this.dismiss('stale')
this._budget.relaxInterval(Date.now(), SUGGESTION_DEFAULTS.minIntervalMs)
this.emit('state-changed', this.getState())
return
}
const candidates = parseSuggestionCandidates(raw, trimmedPrefix, 1, maxChars)
if (candidates.length === 0) {
this._lastSkipReason = 'generation-failed'
@ -1208,7 +1230,8 @@ export class SuggestionService extends EventEmitter {
private _abortStaleGeneration(currentPrefix: string): void {
if (!this._inFlight || !this._abort || this._abort.signal.aborted) return
const refresh = decideSuggestionRefresh(this._lastRequestedPrefix, currentPrefix)
// 세션 규칙과 같은 기준 — 요청 뒤 한 글자라도 자라면 결과는 도착하자마자 stale 이다.
const refresh = decideInFlightGeneration(this._lastRequestedPrefix, currentPrefix)
if (refresh === 'keep') return
this._generationToken += 1
@ -1327,7 +1350,9 @@ export class SuggestionService extends EventEmitter {
logger.info(`제안 수락 무시: 표시 중인 후보 없음 (index=${index ?? 'active'})`)
return { ok: false, reason: 'already-visible' }
}
const text = candidate.text
// 후보는 정제 과정에서 앞 공백을 잃는다 — 커서 앞 원문의 마지막 글자를 보고 구분 공백을
// 붙인다(joinSuggestion). 세션을 닫기 전에(dismiss 가 세션 접두를 지운다) 계산한다.
const text = joinSuggestion(this._acceptPrefix(), candidate.text, candidate.joint ?? 'separate')
const appName = this._appName
const windowTitle = this._windowTitle
const targetWindow = this._windowHandle
@ -1375,12 +1400,25 @@ export class SuggestionService extends EventEmitter {
}
logger.info(`제안 수락: ${text.length}자 삽입 (method=${method === 'keyboard' ? 'keyboard' : 'clipboard'}, app=${appName ?? '-'})`)
this._repository.markAccepted(text)
// 이력은 후보 원문으로 기록돼 있다 — 구분 공백이 붙은 삽입 문자열로는 찾지 못한다.
this._repository.markAccepted(candidate.text)
// 수락한 문장은 사용자 문체의 확실한 표본이다 (학습 동의 시에만 저장됨).
this._learning.recordAccepted(text, { appName, windowTitle })
this._learning.recordAccepted(candidate.text, { appName, windowTitle })
return { ok: true }
}
/**
* 수락 시 이음새 판단에 쓸 커서 앞 원문.
*
* 세션 접두는 뒤 공백을 걷어낸 값이라, 사용자가 멈추기 전에 공백을 쳤는지 알 수 없다.
* 최신 문맥이 여전히 이 세션의 것이면(세션 규칙) 그 원문을, 아니면 세션 접두를 쓴다.
*/
private _acceptPrefix(): string {
const latest = this._lastContext
if (latest && matchesSessionPrefix(this._generatedForPrefix, latest.prefix)) return latest.prefix
return this._generatedForPrefix
}
getHistory(limit = 50): SuggestionHistoryEntry[] {
return this._repository.list(limit)
}
@ -1408,8 +1446,16 @@ export class SuggestionService extends EventEmitter {
}
}
/**
* 제안 이력 기록 — 접두(사용자가 친 글 원문)는 학습 동의가 있고 학습 제외 앱이 아닐 때만
* 남긴다(core shouldPersistSuggestionPrefix). 그 밖에는 빈 문자열로 남겨 개수·수락률만 유지한다.
*/
private _recordSuggestion(input: Omit<SuggestionRecordInput, 'appName'>): void {
this._repository.record({ ...input, appName: this._appName })
const persistPrefix = shouldPersistSuggestionPrefix({
learnTypedText: configGet('inputLearnTypedText') === true,
appName: this._appName
})
this._repository.record({ ...input, prefix: persistPrefix ? input.prefix : '', appName: this._appName })
}
/** 테스트/진단 — 현재 앱이 제외 대상인지. */

View file

@ -5,7 +5,17 @@ import { app, BrowserWindow, screen, ipcMain, Menu, clipboard } from 'electron'
import { join } from 'path'
import { is } from '@electron-toolkit/utils'
import { WINDOW_SIZE } from '@d3ro/core/constants'
import { anchorFloatingPanel, type AnchorKind } from '@d3ro/core/input-intelligence'
import type { AnchorKind, PhysicalRect } from '@d3ro/core/input-intelligence'
import {
fitAnchoredPanel,
placeAnchoredPanel,
placeNearCursor,
type AnchoredPanelPlacement,
type DipPoint,
type ScreenGeometryPort,
type UiRect,
type UiSize
} from '@d3ro/core/popup-placement'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import {
formatBindingSegments,
@ -18,26 +28,22 @@ import { getLogger } from '../services/LoggerService'
import { getIsQuitting, setIsQuitting } from '../lifecycle'
import { hardenWebContents } from './web-contents-hardening'
import { configGet, configSet } from '../services/ConfigService'
import { buildPopupThemeCss } from '@d3ro/ui/theme-vars'
import { getI18n } from '@d3ro/i18n'
import {
createPopupWindow,
injectPopupTheme,
presentPopup,
sendToPopupWindow
} from './popup-window-factory'
import { createElectronScreenGeometry } from './screen-geometry'
import {
SUGGESTION_OVERLAY_MAX_HEIGHT,
SUGGESTION_OVERLAY_WIDTH,
suggestionPanelHeight
} from './suggestion-overlay-geometry'
const logger = getLogger('WindowManager')
// ── 팝업 테마 주입 ────────────────────────────────────────
/**
* 팝업 BrowserWindow에 현재 설정 테마의 CSS 변수를 insertCSS로 주입.
* did-finish-load 이후 호출해야 한다.
*/
function injectPopupTheme(win: BrowserWindow): void {
const theme = configGet('theme') as string
// 'system'은 'dark'로 폴백 (팝업은 시스템 다크모드 감지 불가)
const resolvedTheme = theme === 'system' ? 'dark' : theme
const css = buildPopupThemeCss(resolvedTheme)
win.webContents.insertCSS(css).catch((err: unknown) => {
logger.warn('팝업 테마 CSS 주입 실패', err)
})
}
// ── 팝업 i18n 문자열 가져오기 ─────────────────────────────
/**
* 팝업에 주입할 i18n 문자열 맵을 반환.
@ -161,97 +167,25 @@ function buildSuggestionKeyHints(): SuggestionKeyHints {
}
}
// ── Popup window lifecycle ────────────────────────────
// ── 화면 기하 (DIP) ───────────────────────────────────
// 팝업 창 생성·수명주기·표시는 popup-window-factory, 배치 정책은 core popup-placement 가 정본이다.
/** Popup windows whose renderer finished loading and can receive IPC */
const popupReady = new WeakSet<BrowserWindow>()
/** IPC messages held back until the popup renderer is ready */
const pendingPopupMessages = new WeakMap<BrowserWindow, Array<{ channel: string; data: unknown }>>()
let screenGeometry: ScreenGeometryPort | null = null
/**
* Promote popup renderer console output and load failures into the main log.
* Popups have no renderer logging otherwise, so a missing asset or a script
* exception stays invisible and the popup just renders static markup.
*/
function hookPopupDiagnostics(win: BrowserWindow, name: string): void {
win.webContents.on('console-message', (_event, level, message, line, sourceId) => {
logger.info(`[${name}] [Renderer] [${level}] ${message} (${sourceId}:${line})`)
})
win.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => {
logger.error(`[${name}] renderer load failed [${errorCode}] ${errorDescription} (${validatedURL})`)
})
win.webContents.on('render-process-gone', (_event, details) => {
logger.error(`[${name}] renderer process gone: ${details.reason} (exitCode=${details.exitCode})`)
})
/** Electron screen 은 app ready 뒤에만 쓸 수 있다 — 처음 배치할 때 만든다. */
function getScreenGeometry(): ScreenGeometryPort {
if (!screenGeometry) screenGeometry = createElectronScreenGeometry(screen)
return screenGeometry
}
/**
* Deliver IPC to a popup even while its renderer is still loading.
* webContents.send before load is silently dropped, which leaves popups stuck
* on their initial markup (for example a frozen 0:00 timer).
*/
function sendToPopupWindow(win: BrowserWindow, channel: string, data: unknown): void {
if (win.isDestroyed() || win.webContents.isDestroyed()) return
if (popupReady.has(win)) {
win.webContents.send(channel, data)
return
}
const queue = pendingPopupMessages.get(win) ?? []
queue.push({ channel, data })
if (queue.length > 32) {
queue.splice(0, queue.length - 32)
}
pendingPopupMessages.set(win, queue)
}
function flushPopupMessages(win: BrowserWindow): void {
popupReady.add(win)
const queue = pendingPopupMessages.get(win)
if (!queue || queue.length === 0) return
pendingPopupMessages.delete(win)
for (const message of queue) {
if (!win.isDestroyed() && !win.webContents.isDestroyed()) {
win.webContents.send(message.channel, message.data)
}
}
}
/** Shared popup lifecycle: diagnostics + load tracking + theme injection */
function attachPopupLifecycle(win: BrowserWindow, name: string): void {
hookPopupDiagnostics(win, name)
win.webContents.on('did-start-loading', () => {
popupReady.delete(win)
})
win.webContents.on('did-finish-load', () => {
injectPopupTheme(win)
flushPopupMessages(win)
})
}
/**
* Show a popup without stealing focus, reliably on every call.
* After hide(), showInactive() can lose z-order and repaint, so popups stopped
* appearing from the second show onward: re-assert topmost and force a repaint.
*/
function presentPopup(win: BrowserWindow, level?: 'floating' | 'screen-saver'): void {
if (!win.isVisible()) {
win.showInactive()
}
// Z-order/repaint adjustments must never break a capture session.
try {
win.setAlwaysOnTop(true, level)
win.moveTop()
win.webContents.invalidate()
} catch (err) {
logger.warn(`Popup present adjustment failed: ${err instanceof Error ? err.message : String(err)}`)
}
/** 커서 기준 배치 (녹음 팁 · 결과 · 기록 · 명령 팝업 공통) — 커서와 작업 영역 모두 DIP. */
function placePopupNearCursor(
win: BrowserWindow,
size: UiSize,
cursor: DipPoint = getScreenGeometry().cursor()
): void {
const position = placeNearCursor(cursor, size, getScreenGeometry().workAreaNear(cursor))
win.setBounds({ x: position.x, y: position.y, width: size.width, height: size.height })
}
// ── 윈도우 참조 ───────────────────────────────────────
@ -392,39 +326,13 @@ export function createMainWindow(): BrowserWindow {
// ── RecordingTip 팝업 ─────────────────────────────────
function createRecordingTipWindow(): BrowserWindow {
const win = new BrowserWindow({
width: WINDOW_SIZE.RECORDING_TIP.width,
height: WINDOW_SIZE.RECORDING_TIP.height,
show: false,
frame: false,
transparent: true,
resizable: false,
alwaysOnTop: true,
skipTaskbar: true,
focusable: false,
webPreferences: {
preload: join(__dirname, '../preload/popup.js'),
sandbox: false,
contextIsolation: true,
nodeIntegration: false,
backgroundThrottling: false
return createPopupWindow({
name: 'recording-tip',
bounds: { width: WINDOW_SIZE.RECORDING_TIP.width, height: WINDOW_SIZE.RECORDING_TIP.height },
onClosed: () => {
recordingTipWindow = null
}
})
hardenWebContents(win)
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
win.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/popups/recording-tip/index.html`)
} else {
win.loadFile(join(__dirname, '../renderer/popups/recording-tip/index.html'))
}
attachPopupLifecycle(win, 'recording-tip')
win.on('closed', () => {
recordingTipWindow = null
})
return win
}
export function getRecordingTipWindow(): BrowserWindow {
@ -451,23 +359,8 @@ export function showRecordingTip(
): void {
const win = getRecordingTipWindow()
// 커서 위치에 배치
const cursorPos = screen.getCursorScreenPoint()
const display = screen.getDisplayNearestPoint(cursorPos)
let x = cursorPos.x - Math.round(TIP_WIDTH / 2)
let y = cursorPos.y - TIP_HEIGHT - 20
// 화면 밖 보정
x = Math.max(display.workArea.x, Math.min(x, display.workArea.x + display.workArea.width - TIP_WIDTH))
if (y < display.workArea.y) {
y = cursorPos.y + 20
}
if (y + TIP_HEIGHT > display.workArea.y + display.workArea.height) {
y = Math.max(display.workArea.y, display.workArea.y + display.workArea.height - TIP_HEIGHT)
}
win.setBounds({ x, y, width: TIP_WIDTH, height: TIP_HEIGHT })
// 커서 위치에 배치 (화면 밖 보정 포함)
placePopupNearCursor(win, { width: TIP_WIDTH, height: TIP_HEIGHT })
// 상태 전송 후 즉시 show (2-phase 제거 — 숨겨진 윈도우의 렌더러 비활성 문제 방지)
sendToPopupWindow(win, IPC_CHANNELS.WINDOW.TIP_STATE_CHANGED, { state, _i18n: getPopupI18nStrings(), ...params })
@ -506,38 +399,14 @@ export function sendPartialTranscriptToTip(text: string): void {
// ── ResultPopup 팝업 ──────────────────────────────────
function createResultPopupWindow(): BrowserWindow {
const win = new BrowserWindow({
width: WINDOW_SIZE.RESULT_POPUP.width,
height: WINDOW_SIZE.RESULT_POPUP.height,
show: false,
frame: false,
transparent: true,
resizable: false,
alwaysOnTop: true,
skipTaskbar: true,
focusable: false,
webPreferences: {
preload: join(__dirname, '../preload/popup.js'),
sandbox: false,
contextIsolation: true,
nodeIntegration: false
return createPopupWindow({
name: 'result-popup',
bounds: { width: WINDOW_SIZE.RESULT_POPUP.width, height: WINDOW_SIZE.RESULT_POPUP.height },
onClosed: () => {
cancelPendingResultShow()
resultPopupWindow = null
}
})
hardenWebContents(win)
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
win.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/popups/result-popup/index.html`)
} else {
win.loadFile(join(__dirname, '../renderer/popups/result-popup/index.html'))
}
attachPopupLifecycle(win, 'result-popup')
win.on('closed', () => {
resultPopupWindow = null
})
return win
}
export function getResultPopupWindow(): BrowserWindow {
@ -548,39 +417,96 @@ export function getResultPopupWindow(): BrowserWindow {
return resultPopupWindow
}
/**
* 측정이 오지 않을 때 기다리는 한도.
*
* 숨긴 창에서는 렌더러의 requestAnimationFrame 이 멈출 수 있어 측정(result:measured)이 영영
* 오지 않았다 — 두 번째 삽입 실패부터 받아쓴 텍스트를 볼 방법이 없었다. 한도가 지나면 기본
* 크기로 그대로 보여준다.
*/
const RESULT_MEASURE_TIMEOUT_MS = 400
interface PendingResultShow {
autoHideMs: number
cursor: DipPoint
fallbackTimer: NodeJS.Timeout
}
let pendingResultShow: PendingResultShow | null = null
/** 마지막 표시의 커서 — 한도 뒤 늦게 도착한 측정으로 크기만 고칠 때 쓴다 */
let lastResultCursor: DipPoint | null = null
function cancelPendingResultShow(): void {
if (!pendingResultShow) return
clearTimeout(pendingResultShow.fallbackTimer)
pendingResultShow = null
}
function isMeasuredSize(data: unknown): data is UiSize {
if (data === null || typeof data !== 'object') return false
const { width, height } = data as Record<string, unknown>
return (
typeof width === 'number' &&
typeof height === 'number' &&
Number.isFinite(width) &&
Number.isFinite(height) &&
width > 0 &&
height > 0
)
}
/** Phase 2: 측정 크기(없으면 현재 크기)로 배치를 마무리하고 내용을 보인다. */
function finishResultShow(measured: UiSize | null): void {
const pending = pendingResultShow
if (!pending) return
cancelPendingResultShow()
const win = resultPopupWindow
if (!win || win.isDestroyed()) return
if (measured) placePopupNearCursor(win, measured, pending.cursor)
presentPopup(win)
sendToPopupWindow(win, IPC_CHANNELS.POPUP_RESULT.SHOW, { autoHideMs: pending.autoHideMs })
}
/**
* ResultPopup 표시 (2-phase 리사이즈)
*
* Phase 1: 기본 크기로 커서 곁에 먼저 띄운다 — 내용은 SHOW 전까지 투명하다. 창이 보여야
* 렌더러가 측정 프레임을 돈다(숨긴 창의 rAF 정지 방지).
* Phase 2: 측정이 오면(또는 한도가 지나면) 크기를 맞추고 내용을 보인다.
* 측정 응답은 모듈 단일 리스너가 받는다 — 호출마다 ipcMain.once 를 쌓지 않는다.
*/
export function showResultPopup(text: string, autoHideMs = 5000): void {
const win = getResultPopupWindow()
cancelPendingResultShow()
const cursor = getScreenGeometry().cursor()
lastResultCursor = cursor
placePopupNearCursor(
win,
{ width: WINDOW_SIZE.RESULT_POPUP.width, height: WINDOW_SIZE.RESULT_POPUP.height },
cursor
)
// Phase 1: prepare
sendToPopupWindow(win, IPC_CHANNELS.POPUP_RESULT.PREPARE, { text, _i18n: getPopupI18nStrings() })
presentPopup(win)
ipcMain.once(IPC_CHANNELS.POPUP_RESULT.MEASURED, (_event, data: { width: number; height: number }) => {
const cursorPos = screen.getCursorScreenPoint()
const display = screen.getDisplayNearestPoint(cursorPos)
let x = cursorPos.x - Math.round(data.width / 2)
let y = cursorPos.y - data.height - 20
x = Math.max(display.workArea.x, Math.min(x, display.workArea.x + display.workArea.width - data.width))
if (y < display.workArea.y) {
y = cursorPos.y + 20
}
if (y + data.height > display.workArea.y + display.workArea.height) {
y = Math.max(display.workArea.y, display.workArea.y + display.workArea.height - data.height)
}
win.setBounds({ x, y, width: data.width, height: data.height })
presentPopup(win)
// Phase 2: show
sendToPopupWindow(win, IPC_CHANNELS.POPUP_RESULT.SHOW, { autoHideMs })
})
const fallbackTimer = setTimeout(() => finishResultShow(null), RESULT_MEASURE_TIMEOUT_MS)
fallbackTimer.unref?.()
pendingResultShow = { autoHideMs, cursor, fallbackTimer }
}
ipcMain.on(IPC_CHANNELS.POPUP_RESULT.MEASURED, (event: Electron.IpcMainEvent, data: unknown) => {
const win = resultPopupWindow
if (!win || win.isDestroyed() || event.sender !== win.webContents) return
if (!isMeasuredSize(data)) return
if (pendingResultShow) {
finishResultShow(data)
return
}
// 한도 뒤에 늦게 도착한 측정 — 이미 보이는 팝업의 크기만 맞춘다.
if (win.isVisible() && lastResultCursor) placePopupNearCursor(win, data, lastResultCursor)
})
export function hideResultPopup(): void {
if (resultPopupWindow && !resultPopupWindow.isDestroyed()) {
resultPopupWindow.hide()
@ -590,38 +516,13 @@ export function hideResultPopup(): void {
// ── HistoryPopup 팝업 ─────────────────────────────────
function createHistoryPopupWindow(): BrowserWindow {
const win = new BrowserWindow({
width: 360,
height: 400,
show: false,
frame: false,
transparent: true,
resizable: false,
alwaysOnTop: true,
skipTaskbar: true,
focusable: false,
webPreferences: {
preload: join(__dirname, '../preload/popup.js'),
sandbox: false,
contextIsolation: true,
nodeIntegration: false
return createPopupWindow({
name: 'history-popup',
bounds: { width: 360, height: 400 },
onClosed: () => {
historyPopupWindow = null
}
})
hardenWebContents(win)
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
win.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/popups/history-popup/index.html`)
} else {
win.loadFile(join(__dirname, '../renderer/popups/history-popup/index.html'))
}
attachPopupLifecycle(win, 'history-popup')
win.on('closed', () => {
historyPopupWindow = null
})
return win
}
export function getHistoryPopupWindow(): BrowserWindow {
@ -636,21 +537,7 @@ export function showHistoryPopup(entries: Array<Record<string, unknown>>): void
const win = getHistoryPopupWindow()
// 커서 위치에 즉시 배치+표시 (2-phase 제거 — 숨겨진 윈도우 렌더러 비활성 문제 방지)
const cursorPos = screen.getCursorScreenPoint()
const display = screen.getDisplayNearestPoint(cursorPos)
const popupWidth = 380
const popupHeight = Math.min(60 + entries.length * 44, 500)
let x = cursorPos.x - Math.round(popupWidth / 2)
let y = cursorPos.y - popupHeight - 20
x = Math.max(display.workArea.x, Math.min(x, display.workArea.x + display.workArea.width - popupWidth))
if (y < display.workArea.y) {
y = cursorPos.y + 20
}
win.setBounds({ x, y, width: popupWidth, height: popupHeight })
placePopupNearCursor(win, { width: 380, height: Math.min(60 + entries.length * 44, 500) })
sendToPopupWindow(win, IPC_CHANNELS.POPUP_HISTORY.SHOW_ITEMS, { entries, _i18n: getPopupI18nStrings() })
@ -683,35 +570,13 @@ export function isHistoryPopupVisible(): boolean {
// ── CommandPopup 팝업 ─────────────────────────────────
function createCommandPopupWindow(): BrowserWindow {
const win = new BrowserWindow({
width: 340,
height: 300,
show: false,
frame: false,
transparent: true,
resizable: false,
alwaysOnTop: true,
skipTaskbar: true,
focusable: false,
webPreferences: {
preload: join(__dirname, '../preload/popup.js'),
sandbox: false,
contextIsolation: true,
nodeIntegration: false
return createPopupWindow({
name: 'command-popup',
bounds: { width: 340, height: 300 },
onClosed: () => {
commandPopupWindow = null
}
})
hardenWebContents(win)
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
win.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/popups/command-popup/index.html`)
} else {
win.loadFile(join(__dirname, '../renderer/popups/command-popup/index.html'))
}
attachPopupLifecycle(win, 'command-popup')
win.on('closed', () => { commandPopupWindow = null })
return win
}
export function getCommandPopupWindow(): BrowserWindow {
@ -723,19 +588,7 @@ export function getCommandPopupWindow(): BrowserWindow {
export function showCommandPopup(commands: Array<Record<string, unknown>>, activeId: string | null): void {
const win = getCommandPopupWindow()
const cursorPos = screen.getCursorScreenPoint()
const display = screen.getDisplayNearestPoint(cursorPos)
const popupWidth = 340
const popupHeight = Math.min(80 + commands.length * 48, 400)
let x = cursorPos.x - Math.round(popupWidth / 2)
let y = cursorPos.y - popupHeight - 20
x = Math.max(display.workArea.x, Math.min(x, display.workArea.x + display.workArea.width - popupWidth))
if (y < display.workArea.y) { y = cursorPos.y + 20 }
win.setBounds({ x, y, width: popupWidth, height: popupHeight })
placePopupNearCursor(win, { width: 340, height: Math.min(80 + commands.length * 48, 400) })
sendToPopupWindow(win, IPC_CHANNELS.POPUP_COMMAND.SHOW_ITEMS, { commands, activeId, _i18n: getPopupI18nStrings() })
presentPopup(win)
@ -793,42 +646,17 @@ function initialCaptionOverlayBounds(): Electron.Rectangle {
}
function createCaptionOverlayWindow(): BrowserWindow {
const bounds = initialCaptionOverlayBounds()
const win = new BrowserWindow({
...bounds,
show: false,
frame: false,
transparent: true,
resizable: false,
alwaysOnTop: true,
skipTaskbar: true,
focusable: false,
webPreferences: {
preload: join(__dirname, '../preload/popup.js'),
sandbox: false,
contextIsolation: true,
nodeIntegration: false
return createPopupWindow({
name: 'caption-overlay',
bounds: initialCaptionOverlayBounds(),
// 표시 중에는 항상 보이는 창이다 — 기존 기본값(늦춤)을 유지한다.
backgroundThrottling: true,
// 클릭 통과: 마우스 이벤트를 무시하되, CSS hover 등을 위해 forward 활성화
beforeLoad: (win) => win.setIgnoreMouseEvents(true, { forward: true }),
onClosed: () => {
captionOverlayWindow = null
}
})
hardenWebContents(win)
// 클릭 통과: 마우스 이벤트를 무시하되, CSS hover 등을 위해 forward 활성화
win.setIgnoreMouseEvents(true, { forward: true })
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
win.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/popups/caption-overlay/index.html`)
} else {
win.loadFile(join(__dirname, '../renderer/popups/caption-overlay/index.html'))
}
attachPopupLifecycle(win, 'caption-overlay')
win.on('closed', () => {
captionOverlayWindow = null
})
return win
}
export function getCaptionOverlayWindow(): BrowserWindow {
@ -916,21 +744,18 @@ export function resetCaptionOverlayPosition(): void {
if (win && !win.isDestroyed()) win.setBounds(defaultCaptionOverlayBounds())
}
// ── SuggestionOverlay 업 (입력 인텔리전스) ──────────
// ── SuggestionOverlay (입력 인텔리전스) ──────────────
/**
* 다음 문장 제안 ghost text 오버레이.
*
* IME 후보창과 같은 관례를 따른다: 케어/포커스 요소 바로 아래에 붙고,
* 포커스를 막지 않으며(focusable:false) 기본은 클릭 통과다.
* IME 후보창과 같은 관례를 따른다: 캐럿/포커스 요소 바로 아래에 붙고,
* 포커스를 막지 않는다(focusable:false).
* (KeyType.Windows 의 WS_EX_TRANSPARENT|WS_EX_NOACTIVATE 오버레이와 동일한 전략)
*
* 창은 그려진 패널 높이만큼만 둔다(suggestionPanelHeight). 투명 영역도 클릭을 삼키므로,
* 고정 높이 창은 "준비 중" 한 줄만 보일 때 캐럿 아래 줄의 클릭을 가로챘다.
*/
const SUGGESTION_OVERLAY_WIDTH = 460
/**
* 한 페이지(3개 × 고정 44px) + 진행률 줄 + 키 안내 줄이 스크롤 없이 들어가는 높이.
* style.css 고정 치수로 렌더링해 잰 값(패널 하단 204 + root 패딩 4) — CSS 를 바꾸면 다시 잰다.
*/
const SUGGESTION_OVERLAY_HEIGHT = 208
function applySuggestionOverlayMouseMode(win: BrowserWindow): void {
const interactive = configGet('suggestionOverlayInteractive') !== false
@ -949,41 +774,16 @@ function applySuggestionOverlayMouseMode(win: BrowserWindow): void {
}
function createSuggestionOverlayWindow(): BrowserWindow {
const win = new BrowserWindow({
width: SUGGESTION_OVERLAY_WIDTH,
height: SUGGESTION_OVERLAY_HEIGHT,
show: false,
frame: false,
transparent: true,
resizable: false,
alwaysOnTop: true,
skipTaskbar: true,
focusable: false,
webPreferences: {
preload: join(__dirname, '../preload/popup.js'),
sandbox: false,
contextIsolation: true,
nodeIntegration: false,
backgroundThrottling: false
return createPopupWindow({
name: 'suggestion-overlay',
bounds: { width: SUGGESTION_OVERLAY_WIDTH, height: SUGGESTION_OVERLAY_MAX_HEIGHT },
beforeLoad: applySuggestionOverlayMouseMode,
onClosed: () => {
suggestionOverlayPlacement = null
suggestionOverlayBounds = null
suggestionOverlayWindow = null
}
})
hardenWebContents(win)
applySuggestionOverlayMouseMode(win)
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
win.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/popups/suggestion-overlay/index.html`)
} else {
win.loadFile(join(__dirname, '../renderer/popups/suggestion-overlay/index.html'))
}
attachPopupLifecycle(win, 'suggestion-overlay')
win.on('closed', () => {
suggestionOverlayWindow = null
})
return win
}
export function getSuggestionOverlayWindow(): BrowserWindow {
@ -1016,37 +816,72 @@ interface SuggestionOverlayContent {
provenance?: SuggestionOverlayProvenance | null
}
/** 제안 오버레이 표시 — 커(케어 → 요소 → 커서) 기준 배치. */
/** 현재 세션의 배치(최대 높이 기준) — 내용이 바뀌면 이 자리에서 높이만 맞춘다. */
let suggestionOverlayPlacement: AnchoredPanelPlacement | null = null
let suggestionOverlayBounds: UiRect | null = null
function sameRect(a: UiRect | null, b: UiRect): boolean {
return !!a && a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height
}
/**
* 내용 높이에 맞춰 창 크기를 고친다. 늘어날 때는 내용보다 먼저(잘림 방지),
* 줄어들 때는 내용 다음에(`deliver` 뒤) 적용한다.
*/
function fitSuggestionOverlay(win: BrowserWindow, content: SuggestionOverlayContent, deliver: () => void): void {
const placement = suggestionOverlayPlacement
if (!placement) {
deliver()
return
}
const bounds = fitAnchoredPanel(
placement,
suggestionPanelHeight({
candidateCount: content.candidates.length,
activeIndex: content.activeIndex,
generating: content.generating === true,
warmingUp: content.warmingUp === true,
hasPartialText: !!content.partialText
})
)
const previous = suggestionOverlayBounds
const apply = (): void => {
if (sameRect(suggestionOverlayBounds, bounds)) return
suggestionOverlayBounds = bounds
win.setBounds(bounds)
}
if (!previous || bounds.height >= previous.height) {
apply()
deliver()
} else {
deliver()
apply()
}
}
/**
* 제안 오버레이 표시 — 캐럿 → 요소 → 커서 기준 배치.
*
* anchor 는 UIA 가 준 물리 픽셀이다. 화면 기하 포트로 한 번만 DIP 로 바꾼 뒤 모니터를 고르고
* 배치한다(서비스에는 DIP 를 저장하지 않는다 — 이중 변환 방지).
*/
export function showSuggestionOverlay(
payload: SuggestionOverlayContent & {
anchor: { x: number; y: number; width: number; height: number } | null
anchor: PhysicalRect | null
anchorKind?: AnchorKind
appName: string | null
}
): void {
const win = getSuggestionOverlayWindow()
const cursor = screen.getCursorScreenPoint()
const anchorPoint = payload.anchor
? { x: payload.anchor.x, y: payload.anchor.y + payload.anchor.height }
: cursor
const display = screen.getDisplayNearestPoint(anchorPoint)
const position = anchorFloatingPanel(
suggestionOverlayPlacement = placeAnchoredPanel(
getScreenGeometry(),
payload.anchor,
payload.anchorKind ?? null,
cursor,
{ width: SUGGESTION_OVERLAY_WIDTH, height: SUGGESTION_OVERLAY_HEIGHT },
display.workArea
{ width: SUGGESTION_OVERLAY_WIDTH, height: SUGGESTION_OVERLAY_MAX_HEIGHT }
)
suggestionOverlayBounds = null
win.setBounds({
x: position.x,
y: position.y,
width: SUGGESTION_OVERLAY_WIDTH,
height: SUGGESTION_OVERLAY_HEIGHT
})
sendToPopupWindow(win, IPC_CHANNELS.POPUP_SUGGESTION.SHOW, {
fitSuggestionOverlay(win, payload, () => sendToPopupWindow(win, IPC_CHANNELS.POPUP_SUGGESTION.SHOW, {
candidates: payload.candidates,
activeIndex: payload.activeIndex,
targetTotal: payload.targetTotal ?? payload.candidates.length,
@ -1057,22 +892,27 @@ export function showSuggestionOverlay(
provenance: payload.provenance ?? null,
_i18n: getPopupI18nStrings(),
_keyHints: buildSuggestionKeyHints()
})
}))
presentPopup(win, 'screen-saver')
}
export function updateSuggestionOverlay(payload: SuggestionOverlayContent): void {
if (suggestionOverlayWindow && !suggestionOverlayWindow.isDestroyed()) {
sendToPopupWindow(suggestionOverlayWindow, IPC_CHANNELS.POPUP_SUGGESTION.UPDATE, {
...payload,
targetTotal: payload.targetTotal ?? payload.candidates.length,
_keyHints: buildSuggestionKeyHints()
})
const win = suggestionOverlayWindow
if (win && !win.isDestroyed()) {
fitSuggestionOverlay(win, payload, () =>
sendToPopupWindow(win, IPC_CHANNELS.POPUP_SUGGESTION.UPDATE, {
...payload,
targetTotal: payload.targetTotal ?? payload.candidates.length,
_keyHints: buildSuggestionKeyHints()
})
)
}
}
export function hideSuggestionOverlay(): void {
suggestionOverlayPlacement = null
suggestionOverlayBounds = null
if (suggestionOverlayWindow && !suggestionOverlayWindow.isDestroyed()) {
sendToPopupWindow(suggestionOverlayWindow, IPC_CHANNELS.POPUP_SUGGESTION.HIDE, {})
suggestionOverlayWindow.hide()

View file

@ -0,0 +1,191 @@
// src/main/windows/popup-window-factory.ts
//
// 팝업 창 인프라 정본 — 생성(공통 옵션·보안·로드) · 수명주기(진단·로드 추적·테마 주입) ·
// 로드 전 IPC 보류 · 표시(present).
//
// 팝업 6종이 BrowserWindow 옵션/webPreferences/hardenWebContents/로드 분기/수명주기/closed
// 처리를 각자 복붙하던 시절 옵션이 조용히 갈라졌다 — ResultPopup 에만 backgroundThrottling
// 이 빠져 숨김 상태에서 2-phase 측정이 멈췄다. 창 종류는 이제 PopupWindowSpec 선언만 다르다.
import { BrowserWindow } from 'electron'
import { join } from 'path'
import { is } from '@electron-toolkit/utils'
import { buildPopupThemeCss } from '@d3ro/ui/theme-vars'
import { getLogger } from '../services/LoggerService'
import { configGet } from '../services/ConfigService'
import { hardenWebContents } from './web-contents-hardening'
const logger = getLogger('PopupWindow')
/** 팝업 창 한 종류의 선언. */
export interface PopupWindowSpec {
/** 진단 로그 이름이자 renderer/popups/<name>/index.html 경로 */
name: string
/** 초기 크기(와 선택적 위치) */
bounds: { width: number; height: number; x?: number; y?: number }
/**
* 숨김 상태에서 렌더러를 늦출지.
*
* 기본 false — 팝업은 숨김(show:false) 상태로 미리 만들어 두고 내용을 채운 뒤 보인다.
* 늦추면 숨김 창의 requestAnimationFrame·타이머가 멈춰 측정/첫 그리기가 오지 않는다.
* 항상 보이는 창(자막 오버레이)만 true 로 둔다.
*/
backgroundThrottling?: boolean
/** 로드 전에 적용할 창 설정 (클릭 통과 등) */
beforeLoad?: (win: BrowserWindow) => void
/** 창이 닫혔다 — 소유자가 참조를 지운다 */
onClosed: () => void
}
/** 스펙 → BrowserWindow 생성자 옵션 (순수 — 테스트가 옵션 동등성을 검증한다). */
export function buildPopupWindowOptions(
spec: PopupWindowSpec,
preloadPath: string
): Electron.BrowserWindowConstructorOptions {
return {
...spec.bounds,
show: false,
frame: false,
transparent: true,
resizable: false,
alwaysOnTop: true,
skipTaskbar: true,
focusable: false,
webPreferences: {
preload: preloadPath,
sandbox: false,
contextIsolation: true,
nodeIntegration: false,
backgroundThrottling: spec.backgroundThrottling ?? false
}
}
}
/** 팝업 창을 만들고 로드한다 — 보안 강화 · 수명주기 · closed 처리를 한 번에 붙인다. */
export function createPopupWindow(spec: PopupWindowSpec): BrowserWindow {
const win = new BrowserWindow(buildPopupWindowOptions(spec, join(__dirname, '../preload/popup.js')))
hardenWebContents(win)
spec.beforeLoad?.(win)
const page = `popups/${spec.name}/index.html`
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
void win.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/${page}`)
} else {
void win.loadFile(join(__dirname, '../renderer', page))
}
attachPopupLifecycle(win, spec.name)
win.on('closed', spec.onClosed)
return win
}
// ── 테마 주입 ──────────────────────────────────────────────
/**
* 팝업 BrowserWindow에 현재 설정 테마의 CSS 변수를 insertCSS로 주입.
* did-finish-load 이후 호출해야 한다.
*/
export function injectPopupTheme(win: BrowserWindow): void {
const theme = configGet('theme') as string
// 'system'은 'dark'로 폴백 (팝업은 시스템 다크모드 감지 불가)
const resolvedTheme = theme === 'system' ? 'dark' : theme
const css = buildPopupThemeCss(resolvedTheme)
win.webContents.insertCSS(css).catch((err: unknown) => {
logger.warn('팝업 테마 CSS 주입 실패', err)
})
}
// ── 수명주기 · 로드 전 IPC 보류 ─────────────────────────────
/** Popup windows whose renderer finished loading and can receive IPC */
const popupReady = new WeakSet<BrowserWindow>()
/** IPC messages held back until the popup renderer is ready */
const pendingPopupMessages = new WeakMap<BrowserWindow, Array<{ channel: string; data: unknown }>>()
/**
* Promote popup renderer console output and load failures into the main log.
* Popups have no renderer logging otherwise, so a missing asset or a script
* exception stays invisible and the popup just renders static markup.
*/
function hookPopupDiagnostics(win: BrowserWindow, name: string): void {
win.webContents.on('console-message', (_event, level, message, line, sourceId) => {
logger.info(`[${name}] [Renderer] [${level}] ${message} (${sourceId}:${line})`)
})
win.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => {
logger.error(`[${name}] renderer load failed [${errorCode}] ${errorDescription} (${validatedURL})`)
})
win.webContents.on('render-process-gone', (_event, details) => {
logger.error(`[${name}] renderer process gone: ${details.reason} (exitCode=${details.exitCode})`)
})
}
/**
* Deliver IPC to a popup even while its renderer is still loading.
* webContents.send before load is silently dropped, which leaves popups stuck
* on their initial markup (for example a frozen 0:00 timer).
*/
export function sendToPopupWindow(win: BrowserWindow, channel: string, data: unknown): void {
if (win.isDestroyed() || win.webContents.isDestroyed()) return
if (popupReady.has(win)) {
win.webContents.send(channel, data)
return
}
const queue = pendingPopupMessages.get(win) ?? []
queue.push({ channel, data })
if (queue.length > 32) {
queue.splice(0, queue.length - 32)
}
pendingPopupMessages.set(win, queue)
}
function flushPopupMessages(win: BrowserWindow): void {
popupReady.add(win)
const queue = pendingPopupMessages.get(win)
if (!queue || queue.length === 0) return
pendingPopupMessages.delete(win)
for (const message of queue) {
if (!win.isDestroyed() && !win.webContents.isDestroyed()) {
win.webContents.send(message.channel, message.data)
}
}
}
/** Shared popup lifecycle: diagnostics + load tracking + theme injection */
function attachPopupLifecycle(win: BrowserWindow, name: string): void {
hookPopupDiagnostics(win, name)
win.webContents.on('did-start-loading', () => {
popupReady.delete(win)
})
win.webContents.on('did-finish-load', () => {
injectPopupTheme(win)
flushPopupMessages(win)
})
}
// ── 표시 ──────────────────────────────────────────────────
/**
* Show a popup without stealing focus, reliably on every call.
* After hide(), showInactive() can lose z-order and repaint, so popups stopped
* appearing from the second show onward: re-assert topmost and force a repaint.
*/
export function presentPopup(win: BrowserWindow, level?: 'floating' | 'screen-saver'): void {
if (!win.isVisible()) {
win.showInactive()
}
// Z-order/repaint adjustments must never break a capture session.
try {
win.setAlwaysOnTop(true, level)
win.moveTop()
win.webContents.invalidate()
} catch (err) {
logger.warn(`Popup present adjustment failed: ${err instanceof Error ? err.message : String(err)}`)
}
}

View file

@ -0,0 +1,46 @@
// src/main/windows/screen-geometry.ts
//
// ScreenGeometryPort 의 Electron 어댑터 — 좌표 공간 변환의 유일한 자리.
//
// UIA 사이드카는 DPI-aware(PerMonitor)로 동작해 캐럿/요소 rect 를 물리 픽셀로 준다.
// Electron screen API(커서 · 작업 영역 · setBounds)는 DIP 를 쓴다. 125%/150% 배율에서
// 둘을 섞으면 오버레이가 캐럿에서 멀리 떨어지거나 작업 영역 모서리에 붙는다.
// win32 는 screen.screenToDipRect 로 (rect 가 걸친 모니터의 배율로) 변환하고,
// 그 밖의 플랫폼은 네이티브 좌표가 곧 DIP 라 항등 변환이다.
import type { Screen } from 'electron'
import {
asDipPoint,
asDipRect,
type DipPoint,
type DipRect,
type PhysicalRect,
type ScreenGeometryPort
} from '@d3ro/core/popup-placement'
/** 어댑터가 쓰는 Electron screen 의 부분 집합 (테스트 대역이 이 모양만 흉내 내면 된다). */
export type ScreenApi = Pick<Screen, 'getCursorScreenPoint' | 'getDisplayNearestPoint'> &
Partial<Pick<Screen, 'screenToDipRect'>>
export function createElectronScreenGeometry(
screenApi: ScreenApi,
platform: NodeJS.Platform = process.platform
): ScreenGeometryPort {
const convert =
platform === 'win32' && typeof screenApi.screenToDipRect === 'function'
? screenApi.screenToDipRect.bind(screenApi)
: null
return {
toDip(rect: PhysicalRect): DipRect {
const plain = { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
return asDipRect(convert ? convert(null, plain) : plain)
},
workAreaNear(point: DipPoint): DipRect {
return asDipRect(screenApi.getDisplayNearestPoint({ x: point.x, y: point.y }).workArea)
},
cursor(): DipPoint {
return asDipPoint(screenApi.getCursorScreenPoint())
}
}
}

View file

@ -0,0 +1,60 @@
// src/main/windows/suggestion-overlay-geometry.ts
//
// 제안 오버레이 패널의 그려진 높이 — renderer/popups/suggestion-overlay/style.css 의 고정 치수와
// script.js 의 표시 규칙에서 나온다. 둘 중 하나를 바꾸면 이 파일도 함께 고친다.
//
// 창을 이 높이로 맞추는 이유: 투명 창이라도 창 영역은 클릭을 삼킨다. 고정 460x208 창은
// "준비 중" 한 줄(약 54px)만 보일 때도 캐럿 아래 150px 을 가려 그 아래 줄·버튼 클릭을 막았다.
export const SUGGESTION_OVERLAY_WIDTH = 460
/** script.js PAGE_SIZE — 한 페이지에 보이는 후보 수 */
const PAGE_SIZE = 3
// style.css 고정 치수 (px)
const ROOT_PADDING = 4 // #root padding
const PANEL_BORDER = 1 // .suggestion-panel border
const PANEL_PADDING = 6 // .suggestion-panel padding
const STATUS_HEIGHT = 32 // .status height
const ITEM_HEIGHT = 44 // .suggestion-item height
const ITEM_GAP = 2 // .candidates gap
/** .footer: margin-top 6 + border-top 1 + padding-top 6 + 행 14 + gap 5 + 키 안내 16 + padding-bottom 2 */
const FOOTER_HEIGHT = 6 + 1 + 6 + 14 + 5 + 16 + 2
const CHROME_HEIGHT = 2 * (ROOT_PADDING + PANEL_BORDER + PANEL_PADDING)
/** 한 페이지(3개) + 진행률/키 안내 — 가장 큰 패널이자 창 최대 높이 (= 208). */
export const SUGGESTION_OVERLAY_MAX_HEIGHT =
CHROME_HEIGHT + PAGE_SIZE * ITEM_HEIGHT + (PAGE_SIZE - 1) * ITEM_GAP + FOOTER_HEIGHT
export interface SuggestionPanelLayoutInput {
candidateCount: number
activeIndex: number
generating: boolean
warmingUp: boolean
/** 스트리밍 중인 부분 텍스트가 있는가 */
hasPartialText: boolean
}
/** 지금 그려지는 패널(+root 패딩) 높이 — script.js render() 와 같은 규칙으로 센다. */
export function suggestionPanelHeight(input: SuggestionPanelLayoutInput): number {
const count = Math.max(0, Math.floor(input.candidateCount))
const busy = input.warmingUp || (input.generating && count === 0)
let items = 0
if (count === 0) {
items = input.hasPartialText ? 1 : 0
} else {
const active = Math.min(Math.max(0, Math.floor(input.activeIndex)), count - 1)
const pageStart = Math.floor(active / PAGE_SIZE) * PAGE_SIZE
items = Math.min(PAGE_SIZE, count - pageStart)
}
const height =
CHROME_HEIGHT +
(busy ? STATUS_HEIGHT : 0) +
items * ITEM_HEIGHT +
Math.max(0, items - 1) * ITEM_GAP +
(count > 0 ? FOOTER_HEIGHT : 0)
return Math.min(height, SUGGESTION_OVERLAY_MAX_HEIGHT)
}

View file

@ -4,7 +4,10 @@
* 토큰이 주입되지 않는 상황(개발 초기 로드)을 위해 :root 폴백을 둔다.
*
* 구조: [후보 3개 — 고정 높이] / 가는 선 / [진행률 · 출처] / [키 안내]
* 창 높이(WindowManager SUGGESTION_OVERLAY_HEIGHT)는 이 고정 치수에서 나온다.
* 창 높이는 이 고정 치수에서 계산한다(main/windows/suggestion-overlay-geometry.ts) — 창을
* 그려진 패널 높이로 맞춰 투명 여백이 클릭을 삼키지 않게 한다. 높이에 영향을 주는 치수
* (#root/패널 패딩·테두리, .status, .suggestion-item, .candidates gap, .footer)를 바꾸면
* 그 파일도 함께 고친다. 각 줄은 내용과 무관하게 높이를 고정한다.
*/
:root {
@ -93,7 +96,7 @@ body {
display: flex;
align-items: center;
gap: 8px;
min-height: 32px;
height: 32px;
padding: 0 28px 0 8px;
}
@ -219,6 +222,7 @@ body {
.footer-row {
display: flex;
height: 14px;
align-items: center;
justify-content: space-between;
gap: 12px;
@ -252,6 +256,7 @@ body {
.key-hints {
display: flex;
height: 16px;
align-items: center;
gap: 10px;
white-space: nowrap;

View file

@ -324,8 +324,9 @@ describe('버그 6: 수락 직전 대상 창 확인', () => {
const result = await service.accept()
expect(result).toEqual({ ok: true })
expect(harness.insertText).toHaveBeenCalledWith('다음 단계도 확인하겠습니다.', 'clipboard')
expect(fake.expectInsert).toHaveBeenCalledWith('다음 단계도 확인하겠습니다.')
// 접두('…정리해서')와 후보 사이에 구분 공백을 붙여 넣는다(joinSuggestion).
expect(harness.insertText).toHaveBeenCalledWith(' 다음 단계도 확인하겠습니다.', 'clipboard')
expect(fake.expectInsert).toHaveBeenCalledWith(' 다음 단계도 확인하겠습니다.')
expect(fake.recordAccepted).toHaveBeenCalledTimes(1)
expect(fake.cancelInsert).not.toHaveBeenCalled()
})

View file

@ -0,0 +1,335 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { PersonalPhrase } from '@d3ro/core/input-intelligence'
const config = vi.hoisted(() => ({
suggestionEnabled: true as boolean,
suggestionModelId: 'model-a' as string | null,
llmModelId: 'model-a' as string | null,
inputExcludedApps: [] as string[],
suggestionTriggerDelayMs: 600,
suggestionMinPrefixChars: 8,
suggestionMaxRequestsPerMinute: 12,
suggestionDailyBudget: 500,
suggestionRequestTimeoutMs: 8000,
inputLearnTypedText: false as boolean,
inputTelemetryEnabled: false,
suggestionOverlayInteractive: true,
insertMethod: 'clipboard'
}))
const harness = vi.hoisted(() => ({
isAvailable: vi.fn(() => true),
streamGenerate: vi.fn(),
insertText: vi.fn(async (text: string) => ({ success: true, method: 'clipboard', textLength: text.length })),
phrases: [] as PersonalPhrase[]
}))
vi.mock('../../../src/main/services/ConfigService', () => ({
configGet: vi.fn((key: keyof typeof config) => config[key]),
configSet: vi.fn()
}))
vi.mock('../../../src/main/services/LoggerService', () => ({
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() })
}))
vi.mock('../../../src/main/services/LocalLLMService', () => ({
getLocalLLMService: () => ({ isAvailable: harness.isAvailable, streamGenerate: harness.streamGenerate })
}))
vi.mock('../../../src/main/services/InputTelemetryService', () => ({
getInputTelemetryService: () => ({ listPhrases: () => harness.phrases })
}))
vi.mock('../../../src/main/services/PersonalGraphService', () => ({
getPersonalGraphService: () => ({ retrieveContext: () => ({ continuations: [], related: [] }) })
}))
vi.mock('../../../src/main/services/TextInsertService', () => ({
getTextInsertService: () => ({ insertText: harness.insertText })
}))
vi.mock('../../../src/main/services/modifier-state', () => ({ waitForModifiersReleased: async () => true }))
vi.mock('../../../src/main/utils/win32-foreground', () => ({ getForegroundWindowInfo: () => null }))
vi.mock('../../../src/main/db', () => ({ getDatabase: vi.fn() }))
vi.mock('../../../src/main/db/schema', () => ({ suggestions: {} }))
import { SuggestionService, type SuggestionServiceDeps } from '../../../src/main/services/SuggestionService'
import { SuggestionBudget } from '../../../src/main/services/suggestion/SuggestionBudget'
import { ModelWarmTracker } from '../../../src/main/services/suggestion/ModelWarmTracker'
import type { TypingContext } from '../../../src/main/services/InputTelemetryService'
const PREFIX = '오늘 회의에서 논의한 내용을 정리해서'
const SUGGESTION = '다음 단계도 확인하겠습니다.'
function typingContext(overrides: Partial<TypingContext> = {}): TypingContext {
const prefix = overrides.prefix ?? PREFIX
return {
prefix,
fullText: prefix,
caretOffset: prefix.length,
anchor: { x: 100, y: 100, width: 2, height: 20 },
anchorKind: 'caret',
isPassword: false,
isEditable: true,
isComposing: false,
hasSelection: false,
available: true,
appName: 'Slack.exe',
windowTitle: 'general',
windowHandle: 101,
idleMs: 2000,
capturedAt: Date.now(),
editedSinceFocus: true,
typedRecently: true,
...overrides
}
}
interface Fakes {
deps: SuggestionServiceDeps
record: ReturnType<typeof vi.fn>
markAccepted: ReturnType<typeof vi.fn>
expectInsert: ReturnType<typeof vi.fn>
recordAccepted: ReturnType<typeof vi.fn>
}
function fakes(): Fakes {
const record = vi.fn()
const markAccepted = vi.fn()
const expectInsert = vi.fn(() => vi.fn())
const recordAccepted = vi.fn()
return {
record,
markAccepted,
expectInsert,
recordAccepted,
deps: {
repository: { record, markAccepted, list: vi.fn(() => []) },
budget: new SuggestionBudget(),
warmth: new ModelWarmTracker(),
foreground: { currentWindowHandle: () => 101 },
learning: { expectProgrammaticInsert: expectInsert, recordAccepted }
}
}
}
function streamOf(text: string): AsyncGenerator<string> {
return (async function* () {
yield text
})()
}
/** 호출자가 풀어 줄 때까지 기다리는 스트림 — abort 되면 예외로 끝난다. */
function gatedStream(signal: AbortSignal | undefined, gate: Promise<string>): AsyncGenerator<string> {
return (async function* () {
const text = await new Promise<string>((resolve, reject) => {
signal?.addEventListener('abort', () => reject(new Error('aborted')), { once: true })
void gate.then(resolve)
})
yield text
})()
}
interface Internal {
_generate(prefix: string, context: TypingContext, maxCandidates: number, maxChars: number): Promise<void>
_lastContext: TypingContext | null
_inFlight: boolean
_warmUpPromise: Promise<void> | null
}
let services: SuggestionService[] = []
function create(deps: SuggestionServiceDeps, warm = true): SuggestionService {
if (warm) deps.warmth.noteWarm('model-a', Date.now() + 600_000)
const service = new SuggestionService(deps)
services.push(service)
return service
}
beforeEach(() => {
config.suggestionEnabled = true
config.inputLearnTypedText = false
harness.isAvailable.mockReset()
harness.isAvailable.mockReturnValue(true)
harness.streamGenerate.mockReset()
harness.insertText.mockClear()
harness.phrases = []
})
afterEach(() => {
for (const service of services) service.dispose()
services = []
})
describe('진행 중 생성 — 요청 뒤 이어 치면 즉시 취소한다 (세션 규칙과 동일)', () => {
it('12자 미만 성장도 진행 중 요청을 취소하고 예산을 환급한다', async () => {
let signal: AbortSignal | undefined
let release: (text: string) => void = () => undefined
const gate = new Promise<string>((resolve) => {
release = resolve
})
harness.streamGenerate.mockImplementation((_text: string, options: { signal?: AbortSignal }) => {
signal = options.signal
return gatedStream(options.signal, gate)
})
const fake = fakes()
const service = create(fake.deps)
const internal = service as unknown as Internal
service.handleTypingContext(typingContext({ prefix: '회의 자료는 오늘' }))
expect(internal._inFlight).toBe(true)
service.handleTypingContext(typingContext({ prefix: '회의 자료는 오늘 저녁까지', idleMs: 100 }))
release(SUGGESTION)
await Promise.resolve()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(signal?.aborted).toBe(true)
expect(service.isVisible).toBe(false)
expect(fake.deps.budget.minuteCount).toBe(0)
expect(fake.record).not.toHaveBeenCalled()
})
it('결과 게시 직전 최신 문맥이 자랐으면 옛 접두용 결과를 게시하지 않는다', async () => {
let release: (text: string) => void = () => undefined
const gate = new Promise<string>((resolve) => {
release = resolve
})
harness.streamGenerate.mockImplementation((_text: string, options: { signal?: AbortSignal }) =>
gatedStream(options.signal, gate)
)
const fake = fakes()
const service = create(fake.deps)
const internal = service as unknown as Internal
const context = typingContext({ prefix: '회의 자료는 오늘' })
const generation = internal._generate('회의 자료는 오늘', context, 3, 160)
// 취소 경로를 거치지 않고 최신 문맥만 바뀐 경우 (게시 가드만 검증)
internal._lastContext = typingContext({ prefix: '회의 자료는 오늘 저녁까지' })
release(SUGGESTION)
await generation
expect(service.isVisible).toBe(false)
expect(service.getState().lastSkipReason).toBe('stale')
expect(fake.record).not.toHaveBeenCalled()
})
it('같은 접두(주기 스냅샷)면 그대로 게시한다', async () => {
harness.streamGenerate.mockImplementation(() => streamOf(SUGGESTION))
const fake = fakes()
const service = create(fake.deps)
const internal = service as unknown as Internal
internal._lastContext = typingContext()
await internal._generate(PREFIX, typingContext(), 3, 160)
expect(service.isVisible).toBe(true)
})
})
describe('수락 — 구분 공백', () => {
async function visibleModelSession(fake: Fakes): Promise<SuggestionService> {
harness.streamGenerate.mockImplementation(() => streamOf(SUGGESTION))
const service = create(fake.deps)
await (service as unknown as Internal)._generate(PREFIX, typingContext(), 3, 160)
expect(service.isVisible).toBe(true)
return service
}
it('단어 바로 뒤에서 멈췄으면 공백 하나를 앞에 붙여 삽입한다', async () => {
const fake = fakes()
const service = await visibleModelSession(fake)
expect(await service.accept()).toEqual({ ok: true })
expect(harness.insertText).toHaveBeenCalledWith(` ${SUGGESTION}`, 'clipboard')
expect(fake.expectInsert).toHaveBeenCalledWith(` ${SUGGESTION}`)
// 이력·학습은 후보 원문으로 — 이력 행을 찾을 수 있어야 한다.
expect(fake.markAccepted).toHaveBeenCalledWith(SUGGESTION)
expect(fake.recordAccepted).toHaveBeenCalledWith(SUGGESTION, expect.anything())
})
it('영어 문장 끝 마침표 뒤에도 떼어 쓴다', async () => {
harness.streamGenerate.mockImplementation(() => streamOf('Let me know if you have questions.'))
const fake = fakes()
const service = create(fake.deps)
const context = typingContext({ prefix: 'I sent the report.' })
await (service as unknown as Internal)._generate('I sent the report.', context, 3, 160)
await service.accept()
expect(harness.insertText).toHaveBeenCalledWith(' Let me know if you have questions.', 'clipboard')
})
it('제안 뒤 사용자가 이미 공백을 쳤으면(최신 문맥) 공백을 더하지 않는다', async () => {
const fake = fakes()
const service = await visibleModelSession(fake)
service.handleTypingContext(typingContext({ prefix: `${PREFIX} `, idleMs: 100 }))
expect(service.isVisible).toBe(true)
await service.accept()
expect(harness.insertText).toHaveBeenCalledWith(SUGGESTION, 'clipboard')
})
it('단어 중간에서 이어지는 로컬 기억 후보는 공백 없이 붙인다', async () => {
harness.phrases = [
{
id: 'p1',
phrase: 'Thank you for the update',
count: 3,
source: 'typed',
appName: 'Slack.exe',
lastUsedAt: Date.now(),
createdAt: Date.now()
}
]
const fake = fakes()
// 쿨다운 중에는 로컬 기억만 게시한다.
fake.deps.budget.noteFailure(Date.now())
fake.deps.budget.noteFailure(Date.now())
const service = create(fake.deps)
service.handleTypingContext(typingContext({ prefix: 'Thank yo' }))
expect(service.getState().candidates[0]?.text).toBe('u for the update')
await service.accept()
expect(harness.insertText).toHaveBeenCalledWith('u for the update', 'clipboard')
})
})
describe('모델 서버가 없을 때 — 끝나지 않는 "준비 중" 을 띄우지 않는다', () => {
it('준비 작업(워밍업)이 없으면 오버레이 없이 사유만 남긴다', () => {
harness.isAvailable.mockReturnValue(false)
const fake = fakes()
const service = create(fake.deps)
const updates: boolean[] = []
service.on('updated', (state) => updates.push(state.warmingUp))
service.handleTypingContext(typingContext())
expect(service.getState()).toMatchObject({ warmingUp: false, lastSkipReason: 'model-unavailable' })
expect(service.isPresentationActive).toBe(false)
expect(updates).not.toContain(true)
// 이어 쳐도 already-visible 로 굳지 않는다.
service.handleTypingContext(typingContext({ prefix: `${PREFIX} 공유`, idleMs: 2000 }))
expect(service.isPresentationActive).toBe(false)
})
})
describe('제안 이력 — 입력 원문은 학습 동의 규칙을 따른다', () => {
async function publish(fake: Fakes, context: TypingContext): Promise<void> {
harness.streamGenerate.mockImplementation(() => streamOf(SUGGESTION))
const service = create(fake.deps)
await (service as unknown as Internal)._generate(context.prefix, context, 3, 160)
expect(service.isVisible).toBe(true)
}
it('학습 동의가 없으면 접두 원문을 저장하지 않는다', async () => {
const fake = fakes()
await publish(fake, typingContext())
expect(fake.record).toHaveBeenCalledWith(expect.objectContaining({ prefix: '', text: SUGGESTION, appName: 'Slack.exe' }))
})
it('동의가 있어도 터미널(학습 제외 앱)의 명령 줄은 저장하지 않는다', async () => {
config.inputLearnTypedText = true
const fake = fakes()
await publish(fake, typingContext({ prefix: 'mysql -u root -pS3cret prod', appName: 'WindowsTerminal.exe' }))
expect(fake.record).toHaveBeenCalledWith(expect.objectContaining({ prefix: '' }))
})
it('동의가 있고 일반 앱이면 접두를 저장한다', async () => {
config.inputLearnTypedText = true
const fake = fakes()
await publish(fake, typingContext())
expect(fake.record).toHaveBeenCalledWith(expect.objectContaining({ prefix: PREFIX }))
})
})

View file

@ -0,0 +1,382 @@
// 팝업 창 배치·수명주기 회귀 (redteam r2-3)
// - 제안 오버레이: UIA 물리 픽셀 앵커를 DIP 로 바꿔 배치한다 (150% 배율)
// - 제안 오버레이: 창을 그려진 패널 높이로 줄여 투명 여백이 클릭을 삼키지 않는다
// - 결과 팝업: 숨긴 창의 측정(rAF)이 오지 않아도 뜨고, 측정 리스너를 쌓지 않는다
// - 팝업 팩토리: 측정/숨김 렌더가 필요한 팝업은 backgroundThrottling:false
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
type Listener = (...args: unknown[]) => void
interface FakeWebContents {
listeners: Map<string, Listener[]>
sent: Array<{ channel: string; data: unknown }>
on: (event: string, listener: Listener) => void
send: (channel: string, data: unknown) => void
isDestroyed: () => boolean
insertCSS: () => Promise<string>
invalidate: () => void
setWindowOpenHandler: () => void
}
interface FakeWindow {
options: Record<string, unknown>
webContents: FakeWebContents
bounds: Array<{ x: number; y: number; width: number; height: number }>
visible: boolean
showInactive: ReturnType<typeof vi.fn>
setIgnoreMouseEvents: ReturnType<typeof vi.fn>
}
const state = vi.hoisted(() => ({
windows: [] as FakeWindow[],
ipcOn: new Map<string, Listener[]>(),
scale: 1.5
}))
vi.unmock('../../../src/main/windows/WindowManager')
vi.mock('electron', () => {
class FakeBrowserWindow {
options: Record<string, unknown>
bounds: Array<{ x: number; y: number; width: number; height: number }> = []
visible = false
destroyed = false
windowListeners = new Map<string, Listener>()
webContents: FakeWebContents = {
listeners: new Map<string, Listener[]>(),
sent: [],
on(event: string, listener: Listener) {
const list = this.listeners.get(event) ?? []
list.push(listener)
this.listeners.set(event, list)
},
send(channel: string, data: unknown) {
this.sent.push({ channel, data })
},
isDestroyed: () => false,
insertCSS: async () => '',
invalidate: () => undefined,
setWindowOpenHandler: () => undefined
}
showInactive = vi.fn(() => {
this.visible = true
})
setIgnoreMouseEvents = vi.fn()
constructor(options: Record<string, unknown>) {
this.options = options
state.windows.push(this as unknown as FakeWindow)
}
on(event: string, listener: Listener): this {
this.windowListeners.set(event, listener)
return this
}
setBounds(bounds: { x: number; y: number; width: number; height: number }): void {
this.bounds.push(bounds)
}
isVisible(): boolean {
return this.visible
}
isDestroyed(): boolean {
return this.destroyed
}
hide(): void {
this.visible = false
}
setAlwaysOnTop = vi.fn()
moveTop = vi.fn()
loadURL = vi.fn(async () => undefined)
loadFile = vi.fn(async () => undefined)
}
const scaled = (value: number): number => Math.round(value / state.scale)
return {
app: { quit: vi.fn(), getPath: vi.fn(() => '.'), on: vi.fn(), getVersion: vi.fn(() => '1.0.0'), isPackaged: false },
BrowserWindow: Object.assign(FakeBrowserWindow, { getAllWindows: vi.fn(() => []) }),
screen: {
getPrimaryDisplay: vi.fn(() => ({
workAreaSize: { width: 1280, height: 680 },
workArea: { x: 0, y: 0, width: 1280, height: 680 }
})),
getAllDisplays: vi.fn(() => [{ workArea: { x: 0, y: 0, width: 1280, height: 680 } }]),
getCursorScreenPoint: vi.fn(() => ({ x: 640, y: 400 })),
// 150% 모니터 하나: 물리 1920x1080 → DIP 1280x720 (작업 영역 높이 680)
getDisplayNearestPoint: vi.fn(() => ({ workArea: { x: 0, y: 0, width: 1280, height: 680 } })),
screenToDipRect: vi.fn((_win: unknown, rect: { x: number; y: number; width: number; height: number }) => ({
x: scaled(rect.x),
y: scaled(rect.y),
width: scaled(rect.width),
height: scaled(rect.height)
}))
},
ipcMain: {
on: vi.fn((channel: string, listener: Listener) => {
const list = state.ipcOn.get(channel) ?? []
list.push(listener)
state.ipcOn.set(channel, list)
}),
once: vi.fn(),
handle: vi.fn(),
removeHandler: vi.fn()
},
Menu: { setApplicationMenu: vi.fn(), buildFromTemplate: vi.fn() },
clipboard: { writeText: vi.fn() },
shell: { openExternal: vi.fn(async () => undefined) },
safeStorage: { isEncryptionAvailable: vi.fn(() => false) },
nativeImage: { createFromPath: vi.fn(), createEmpty: vi.fn() },
Tray: vi.fn(),
dialog: { showErrorBox: vi.fn() }
}
})
vi.mock('@electron-toolkit/utils', () => ({ is: { dev: false } }))
const config: Record<string, unknown> = {
theme: 'dark',
language: 'ko',
suggestionOverlayInteractive: true,
keyBindings: {}
}
vi.mock('../../../src/main/services/ConfigService', () => ({
configGet: (key: string) => config[key],
configSet: (key: string, value: unknown) => {
config[key] = value
}
}))
const originalPlatform = process.platform
beforeAll(() => {
// 어댑터는 win32 에서만 screenToDipRect 를 쓴다 — CI(리눅스)에서도 같은 경로를 검증한다.
Object.defineProperty(process, 'platform', { value: 'win32' })
})
afterAll(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform })
})
beforeEach(() => {
vi.resetModules()
state.windows.length = 0
state.ipcOn.clear()
state.scale = 1.5
})
afterEach(() => {
vi.useRealTimers()
})
function finishLoad(win: FakeWindow): void {
for (const listener of win.webContents.listeners.get('did-finish-load') ?? []) listener()
}
function sentOn(win: FakeWindow, channel: string): unknown[] {
return win.webContents.sent.filter((message) => message.channel === channel).map((message) => message.data)
}
describe('제안 오버레이 — 물리 픽셀 앵커를 DIP 로 배치한다', () => {
const content = { candidates: [], activeIndex: 0, generating: true, warmingUp: false, partialText: null }
it('150% 에서 캐럿 물리 (600,400) → DIP (400,267) 바로 아래, 창은 "생성 중" 한 줄 높이(54)', async () => {
const wm = await import('../../../src/main/windows/WindowManager')
wm.showSuggestionOverlay({
...content,
anchor: { x: 600, y: 400, width: 2, height: 30 },
anchorKind: 'caret',
appName: 'notepad.exe'
})
const win = state.windows[0]
expect(win.bounds.at(-1)).toEqual({ x: 400, y: 267 + 20 + 6, width: 460, height: 54 })
})
it('150% 에서 캐럿 물리 (1500,900) 은 작업 영역 구석에 박히지 않고 캐럿 위로 뒤집혀 캐럿 곁에 붙는다', async () => {
const wm = await import('../../../src/main/windows/WindowManager')
wm.showSuggestionOverlay({
...content,
anchor: { x: 1500, y: 900, width: 2, height: 30 },
anchorKind: 'caret',
appName: 'notepad.exe'
})
const bounds = state.windows[0].bounds.at(-1)
// DIP 캐럿 (1000,600) — 창 아래 모서리가 캐럿 위 6px 에 붙는다
expect(bounds).toEqual({ x: 820, y: 600 - 6 - 54, width: 460, height: 54 })
})
it('후보가 도착하면 같은 자리에서 높이만 늘고, 창이 그려진 패널보다 크지 않다', async () => {
const wm = await import('../../../src/main/windows/WindowManager')
wm.showSuggestionOverlay({
...content,
anchor: { x: 600, y: 400, width: 2, height: 30 },
anchorKind: 'caret',
appName: 'notepad.exe'
})
const win = state.windows[0]
win.visible = true
wm.updateSuggestionOverlay({
candidates: [{ text: '다음 문장', rank: 0 }],
activeIndex: 0,
generating: true,
warmingUp: false,
partialText: null
})
expect(win.bounds.at(-1)).toEqual({ x: 400, y: 293, width: 460, height: 116 })
// 같은 높이의 스트리밍 갱신은 창을 다시 옮기지 않는다
const count = win.bounds.length
wm.updateSuggestionOverlay({
candidates: [{ text: '다음 문장', rank: 0 }],
activeIndex: 0,
generating: true,
warmingUp: false,
partialText: null
})
expect(win.bounds.length).toBe(count)
})
})
describe('결과 팝업 — 숨긴 창에서 측정이 오지 않아도 뜬다', () => {
it('측정 리스너는 모듈에 하나뿐이고, 표시 호출마다 쌓이지 않는다', async () => {
const wm = await import('../../../src/main/windows/WindowManager')
const { IPC_CHANNELS } = await import('@d3ro/core/ipc-channels')
wm.showResultPopup('첫 번째')
wm.showResultPopup('두 번째')
wm.showResultPopup('세 번째')
expect(state.ipcOn.get(IPC_CHANNELS.POPUP_RESULT.MEASURED)).toHaveLength(1)
})
it('측정 없이도 창을 먼저 띄우고, 한도가 지나면 내용을 보인다 (두 번째 표시 포함)', async () => {
vi.useFakeTimers()
const wm = await import('../../../src/main/windows/WindowManager')
const { IPC_CHANNELS } = await import('@d3ro/core/ipc-channels')
wm.showResultPopup('받아쓴 텍스트')
const win = state.windows[0]
finishLoad(win)
expect(win.visible).toBe(true)
vi.advanceTimersByTime(400)
expect(sentOn(win, IPC_CHANNELS.POPUP_RESULT.SHOW)).toHaveLength(1)
// 자동 닫힘 뒤 두 번째 실패 — 렌더러가 측정을 보내지 않아도 다시 뜬다.
wm.hideResultPopup()
expect(win.visible).toBe(false)
wm.showResultPopup('다시 받아쓴 텍스트')
expect(win.visible).toBe(true)
vi.advanceTimersByTime(400)
expect(sentOn(win, IPC_CHANNELS.POPUP_RESULT.SHOW)).toHaveLength(2)
})
it('측정이 오면 그 크기로 커서 곁에 맞추고 바로 보인다', async () => {
vi.useFakeTimers()
const wm = await import('../../../src/main/windows/WindowManager')
const { IPC_CHANNELS } = await import('@d3ro/core/ipc-channels')
wm.showResultPopup('받아쓴 텍스트')
const win = state.windows[0]
finishLoad(win)
const measured = state.ipcOn.get(IPC_CHANNELS.POPUP_RESULT.MEASURED)?.[0]
measured?.({ sender: win.webContents }, { width: 300, height: 120 })
// 커서 (640,400): 가로 가운데, 위 20px
expect(win.bounds.at(-1)).toEqual({ x: 490, y: 260, width: 300, height: 120 })
expect(sentOn(win, IPC_CHANNELS.POPUP_RESULT.SHOW)).toEqual([{ autoHideMs: 5000 }])
// 한도가 지나도 두 번 보이지 않는다
vi.advanceTimersByTime(400)
expect(sentOn(win, IPC_CHANNELS.POPUP_RESULT.SHOW)).toHaveLength(1)
})
it('다른 창이 보낸 측정은 무시한다', async () => {
vi.useFakeTimers()
const wm = await import('../../../src/main/windows/WindowManager')
const { IPC_CHANNELS } = await import('@d3ro/core/ipc-channels')
wm.showResultPopup('받아쓴 텍스트')
const win = state.windows[0]
finishLoad(win)
state.ipcOn.get(IPC_CHANNELS.POPUP_RESULT.MEASURED)?.[0]?.({ sender: {} }, { width: 300, height: 120 })
expect(sentOn(win, IPC_CHANNELS.POPUP_RESULT.SHOW)).toHaveLength(0)
})
})
describe('팝업 팩토리 — 공통 옵션', () => {
it('측정/숨김 렌더가 필요한 팝업은 backgroundThrottling:false, 항상 보이는 자막 창만 기존값', async () => {
const wm = await import('../../../src/main/windows/WindowManager')
wm.getResultPopupWindow()
wm.getHistoryPopupWindow()
wm.getCommandPopupWindow()
wm.getRecordingTipWindow()
wm.getSuggestionOverlayWindow()
wm.getCaptionOverlayWindow()
const throttling = state.windows.map(
(win) => (win.options.webPreferences as { backgroundThrottling?: boolean }).backgroundThrottling
)
expect(throttling).toEqual([false, false, false, false, false, true])
for (const win of state.windows) {
expect(win.options).toMatchObject({ show: false, frame: false, transparent: true, focusable: false, alwaysOnTop: true })
expect(win.options.webPreferences).toMatchObject({ contextIsolation: true, nodeIntegration: false })
}
// 자막 창은 로드 전에 클릭 통과
expect(state.windows[5].setIgnoreMouseEvents).toHaveBeenCalledWith(true, { forward: true })
})
it('closed 이벤트면 창 참조를 지워 다음 호출에서 다시 만든다', async () => {
const wm = await import('../../../src/main/windows/WindowManager')
const first = wm.getResultPopupWindow() as unknown as { destroyed: boolean; windowListeners: Map<string, Listener> }
first.destroyed = true
first.windowListeners.get('closed')?.()
wm.getResultPopupWindow()
expect(state.windows).toHaveLength(2)
})
})
describe('suggestionPanelHeight — style.css 고정 치수와 같은 규칙', () => {
it('상태별 패널 높이', async () => {
const { suggestionPanelHeight, SUGGESTION_OVERLAY_MAX_HEIGHT } = await import(
'../../../src/main/windows/suggestion-overlay-geometry'
)
const base = { candidateCount: 0, activeIndex: 0, generating: false, warmingUp: false, hasPartialText: false }
expect(SUGGESTION_OVERLAY_MAX_HEIGHT).toBe(208)
expect(suggestionPanelHeight({ ...base, warmingUp: true })).toBe(54)
expect(suggestionPanelHeight({ ...base, generating: true })).toBe(54)
expect(suggestionPanelHeight({ ...base, generating: true, hasPartialText: true })).toBe(98)
expect(suggestionPanelHeight({ ...base, candidateCount: 1, generating: true })).toBe(116)
expect(suggestionPanelHeight({ ...base, candidateCount: 2 })).toBe(162)
expect(suggestionPanelHeight({ ...base, candidateCount: 3 })).toBe(208)
// 두 번째 페이지에 후보 1개
expect(suggestionPanelHeight({ ...base, candidateCount: 4, activeIndex: 3 })).toBe(116)
expect(suggestionPanelHeight({ ...base, candidateCount: 12, activeIndex: 5 })).toBe(208)
})
})
describe('createElectronScreenGeometry — 좌표 공간 변환은 어댑터 한 곳에서', () => {
function fakeScreen() {
return {
getCursorScreenPoint: vi.fn(() => ({ x: 10, y: 20 })),
getDisplayNearestPoint: vi.fn(() => ({ workArea: { x: 0, y: 0, width: 1280, height: 680 } })),
screenToDipRect: vi.fn((_win: unknown, rect: { x: number; y: number; width: number; height: number }) => ({
x: rect.x / 1.5,
y: rect.y / 1.5,
width: rect.width / 1.5,
height: rect.height / 1.5
}))
}
}
it('win32 은 screenToDipRect 로 변환한다', async () => {
const { createElectronScreenGeometry } = await import('../../../src/main/windows/screen-geometry')
const screenApi = fakeScreen()
const port = createElectronScreenGeometry(screenApi as never, 'win32')
expect(port.toDip({ x: 600, y: 300, width: 3, height: 30 })).toEqual({ x: 400, y: 200, width: 2, height: 20 })
expect(screenApi.screenToDipRect).toHaveBeenCalledWith(null, { x: 600, y: 300, width: 3, height: 30 })
})
it('그 밖의 플랫폼은 항등 변환이다 (네이티브 좌표 = DIP)', async () => {
const { createElectronScreenGeometry } = await import('../../../src/main/windows/screen-geometry')
const screenApi = fakeScreen()
const port = createElectronScreenGeometry(screenApi as never, 'darwin')
expect(port.toDip({ x: 600, y: 300, width: 3, height: 30 })).toEqual({ x: 600, y: 300, width: 3, height: 30 })
expect(screenApi.screenToDipRect).not.toHaveBeenCalled()
expect(port.cursor()).toEqual({ x: 10, y: 20 })
expect(port.workAreaNear(port.cursor())).toEqual({ x: 0, y: 0, width: 1280, height: 680 })
})
})