fix(macos): hidden autostart dock indicator + 자동실행 mismatch false positive

두 macOS 한정 버그 묶음:

1. autostart --hidden 으로 spawn 시 quickCapture (NSPanel) 만 떠 있어
   dock running indicator (점) 가 표출 안 됨 — NSPanel 은 NSApp main window
   로 register 안 됨. inboxWindow 를 hidden 상태로 미리 create + ready-to-show
   시점에 showInactive → hide trick 으로 NSApp 에 register, 사용자 화면
   깜빡임 없이 dock 점 켜짐.

2. SettingsPage 의 자동실행 mismatch 경고가 macOS 에서 false positive.
   macOS 13+ 의 SMAppService API 가 args 옵션 무시 + unsigned/Electron
   앱에 대해 executableWillLaunchAtLogin 을 자주 false 로 반환 → 정상 등록
   상태에서도 경고 떠 있음. AutostartDiagnostic 결과에 platform 필드 추가,
   willLaunch 신호는 win32 에서만 mismatch 판정에 사용.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
th-kim0823
2026-05-14 13:11:17 +09:00
parent 64935d943c
commit a68feae20e
7 changed files with 89 additions and 18 deletions

View File

@@ -193,9 +193,10 @@ app.whenReady().then(async () => {
}); });
if (!reg.ok) logger.warn('hotkey.register.failed', { reason: reg.reason }); if (!reg.ok) logger.warn('hotkey.register.failed', { reason: reg.reason });
if (!startedHidden) { // macOS LoginItems autostart 시 startedHidden=true 로 spawn — 그대로 두면 quickCapture
createInboxWindow(); // (NSPanel) 만 떠 있어 dock running indicator 미표출. inboxWindow 를 hidden 상태로
} // 미리 create 하면 NSApp register → 점 표출 + 사용자가 dock 아이콘 확인으로 앱 살아있음 인지.
createInboxWindow({ visible: !startedHidden });
createQuickCaptureWindow(); createQuickCaptureWindow();
await worker.loadFromDb(); await worker.loadFromDb();

View File

