diff --git a/src-tauri/src/commands/launcher.rs b/src-tauri/src/commands/launcher.rs index ff490ec..38abe04 100644 --- a/src-tauri/src/commands/launcher.rs +++ b/src-tauri/src/commands/launcher.rs @@ -458,7 +458,7 @@ pub(crate) const PLATFORM_UNSUPPORTED_CODE: &str = "launcher.platform_unsupporte fn platform_unsupported_error() -> CommandError { CommandError::new( PLATFORM_UNSUPPORTED_CODE, - "detect_game_path requires Windows (registry/default-path lookup for game install path)", + "launcher command requires Windows", ) } @@ -873,6 +873,46 @@ pub async fn kill_game_processes(pids: Vec) -> Result, CommandErro } } +/// Close MapleStory's Nexon launcher Play dialog if it is currently +/// visible. +/// +/// Mirrors one tick of WPF `checkPlayPage_Tick`: find +/// `StartUpDlgClass` / `MapleStory` and post `WM_CLOSE`. The +/// frontend drives this command on a short interval when the +/// MapleStory-only `skipPlayWnd` preference is enabled. +#[tauri::command] +#[specta::specta] +pub async fn close_maple_play_window() -> Result { + #[cfg(target_os = "windows")] + { + maple_guard_imp::close_maple_play_window_impl().await + } + #[cfg(not(target_os = "windows"))] + { + Err(platform_unsupported_error()) + } +} + +/// Best-effort terminate MapleStory's `Patcher.exe` from the same +/// directory as `game_path`. +/// +/// Mirrors one tick of WPF `checkPatcher_Tick`'s kill branch. The +/// frontend drives this command on a short interval when the +/// MapleStory-only `autoKillPatcher` preference is enabled. +#[tauri::command] +#[specta::specta] +pub async fn check_and_kill_maple_patcher(game_path: String) -> Result, CommandError> { + #[cfg(target_os = "windows")] + { + maple_guard_imp::check_and_kill_maple_patcher_impl(game_path).await + } + #[cfg(not(target_os = "windows"))] + { + let _ = game_path; + Err(platform_unsupported_error()) + } +} + /// Type the account name + OTP into the MapleStory launcher's /// login dialog and press Enter, replicating the tail of /// `getOtpWorker_RunWorkerCompleted` @@ -1175,6 +1215,53 @@ mod list_imp { } } +// ===================================================================== +// Windows-only MapleStory Play/Patcher guards +// ===================================================================== + +#[cfg(target_os = "windows")] +mod maple_guard_imp { + use super::*; + use crate::services::process::{ + check_and_kill_patcher as svc_check_and_kill_patcher, + close_play_window as svc_close_play_window, + }; + + pub(super) async fn close_maple_play_window_impl() -> Result { + tokio::task::spawn_blocking(svc_close_play_window) + .await + .map_err(|join_err| { + CommandError::new( + SPAWN_BLOCKING_FAILED_CODE, + format!("close_maple_play_window spawn_blocking failed: {join_err}"), + ) + .with_details(json!({ + "is_panic": join_err.is_panic(), + "is_cancelled": join_err.is_cancelled(), + })) + })? + .map_err(CommandError::from) + } + + pub(super) async fn check_and_kill_maple_patcher_impl( + game_path: String, + ) -> Result, CommandError> { + tokio::task::spawn_blocking(move || svc_check_and_kill_patcher(&PathBuf::from(game_path))) + .await + .map_err(|join_err| { + CommandError::new( + SPAWN_BLOCKING_FAILED_CODE, + format!("check_and_kill_maple_patcher spawn_blocking failed: {join_err}"), + ) + .with_details(json!({ + "is_panic": join_err.is_panic(), + "is_cancelled": join_err.is_cancelled(), + })) + })? + .map_err(CommandError::from) + } +} + // ===================================================================== // Windows-only auto-paste orchestration (D5d) // ===================================================================== diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 25c06ad..70e0ec5 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -288,6 +288,9 @@ pub fn build_specta_builder() -> Builder { // launcher (P10.3 — D5c process) launcher::list_game_processes, launcher::kill_game_processes, + // launcher (MapleStory Play/Patcher guards) + launcher::close_maple_play_window, + launcher::check_and_kill_maple_patcher, // launcher (P10.3 — D5d auto-paste) launcher::auto_paste, // game (P12.3 D2 — list_games) diff --git a/src/composables/useGameLauncher.ts b/src/composables/useGameLauncher.ts index dcd2ed8..445a676 100644 --- a/src/composables/useGameLauncher.ts +++ b/src/composables/useGameLauncher.ts @@ -94,6 +94,15 @@ export interface UseGameLauncherReturn { runGame: (accountId?: string, password?: string) => Promise } +const MAPLE_GUARD_GAME_CODES = new Set(['610074_T9', '610075_T9']) +const MAPLE_GUARD_INTERVAL_MS = 100 +const MAPLE_GUARD_TICKS = 150 + +function parseBool(value: string | undefined, fallback: boolean): boolean { + if (value === undefined || value === '') return fallback + return value.toLowerCase() === 'true' +} + export function useGameLauncher(): UseGameLauncherReturn { const { t } = useI18n() const game = useGameStore() @@ -305,6 +314,30 @@ export function useGameLauncher(): UseGameLauncherReturn { return true } + function runMapleLaunchGuards(gamePath: string): void { + const code = game.selectedGameCode + if (code === null || !MAPLE_GUARD_GAME_CODES.has(code)) return + + const skipPlayWnd = parseBool(configStore.get('skipPlayWnd'), true) + const autoKillPatcher = parseBool(configStore.get('autoKillPatcher'), true) + if (!skipPlayWnd && !autoKillPatcher) return + + let remaining = MAPLE_GUARD_TICKS + const tick = (): void => { + if (skipPlayWnd) { + void safeInvoke(commands.closeMaplePlayWindow()) + } + if (autoKillPatcher) { + void safeInvoke(commands.checkAndKillMaplePatcher(gamePath)) + } + remaining -= 1 + if (remaining <= 0) window.clearInterval(timer) + } + + const timer = window.setInterval(tick, MAPLE_GUARD_INTERVAL_MS) + tick() + } + async function runGame(accountId = '', password = ''): Promise { /* * P12.4 followup-A D6 — when invoked from the LoginPage @@ -352,6 +385,7 @@ export function useGameLauncher(): UseGameLauncherReturn { const mode = resolveStartMode() await wrapCommand(commands.launchGame(gamePath, mode, ini.exe, accountId, password)) + runMapleLaunchGuards(gamePath) } return { runGame } diff --git a/src/types/bindings.ts b/src/types/bindings.ts index d41b7b0..c2ec353 100644 --- a/src/types/bindings.ts +++ b/src/types/bindings.ts @@ -1781,6 +1781,39 @@ async killGameProcesses(pids: number[]) : Promise else return { status: "error", error: e as any }; } }, +/** + * Close MapleStory's Nexon launcher Play dialog if it is currently + * visible. + * + * Mirrors one tick of WPF `checkPlayPage_Tick`: find + * `StartUpDlgClass` / `MapleStory` and post `WM_CLOSE`. The + * frontend drives this command on a short interval when the + * MapleStory-only `skipPlayWnd` preference is enabled. + */ +async closeMaplePlayWindow() : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("close_maple_play_window") }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +/** + * Best-effort terminate MapleStory's `Patcher.exe` from the same + * directory as `game_path`. + * + * Mirrors one tick of WPF `checkPatcher_Tick`'s kill branch. The + * frontend drives this command on a short interval when the + * MapleStory-only `autoKillPatcher` preference is enabled. + */ +async checkAndKillMaplePatcher(gamePath: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("check_and_kill_maple_patcher", { gamePath }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, /** * Type the account name + OTP into the MapleStory launcher's * login dialog and press Enter, replicating the tail of diff --git a/tests/unit/composables/useGameLauncher.spec.ts b/tests/unit/composables/useGameLauncher.spec.ts index 01c9b56..d848ad6 100644 --- a/tests/unit/composables/useGameLauncher.spec.ts +++ b/tests/unit/composables/useGameLauncher.spec.ts @@ -75,6 +75,8 @@ vi.mock('../../../src/types/bindings', () => ({ detectGamePath: vi.fn(), listGameProcesses: vi.fn(), killGameProcesses: vi.fn(), + closeMaplePlayWindow: vi.fn(), + checkAndKillMaplePatcher: vi.fn(), openUrl: vi.fn(), launchGame: vi.fn(), getAllConfig: vi.fn(), @@ -91,6 +93,8 @@ import { createAppI18n } from '../../../src/i18n' const mockDetectGamePath = vi.mocked(commands.detectGamePath) const mockListGameProcesses = vi.mocked(commands.listGameProcesses) const mockKillGameProcesses = vi.mocked(commands.killGameProcesses) +const mockCloseMaplePlayWindow = vi.mocked(commands.closeMaplePlayWindow) +const mockCheckAndKillMaplePatcher = vi.mocked(commands.checkAndKillMaplePatcher) const mockOpenUrl = vi.mocked(commands.openUrl) const mockLaunchGame = vi.mocked(commands.launchGame) @@ -165,18 +169,25 @@ function seedLiveSelection(game: ReturnType): void { describe('useGameLauncher', () => { beforeEach(() => { + vi.useFakeTimers() setActivePinia(createPinia()) mockDetectGamePath.mockReset() mockListGameProcesses.mockReset() mockKillGameProcesses.mockReset() + mockCloseMaplePlayWindow.mockReset() + mockCheckAndKillMaplePatcher.mockReset() mockOpenUrl.mockReset() mockLaunchGame.mockReset() + mockCloseMaplePlayWindow.mockReturnValue(ok(false)) + mockCheckAndKillMaplePatcher.mockReturnValue(ok([])) elMessageWarning.mockReset() elMessageInfo.mockReset() elMessageBoxConfirm.mockReset() }) afterEach(() => { + vi.clearAllTimers() + vi.useRealTimers() vi.clearAllMocks() }) @@ -192,6 +203,8 @@ describe('useGameLauncher', () => { await flushPromises() expect(mockLaunchGame).toHaveBeenCalledWith(FAKE_PATH, 'Auto', FAKE_INI.exe, '', '') + expect(mockCloseMaplePlayWindow).toHaveBeenCalledTimes(1) + expect(mockCheckAndKillMaplePatcher).toHaveBeenCalledWith(FAKE_PATH) }) it('no live selection + no persisted snapshot → GameSelected warning, no launch', async () => { @@ -349,6 +362,40 @@ describe('useGameLauncher', () => { expect(mockLaunchGame).toHaveBeenCalledWith(FAKE_PATH, 'Auto', FAKE_INI.exe, 'alice', 'hunter2') }) + it('Maple launch guards follow skipPlayWnd and autoKillPatcher preferences', async () => { + const { runGame, game, config } = mountHarness() + seedLiveSelection(game) + config.entries['skipPlayWnd'] = 'false' + config.entries['autoKillPatcher'] = 'true' + + mockDetectGamePath.mockReturnValueOnce(ok(FAKE_PATH)) + mockListGameProcesses.mockReturnValueOnce(ok([])) + mockLaunchGame.mockReturnValueOnce(ok(null)) + + await runGame() + await flushPromises() + + expect(mockCloseMaplePlayWindow).not.toHaveBeenCalled() + expect(mockCheckAndKillMaplePatcher).toHaveBeenCalledWith(FAKE_PATH) + }) + + it('non-Maple launches do not start Play/Patcher guards', async () => { + const { runGame, game } = mountHarness() + seedLiveSelection(game) + game.selectedGameCode = '999999_T0' + game.ini = { '999999_T0': { ...FAKE_INI, win_class_name: 'OtherClass' } } + + mockDetectGamePath.mockReturnValueOnce(ok(FAKE_PATH)) + mockListGameProcesses.mockReturnValueOnce(ok([])) + mockLaunchGame.mockReturnValueOnce(ok(null)) + + await runGame() + await flushPromises() + + expect(mockCloseMaplePlayWindow).not.toHaveBeenCalled() + expect(mockCheckAndKillMaplePatcher).not.toHaveBeenCalled() + }) + it('corrupt persisted snapshot → restoreLastSelected returns false → GameSelected toast', async () => { const { runGame, config } = mountHarness() config.entries['loginGame'] = FAKE_GAME_CODE diff --git a/tests/unit/pages/AccountList.spec.ts b/tests/unit/pages/AccountList.spec.ts index 7b719a9..c3899d4 100644 --- a/tests/unit/pages/AccountList.spec.ts +++ b/tests/unit/pages/AccountList.spec.ts @@ -288,6 +288,8 @@ vi.mock('../../../src/types/bindings', () => ({ detectGamePath: vi.fn(), listGameProcesses: vi.fn(), killGameProcesses: vi.fn(), + closeMaplePlayWindow: vi.fn(), + checkAndKillMaplePatcher: vi.fn(), launchGame: vi.fn(), openUrl: vi.fn(), /* @@ -808,6 +810,8 @@ describe('AccountList page', () => { vi.mocked(commands.detectGamePath).mockReturnValue(ok(null)) vi.mocked(commands.listGameProcesses).mockReturnValue(ok([])) vi.mocked(commands.killGameProcesses).mockReturnValue(ok([])) + vi.mocked(commands.closeMaplePlayWindow).mockReturnValue(ok(false)) + vi.mocked(commands.checkAndKillMaplePatcher).mockReturnValue(ok([])) vi.mocked(commands.launchGame).mockReturnValue(ok(null)) vi.mocked(commands.openUrl).mockReturnValue(ok(null)) }) @@ -2462,6 +2466,41 @@ describe('AccountList page', () => { ) }) + it('D8f: traditional login keeps login_action_type=1 on direct empty-credential launch', async () => { + /* + * WPF direct branch has two arms: + * + * (tradLogin && login_action_type == 1) || login_action_type == 0 + * + * The previous case pins the `login_action_type=0` arm; this + * one pins the traditional-login MapleStory arm so it cannot + * accidentally be routed through the OTP+command-line branch. + */ + vi.mocked(commands.getAccounts).mockReturnValueOnce(ok(POPULATED_LIST)) + vi.mocked(commands.detectGamePath).mockReturnValueOnce(ok('C:\\Beanfun\\MapleStory.exe')) + + const ctx = buildHarness() + useAuthStore().session = FAKE_SESSION + seedActiveGame(MAPLESTORY_TW, { ...MAPLESTORY_TW_INI, login_action_type: '1' }) + useConfigStore().entries['tradLogin'] = 'true' + useAccountStore().selectedSid = 'sid-1' + + const wrapper = await ctx.mountIt() + await flushPromises() + + await wrapper.get('[data-test="account-list-start"]').trigger('click') + await flushPromises() + + expect(commands.getOtp).not.toHaveBeenCalled() + expect(commands.launchGame).toHaveBeenCalledWith( + 'C:\\Beanfun\\MapleStory.exe', + 'Auto', + MAPLESTORY_TW_INI.exe, + '', + '', + ) + }) + it('D8f: Start Game OTP+launch chain (login_action_type=1, tradLogin=false) → getOtp → runGame(account, otp)', async () => { /* * Mirrors WPF `MainWindow.xaml.cs::getOtpWorker_RunWorkerCompleted`