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:
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} />
|
||||||
앱 시작 시 자동으로 실행
|
앱 시작 시 자동으로 실행
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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 />);
|
||||||
|
|||||||
Reference in New Issue
Block a user