@@ -13,6 +13,12 @@ export interface AutostartState {
withArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean }; withArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean };
noArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean }; noArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean };
execPath: string; execPath: string;
/**
* 플랫폼 분기용. macOS 13+ 의 SMAppService API 는 args 옵션 무시 + unsigned/Electron
* 앱에 대해 executableWillLaunchAtLogin 이 false 를 반환할 수 있어, mismatch 판정에서
* 해당 신호를 제외해야 false positive 방지 가능.
*/
platform: NodeJS.Platform;
registryPath?: string; registryPath?: string;
registryValue?: string | null; registryValue?: string | null;
} }
@@ -26,7 +32,8 @@ export async function collectAutostartState(): Promise<AutostartState> {
const state: AutostartState = { const state: AutostartState = {
withArgs: { openAtLogin: w.openAtLogin, executableWillLaunchAtLogin: w.executableWillLaunchAtLogin }, withArgs: { openAtLogin: w.openAtLogin, executableWillLaunchAtLogin: w.executableWillLaunchAtLogin },
noArgs: { openAtLogin: n.openAtLogin, executableWillLaunchAtLogin: n.executableWillLaunchAtLogin }, noArgs: { openAtLogin: n.openAtLogin, executableWillLaunchAtLogin: n.executableWillLaunchAtLogin },
execPath: process.execPath execPath: process.execPath,
platform: process.platform
}; };
if (process.platform === 'win32') { if (process.platform === 'win32') {
state.registryPath = `${WIN_REGISTRY_PATH}\\${WIN_REGISTRY_KEY}`; state.registryPath = `${WIN_REGISTRY_PATH}\\${WIN_REGISTRY_KEY}`;

View File

@@ -11,10 +11,13 @@ export function getInboxWindow(): BrowserWindowType | null {
return inboxWindow; return inboxWindow;
} }
export function createInboxWindow(): BrowserWindowType { export function createInboxWindow(opts: { visible?: boolean } = {}): BrowserWindowType {
const visible = opts.visible ?? true;
if (inboxWindow && !inboxWindow.isDestroyed()) { if (inboxWindow && !inboxWindow.isDestroyed()) {
inboxWindow.show(); if (visible) {
inboxWindow.focus(); inboxWindow.show();
inboxWindow.focus();
}
return inboxWindow; return inboxWindow;
} }
@@ -43,6 +46,19 @@ export function createInboxWindow(): BrowserWindowType {
} }
}); });
inboxWindow.once('ready-to-show', () => inboxWindow?.show()); inboxWindow.once('ready-to-show', () => {
if (visible) {
inboxWindow?.show();
return;
}
// macOS hidden autostart: regular NSWindow 를 NSApp 에 register 해야 dock running
// indicator (점) 가 표출된다. panel type 의 quickCapture 만 있으면 NSPanel 미인지 →
// dock 점이 안 보여 "앱이 안 떠 있는 것처럼" 보이는 버그. showInactive 로 focus 점유
// 없이 짧게 표출 후 즉시 hide — 사용자 화면 깜빡임 최소화.
if (process.platform === 'darwin') {
inboxWindow?.showInactive();
inboxWindow?.hide();
}
});
return inboxWindow; return inboxWindow;
} }

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import type { AutostartResponse } from '@shared/types'; import type { AutostartResponse } from '@shared/types';
import { inboxApi } from '../../api.js'; import { inboxApi } from '../../api.js';
import { SectionIntro } from './SectionIntro.js';
export function AutostartSection(): React.ReactElement { export function AutostartSection(): React.ReactElement {
const [data, setData] = useState<AutostartResponse | null>(null); const [data, setData] = useState<AutostartResponse | null>(null);
@@ -31,14 +32,19 @@ export function AutostartSection(): React.ReactElement {
} }
const d = data.diagnostic; const d = data.diagnostic;
// v0.2.7 F12 deeper fix — withArgs vs noArgs 의 openAtLogin 불일치, 또 // withArgs vs noArgs 의 openAtLogin 불일치는 양 플랫폼에서 진짜 mismatch 시그널.
// executableWillLaunchAtLogin = false 면 mismatch 로 간주 (등록은 됐지만 실제론 // executableWillLaunchAtLogin 은 Win 에서만 신뢰 — macOS 13+ SMAppService API 는
// 로그인 시 실행되지 않을 수 있는 상태). // LoginItems 에 등록되어 있어도 unsigned/Electron 앱에 대해 false 를 자주 반환해
const mismatch = d.withArgs.openAtLogin !== d.noArgs.openAtLogin // false positive 가 발생함. macOS 는 이 신호를 mismatch 판정에서 제외.
|| (data.openAtLogin && !d.withArgs.executableWillLaunchAtLogin); const willLaunchSignal = d.platform === 'win32' && data.openAtLogin && !d.withArgs.executableWillLaunchAtLogin;
const mismatch = d.withArgs.openAtLogin !== d.noArgs.openAtLogin || willLaunchSignal;
return ( return (
<div> <div>
<SectionIntro>
Inkling . ,
Cmd+Shift+J (macOS) / Ctrl+Shift+J (Windows) .
</SectionIntro>
<label style={{ display: 'flex', gap: 8, alignItems: 'center', fontSize: 13 }}> <label style={{ display: 'flex', gap: 8, alignItems: 'center', fontSize: 13 }}>
<input type="checkbox" checked={data.openAtLogin} onChange={onToggle} /> <input type="checkbox" checked={data.openAtLogin} onChange={onToggle} />

View File

@@ -113,6 +113,8 @@ export interface AutostartDiagnostic {
withArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean }; withArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean };
noArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean }; noArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean };
execPath: string; execPath: string;
/** mismatch 판정 플랫폼 분기용 (macOS 의 SMAppService API 한계 우회). */
platform: NodeJS.Platform;
registryPath?: string; registryPath?: string;
registryValue?: string | null; registryValue?: string | null;
} }

View File

