Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 88 additions & 1 deletion src-tauri/src/commands/launcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)
}

Expand Down Expand Up @@ -873,6 +873,46 @@ pub async fn kill_game_processes(pids: Vec<u32>) -> Result<Vec<u32>, 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<bool, CommandError> {
#[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<Vec<u32>, 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`
Expand Down Expand Up @@ -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<bool, CommandError> {
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<Vec<u32>, 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)
// =====================================================================
Expand Down
3 changes: 3 additions & 0 deletions src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,9 @@ pub fn build_specta_builder<R: tauri::Runtime>() -> Builder<R> {
// 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)
Expand Down
34 changes: 34 additions & 0 deletions src/composables/useGameLauncher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,15 @@ export interface UseGameLauncherReturn {
runGame: (accountId?: string, password?: string) => Promise<void>
}

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()
Expand Down Expand Up @@ -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<void> {
/*
* P12.4 followup-A D6 — when invoked from the LoginPage
Expand Down Expand Up @@ -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 }
Expand Down
33 changes: 33 additions & 0 deletions src/types/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1781,6 +1781,39 @@ async killGameProcesses(pids: number[]) : Promise<Result<number[], CommandError>
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<Result<boolean, CommandError>> {
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<Result<number[], CommandError>> {
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
Expand Down
47 changes: 47 additions & 0 deletions tests/unit/composables/useGameLauncher.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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)

Expand Down Expand Up @@ -165,18 +169,25 @@ function seedLiveSelection(game: ReturnType<typeof useGameStore>): 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()
})

Expand All @@ -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 () => {
Expand Down Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions tests/unit/pages/AccountList.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
/*
Expand Down Expand Up @@ -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))
})
Expand Down Expand Up @@ -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`
Expand Down
Loading