@@ -42,6 +42,7 @@ describe('AutostartDiagnostic — collectAutostartState', () => {
expect(state.withArgs).toEqual({ openAtLogin: true, executableWillLaunchAtLogin: true }); expect(state.withArgs).toEqual({ openAtLogin: true, executableWillLaunchAtLogin: true });
expect(state.noArgs).toEqual({ openAtLogin: false, executableWillLaunchAtLogin: true }); expect(state.noArgs).toEqual({ openAtLogin: false, executableWillLaunchAtLogin: true });
expect(state.execPath).toBe(process.execPath); expect(state.execPath).toBe(process.execPath);
expect(state.platform).toBe('darwin');
}); });
it('passes args=["--hidden"] for the first call, no args for the second', async () => { it('passes args=["--hidden"] for the first call, no args for the second', async () => {

View File

@@ -3,15 +3,17 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import '@testing-library/jest-dom/vitest'; import '@testing-library/jest-dom/vitest';
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'; import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
function makeDiag(open: boolean): { function makeDiag(open: boolean, platform: NodeJS.Platform = 'win32'): {
withArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean }; withArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean };
noArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean }; noArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean };
execPath: string; execPath: string;
platform: NodeJS.Platform;
} { } {
return { return {
withArgs: { openAtLogin: open, executableWillLaunchAtLogin: open }, withArgs: { openAtLogin: open, executableWillLaunchAtLogin: open },
noArgs: { openAtLogin: open, executableWillLaunchAtLogin: open }, noArgs: { openAtLogin: open, executableWillLaunchAtLogin: open },
execPath: '/path/to/exe' execPath: '/path/to/exe',
platform
}; };
} }
@@ -51,7 +53,8 @@ describe('AutostartSection', () => {
diagnostic: { diagnostic: {
withArgs: { openAtLogin: true, executableWillLaunchAtLogin: true }, withArgs: { openAtLogin: true, executableWillLaunchAtLogin: true },
noArgs: { openAtLogin: false, executableWillLaunchAtLogin: true }, noArgs: { openAtLogin: false, executableWillLaunchAtLogin: true },
execPath: '/path/to/Inkling.exe' execPath: '/path/to/Inkling.exe',
platform: 'win32'
} }
}); });
render(<AutostartSection />); render(<AutostartSection />);
@@ -71,6 +74,7 @@ describe('AutostartSection', () => {
withArgs: { openAtLogin: true, executableWillLaunchAtLogin: true }, withArgs: { openAtLogin: true, executableWillLaunchAtLogin: true },
noArgs: { openAtLogin: true, executableWillLaunchAtLogin: true }, noArgs: { openAtLogin: true, executableWillLaunchAtLogin: true },
execPath: 'C:\\app.exe', execPath: 'C:\\app.exe',
platform: 'win32',
registryPath: 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run\\Inkling', registryPath: 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run\\Inkling',
registryValue: '"C:\\app.exe" --hidden' registryValue: '"C:\\app.exe" --hidden'
} }
@@ -89,7 +93,8 @@ describe('AutostartSection', () => {
diagnostic: { diagnostic: {
withArgs: { openAtLogin: true, executableWillLaunchAtLogin: true }, withArgs: { openAtLogin: true, executableWillLaunchAtLogin: true },
noArgs: { openAtLogin: true, executableWillLaunchAtLogin: true }, noArgs: { openAtLogin: true, executableWillLaunchAtLogin: true },
execPath: '/p' execPath: '/p',
platform: 'win32'
} }
}); });
render(<AutostartSection />); render(<AutostartSection />);
@@ -97,6 +102,38 @@ describe('AutostartSection', () => {
expect(screen.queryByText(/⚠️/)).not.toBeInTheDocument(); expect(screen.queryByText(/⚠️/)).not.toBeInTheDocument();
}); });
it('macOS: no false-positive mismatch when willLaunch=false (SMAppService 한계)', async () => {
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
vi.mocked(inboxApi.getAutostart).mockResolvedValueOnce({
openAtLogin: true,
diagnostic: {
withArgs: { openAtLogin: true, executableWillLaunchAtLogin: false },
noArgs: { openAtLogin: true, executableWillLaunchAtLogin: false },
execPath: '/Applications/Inkling.app',
platform: 'darwin'
}
});
render(<AutostartSection />);
await screen.findByRole('checkbox');
expect(screen.queryByText(/⚠️/)).not.toBeInTheDocument();
});
it('Win: mismatch warning when openAtLogin=true but willLaunch=false', async () => {
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
vi.mocked(inboxApi.getAutostart).mockResolvedValueOnce({
openAtLogin: true,
diagnostic: {
withArgs: { openAtLogin: true, executableWillLaunchAtLogin: false },
noArgs: { openAtLogin: true, executableWillLaunchAtLogin: false },
execPath: 'C:\\app.exe',
platform: 'win32'
}
});
render(<AutostartSection />);
await screen.findByRole('checkbox');
expect(await screen.findByText(/⚠️/)).toBeInTheDocument();
});
it('"재등록" button calls setAutostart with current openAtLogin value', async () => { it('"재등록" button calls setAutostart with current openAtLogin value', async () => {
const { inboxApi } = await import('../../src/renderer/inbox/api.js'); const { inboxApi } = await import('../../src/renderer/inbox/api.js');
vi.mocked(inboxApi.getAutostart).mockResolvedValueOnce({ vi.mocked(inboxApi.getAutostart).mockResolvedValueOnce({
@@ -104,7 +141,8 @@ describe('AutostartSection', () => {
diagnostic: { diagnostic: {
withArgs: { openAtLogin: true, executableWillLaunchAtLogin: true }, withArgs: { openAtLogin: true, executableWillLaunchAtLogin: true },
noArgs: { openAtLogin: true, executableWillLaunchAtLogin: true }, noArgs: { openAtLogin: true, executableWillLaunchAtLogin: true },
execPath: '/p' execPath: '/p',
platform: 'win32'
} }
}); });
render(<AutostartSection />); render(<AutostartSection />);