diff --git a/README.md b/README.md index 550953b6..0c53a3cf 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ HomeworkHelper는 Windows 호스트 앱과 macOS 메뉴바 원격 클라이언 ## 현재 지원 범위 -- **Windows host app**: PyQt 기반 메인 GUI, 프로세스/웹 바로가기 관리, 세션 기록, 알림, 사이드바, 스크린샷/OBS 보조 기능. +- **Windows host app**: PySide6 기반 메인 GUI, 프로세스/웹 바로가기 관리, 세션 기록, 알림, 사이드바, 스크린샷/OBS 보조 기능. - **Remote Agent**: 호스트 앱의 FastAPI 서버를 통해 상태 조회, 프로세스 실행/종료, 대시보드 요약, pairing/token 기반 보호 endpoint를 제공합니다. - **macOS remote client**: 메뉴바 popover 중심의 네이티브 Swift 클라이언트입니다. pairing, host 상태 확인, Moonlight 실행 상태 반영, 원격 quick action을 담당합니다. - **Dashboard frontend**: `src/api/dashboard/frontend`의 Vite/React 앱을 빌드 시 `build/dashboard-static`으로 생성해 PyInstaller 패키지에 포함합니다. @@ -22,6 +22,9 @@ pip install -r requirements.txt python homework_helper.pyw ``` +기본 화면은 modernized Widgets이며 독립 Qt Quick 후보 실행 및 검증 방법은 +[`docs/development/windows-gui-modernization.md`](docs/development/windows-gui-modernization.md)에 정리되어 있습니다. + 서버만 확인할 때는 GUI 단일 인스턴스 경로를 우회합니다. ```bash diff --git a/build.py b/build.py index 892510f7..9ab053cf 100644 --- a/build.py +++ b/build.py @@ -20,6 +20,7 @@ import platform import argparse import hashlib +import importlib.metadata from pathlib import Path from datetime import datetime, timedelta try: @@ -65,6 +66,16 @@ VERSION_BUMP_CHOICES = ("none", "build", "patch", "minor", "major") DEFAULT_VERSION_BUMP = "build" VERSION_PATTERN = re.compile(r'v(\d+)\.(\d+)\.(\d+)_b(\d+)_g([0-9a-fA-F]+|unknown)(?:_dirty)?') +WINDOWS_PYTHON_VERSION = (3, 14) +WINDOWS_REQUIRED_DISTRIBUTIONS = ( + "PySide6", + "PyInstaller", + "pywin32", + "winshell", + "pycaw", + "Windows-Toasts", + "winrt-Windows.Gaming.Input", +) # 코드 서명 CERT_DIR = PROJECT_ROOT / "certs" @@ -80,6 +91,122 @@ class BuildConfigError(RuntimeError): """Raised when local build configuration is missing or invalid.""" +def configure_console_output(stdout=None, stderr=None) -> None: + """Replace glyphs unsupported by the active Windows console encoding.""" + streams = ( + sys.stdout if stdout is None else stdout, + sys.stderr if stderr is None else stderr, + ) + for stream in streams: + reconfigure = getattr(stream, "reconfigure", None) + if reconfigure is None: + continue + try: + reconfigure(errors="replace") + except (OSError, ValueError): + pass + + +def bootstrap_windows_build_runtime( + argv: list[str], + *, + system_name: str | None = None, + project_root: Path = PROJECT_ROOT, + python_executable: str | None = None, + python_version: tuple[int, int] | None = None, + runner=subprocess.run, + launcher_finder=shutil.which, +) -> int | None: + """Update the repository .venv and delegate without shell activation.""" + if (system_name or platform.system()) != "Windows": + return None + + current_python = Path(python_executable or sys.executable) + current_version = python_version or tuple(sys.version_info[:2]) + managed_python = project_root / ".venv" / "Scripts" / "python.exe" + + if not managed_python.exists(): + if current_version == WINDOWS_PYTHON_VERSION: + launcher = [str(current_python)] + else: + py_launcher = launcher_finder("py") + if not py_launcher: + raise BuildConfigError( + "Python 3.14가 필요합니다. Python Launcher를 포함해 Python 3.14를 " + "설치한 뒤 build.py를 다시 실행하세요." + ) + launcher = [str(py_launcher), "-3.14"] + print(f"[준비] Windows 프로젝트 가상환경 생성: {managed_python.parent.parent}") + created = runner([*launcher, "-m", "venv", str(managed_python.parent.parent)], cwd=project_root) + if created.returncode != 0 or not managed_python.exists(): + raise BuildConfigError("Windows 프로젝트 Python 3.14 가상환경 생성에 실패했습니다.") + + if current_python.resolve() != managed_python.resolve(): + delegated = runner( + [str(managed_python), str(project_root / "build.py"), *argv], + cwd=project_root, + ) + return int(delegated.returncode) + + print("[준비] requirements.txt 의존성 설치 및 업데이트") + installed = runner( + [ + str(managed_python), + "-m", + "pip", + "install", + "--disable-pip-version-check", + "--upgrade", + "-r", + str(project_root / "requirements.txt"), + ], + cwd=project_root, + ) + if installed.returncode != 0: + raise BuildConfigError("Windows 빌드 의존성 설치에 실패했습니다.") + return None + + +def installed_distribution_versions(names: tuple[str, ...]) -> dict[str, str]: + versions = {} + for name in names: + try: + versions[name] = importlib.metadata.version(name) + except importlib.metadata.PackageNotFoundError: + versions[name] = "missing" + return versions + + +def validate_windows_build_runtime( + *, + python_version: tuple[int, int] | None = None, + distribution_versions=None, +) -> dict[str, str]: + """Require Python 3.14, build dependencies, and one Qt binding.""" + current_version = python_version or tuple(sys.version_info[:2]) + if current_version != WINDOWS_PYTHON_VERSION: + raise BuildConfigError( + "Windows 빌드는 Python 3.14가 필요합니다. " + f"현재 버전: {current_version[0]}.{current_version[1]}" + ) + + version_reader = distribution_versions or installed_distribution_versions + versions = version_reader(WINDOWS_REQUIRED_DISTRIBUTIONS) + missing = [name for name, version in versions.items() if version == "missing"] + if missing: + raise BuildConfigError("Windows 빌드 필수 패키지가 없습니다: " + ", ".join(missing)) + + bindings = version_reader(("PySide6", "PyQt6")) + installed_bindings = [name for name, version in bindings.items() if version != "missing"] + if installed_bindings != ["PySide6"]: + found = ", ".join(installed_bindings) if installed_bindings else "없음" + raise BuildConfigError( + "Windows 빌드 환경에는 PySide6만 설치되어야 합니다. " + f"현재 감지된 Qt 바인딩: {found}" + ) + return versions + + def select_build_target(system_name: str | None = None) -> str: """Return the release target for the current OS.""" system_name = system_name or platform.system() @@ -1198,6 +1325,8 @@ def build_with_pyinstaller(gui): gui.log(f"빌드 명령: {' '.join(cmd)}\n") try: + pyinstaller_env = dict(os.environ) + pyinstaller_env["QT_API"] = "PySide6" process = subprocess.Popen( cmd, stdout=subprocess.PIPE, @@ -1206,6 +1335,7 @@ def build_with_pyinstaller(gui): encoding='utf-8', errors='replace', cwd=PROJECT_ROOT, + env=pyinstaller_env, bufsize=1 ) @@ -2090,11 +2220,22 @@ def create_candidate_version_config( def main(argv: list[str] | None = None): """메인 함수""" - args = parse_args(argv) + configure_console_output() + effective_argv = list(sys.argv[1:] if argv is None else argv) + args = parse_args(effective_argv) + try: + delegated_exit = bootstrap_windows_build_runtime(effective_argv) + except BuildConfigError as exc: + print(f"[오류] {exc}") + return 1 + if delegated_exit is not None: + return delegated_exit # 커스텀 폰트 로딩 try: target = args.target or select_build_target() validate_target_inputs(target) + if target == "windows-host": + validate_windows_build_runtime() version_config = load_version_config(args.version_file) candidate_config = create_candidate_version_config( target, diff --git a/clients/macos/Sources/HomeworkHelperRemoteApp.swift b/clients/macos/Sources/HomeworkHelperRemoteApp.swift index 6b993a07..ccc55282 100644 --- a/clients/macos/Sources/HomeworkHelperRemoteApp.swift +++ b/clients/macos/Sources/HomeworkHelperRemoteApp.swift @@ -5,7 +5,6 @@ extension Notification.Name { static let homeworkHelperRemoteMainWindowWillShow = Notification.Name("HomeworkHelperRemoteMainWindowWillShow") static let homeworkHelperRemoteToggleSidebar = Notification.Name("HomeworkHelperRemoteToggleSidebar") static let homeworkHelperRemoteRefreshRequested = Notification.Name("HomeworkHelperRemoteRefreshRequested") - static let homeworkHelperRemoteOpenSettings = Notification.Name("HomeworkHelperRemoteOpenSettings") static let homeworkHelperRemoteMenuBarIconDidChange = Notification.Name("HomeworkHelperRemoteMenuBarIconDidChange") static let homeworkHelperRemoteMenuBarStatusDidChange = Notification.Name("HomeworkHelperRemoteMenuBarStatusDidChange") static let homeworkHelperRemoteGlobalShortcutPressed = Notification.Name("HomeworkHelperRemoteGlobalShortcutPressed") @@ -27,31 +26,25 @@ enum RemoteSharedModel { @MainActor final class RemoteAppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { - enum SettingsOpenSource { - case popoverButton - case popoverShortcut - case uiTest - } - static let mainWindowIdentifier = "HomeworkHelperRemoteMainWindow" static let mainWindowTitle = "HomeworkHelper Remote" static let placeholderWindowIdentifier = "HomeworkHelperRemotePlaceholderWindow" static let placeholderWindowTitle = "HomeworkHelper Remote Hidden" static let settingsWindowIdentifier = "HomeworkHelperRemoteSettingsWindow" - static let settingsWindowTitle = "HomeworkHelper Remote 설정" private static weak var shared: RemoteAppDelegate? private static let moonlightBundleIdentifier = "com.moonlight-stream.Moonlight" private static var isOpeningMainWindow = false private static var uiTestMainWindow: NSWindow? private static var uiTestPopoverWindow: NSWindow? - private static var explicitSettingsOpenExpiresAt: Date? - private static let explicitSettingsOpenWindow: TimeInterval = 1.0 private var statusItem: NSStatusItem? private var statusItemClickMonitor: Any? private var popoverOutsideClickMonitor: Any? private var popoverKeyDownMonitor: Any? + private var settingsWindow: NSWindow? + private static var settingsOpener: (@MainActor () -> Void)? + private static var pendingSettingsOpen = false private let popover = NSPopover() func applicationDidFinishLaunching(_ notification: Notification) { @@ -85,7 +78,7 @@ final class RemoteAppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegat } if RemoteUITestFlags.openSettings { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - Self.openSettingsWindow(source: .uiTest) + Self.showSettingsWindow() } } else if RemoteUITestFlags.clickStatusItem { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { @@ -214,7 +207,7 @@ final class RemoteAppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegat return event } guard self.isCommandComma(event) else { return event } - Self.openSettingsWindow(source: .popoverShortcut) + Self.showSettingsWindow() return nil } } @@ -465,110 +458,49 @@ final class RemoteAppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegat } } - static func openSettingsWindow(source: SettingsOpenSource) { - guard source == .uiTest || shared?.popover.isShown == true else { return } - beginExplicitSettingsOpen() - shared?.closePopoverForFocusLoss() - NSApp.setActivationPolicy(.accessory) - NSApp.activate(ignoringOtherApps: true) - if focusExistingSettingsWindow() { - clearExplicitSettingsOpen() + static func showSettingsWindow() { + guard let shared else { + pendingSettingsOpen = true return } - NotificationCenter.default.post(name: .homeworkHelperRemoteOpenSettings, object: nil) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - guard isExplicitSettingsOpenPending() else { return } - if focusExistingSettingsWindow() { - clearExplicitSettingsOpen() - return - } - if NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) { - return - } - NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil) - } + shared.presentSettingsWindow() } - static func prepareSettingsWindow(_ window: NSWindow) { - window.identifier = NSUserInterfaceItemIdentifier(settingsWindowIdentifier) - window.title = settingsWindowTitle - window.isReleasedWhenClosed = false - NSApp.setActivationPolicy(.accessory) - let prepared = deduplicateSettingsWindows(preferred: window) ?? window - guard isExplicitSettingsOpenPending() else { return } - NSApp.activate(ignoringOtherApps: true) - focusSettingsWindow(prepared) - clearExplicitSettingsOpen() + static func installSettingsOpener(_ opener: @escaping @MainActor () -> Void) { + settingsOpener = opener + guard pendingSettingsOpen else { return } + pendingSettingsOpen = false + shared?.presentSettingsWindow() } - static func hideSettingsWindow(_ window: NSWindow?) { - if let window { - window.orderOut(nil) - } else { - settingsWindows().forEach { $0.orderOut(nil) } - } - restoreAccessoryIfNoVisibleUserWindows() + static func registerSettingsWindow(_ window: NSWindow) { + shared?.settingsWindow = window } - @discardableResult - private static func focusExistingSettingsWindow() -> Bool { - guard isExplicitSettingsOpenPending() else { return false } - guard let window = deduplicateSettingsWindows() else { return false } + private func presentSettingsWindow() { + closePopoverForFocusLoss() NSApp.setActivationPolicy(.accessory) - NSApp.activate(ignoringOtherApps: true) - focusSettingsWindow(window) - return true - } - - private static func mainWindows() -> [NSWindow] { - NSApp.windows.filter(isMainWindowCandidate) - } - - private static func settingsWindows() -> [NSWindow] { - NSApp.windows.filter(isSettingsWindowCandidate) - } - - private static func deduplicateSettingsWindows(preferred preferredWindow: NSWindow? = nil) -> NSWindow? { - let candidates = settingsWindows() - guard !candidates.isEmpty else { return nil } - let keeper = preferredWindow.flatMap { preferred in - candidates.first { $0 === preferred } - } ?? candidates.first(where: { $0.isKeyWindow }) - ?? candidates.first(where: { $0.isVisible }) - ?? candidates[0] - for window in candidates where window !== keeper { - window.orderOut(nil) + if let settingsWindow { + NSApp.activate(ignoringOtherApps: true) + settingsWindow.makeKeyAndOrderFront(nil) + settingsWindow.orderFrontRegardless() + return } - return keeper - } - - private static func focusSettingsWindow(_ window: NSWindow) { - window.makeKeyAndOrderFront(nil) - window.orderFrontRegardless() - } - - private static func restoreAccessoryIfNoVisibleUserWindows() { - DispatchQueue.main.async { - guard NSApp.windows.contains(where: isVisibleUserWindow) == false else { return } - NSApp.setActivationPolicy(.accessory) + NSApp.activate(ignoringOtherApps: true) + guard let settingsOpener = Self.settingsOpener else { + Self.pendingSettingsOpen = true + return } + settingsOpener() } - private static func beginExplicitSettingsOpen() { - explicitSettingsOpenExpiresAt = Date().addingTimeInterval(explicitSettingsOpenWindow) - } - - private static func isExplicitSettingsOpenPending() -> Bool { - guard let expiresAt = explicitSettingsOpenExpiresAt else { return false } - if Date() <= expiresAt { - return true - } - explicitSettingsOpenExpiresAt = nil - return false + static func hideSettingsWindow(_ window: NSWindow?) { + (window ?? shared?.settingsWindow)?.orderOut(nil) + NSApp.setActivationPolicy(.accessory) } - private static func clearExplicitSettingsOpen() { - explicitSettingsOpenExpiresAt = nil + private static func mainWindows() -> [NSWindow] { + NSApp.windows.filter(isMainWindowCandidate) } private static func isMainWindowCandidate(_ window: NSWindow) -> Bool { @@ -581,19 +513,6 @@ final class RemoteAppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegat || window.title == mainWindowTitle } - private static func isSettingsWindowCandidate(_ window: NSWindow) -> Bool { - window.identifier?.rawValue == settingsWindowIdentifier - || window.title == settingsWindowTitle - } - - private static func isVisibleUserWindow(_ window: NSWindow) -> Bool { - guard window.isVisible else { return false } - if window.identifier?.rawValue == placeholderWindowIdentifier || window.title == placeholderWindowTitle { - return false - } - let typeName = String(describing: type(of: window)) - return typeName.contains("Popover") == false - } } @main @@ -616,6 +535,10 @@ struct HomeworkHelperRemoteApp: App { .windowResizability(.contentSize) .commands { CommandGroup(replacing: .appSettings) { + Button("설정…") { + RemoteAppDelegate.showSettingsWindow() + } + .keyboardShortcut(",", modifiers: .command) } CommandMenu("원격") { Button("새로고침") { @@ -636,8 +559,10 @@ struct RemoteSettingsOpenBridge: View { var body: some View { Color.clear - .onReceive(NotificationCenter.default.publisher(for: .homeworkHelperRemoteOpenSettings)) { _ in - openSettings() + .onAppear { + RemoteAppDelegate.installSettingsOpener { + openSettings() + } } } } @@ -1095,7 +1020,7 @@ struct MenuBarPopoverView: View { } if !viewModel.isPaired { Button { - RemoteAppDelegate.openSettingsWindow(source: .popoverButton) + RemoteAppDelegate.showSettingsWindow() } label: { Label("페어링 필요 · 설정 열기", systemImage: "link.badge.plus") .frame(maxWidth: .infinity) @@ -1121,7 +1046,7 @@ struct MenuBarPopoverView: View { Divider() HStack(spacing: 8) { MenuBarFooterButton(title: "설정", systemImage: "gearshape") { - RemoteAppDelegate.openSettingsWindow(source: .popoverButton) + RemoteAppDelegate.showSettingsWindow() } MenuBarMoonlightButton(viewModel: viewModel) MenuBarFooterButton(title: "앱 종료", systemImage: "power", tone: .destructive) { @@ -1381,7 +1306,7 @@ enum RemoteSettingsLayout { static let tabPadding: CGFloat = 12 static let sectionSpacing: CGFloat = 12 static let windowHorizontalInset: CGFloat = 34 - static let windowVerticalInset: CGFloat = 72 + static let windowVerticalInset: CGFloat = 24 static let minWindowWidth: CGFloat = 430 static let maxWindowWidth: CGFloat = 480 static let minWindowHeight: CGFloat = 180 @@ -1403,7 +1328,7 @@ struct RemoteSettingsView: View { private var targetSize: CGSize { let measured = measuredSizes[selectedTab] ?? CGSize(width: RemoteSettingsLayout.contentWidth, height: 260) let paddedWidth = measured.width * 1.06 + RemoteSettingsLayout.windowHorizontalInset - let paddedHeight = measured.height * 1.10 + RemoteSettingsLayout.windowVerticalInset + let paddedHeight = measured.height + RemoteSettingsLayout.windowVerticalInset let visible = NSScreen.main?.visibleFrame.size ?? CGSize(width: 1180, height: 800) return CGSize( width: min(RemoteSettingsLayout.maxWindowWidth, min(max(RemoteSettingsLayout.minWindowWidth, paddedWidth), max(RemoteSettingsLayout.minWindowWidth, visible.width - 80))), diff --git a/clients/macos/Sources/RemoteDashboardViewModel.swift b/clients/macos/Sources/RemoteDashboardViewModel.swift index 97b4f064..00245ff9 100644 --- a/clients/macos/Sources/RemoteDashboardViewModel.swift +++ b/clients/macos/Sources/RemoteDashboardViewModel.swift @@ -2433,6 +2433,8 @@ final class RemoteDashboardViewModel: ObservableObject { monitoringPath: process.monitoringPath, launchPath: process.launchPath, preferredLaunchType: process.preferredLaunchType, + launchArgsEnabled: process.launchArgsEnabled, + launchArgs: process.launchArgs, lastPlayedTimestamp: process.lastPlayedTimestamp, userCycleHours: process.userCycleHours, staminaTrackingEnabled: process.staminaTrackingEnabled, diff --git a/clients/macos/Sources/RemoteModels.swift b/clients/macos/Sources/RemoteModels.swift index 1d60600f..01db3040 100644 --- a/clients/macos/Sources/RemoteModels.swift +++ b/clients/macos/Sources/RemoteModels.swift @@ -539,6 +539,8 @@ struct RemoteProcess: Codable, Identifiable { let monitoringPath: String? let launchPath: String? let preferredLaunchType: String? + let launchArgsEnabled: Bool + let launchArgs: String? let lastPlayedTimestamp: Double? let userCycleHours: Int? let staminaTrackingEnabled: Bool @@ -561,6 +563,8 @@ struct RemoteProcess: Codable, Identifiable { case monitoringPath = "monitoring_path" case launchPath = "launch_path" case preferredLaunchType = "preferred_launch_type" + case launchArgsEnabled = "launch_args_enabled" + case launchArgs = "launch_args" case lastPlayedTimestamp = "last_played_timestamp" case userCycleHours = "user_cycle_hours" case staminaTrackingEnabled = "stamina_tracking_enabled" @@ -583,6 +587,8 @@ struct RemoteProcess: Codable, Identifiable { monitoringPath = try container.decodeIfPresent(String.self, forKey: .monitoringPath) launchPath = try container.decodeIfPresent(String.self, forKey: .launchPath) preferredLaunchType = try container.decodeIfPresent(String.self, forKey: .preferredLaunchType) + launchArgsEnabled = try container.decodeIfPresent(Bool.self, forKey: .launchArgsEnabled) ?? false + launchArgs = try container.decodeIfPresent(String.self, forKey: .launchArgs) lastPlayedTimestamp = try container.decodeIfPresent(Double.self, forKey: .lastPlayedTimestamp) userCycleHours = try container.decodeIfPresent(Int.self, forKey: .userCycleHours) staminaTrackingEnabled = try container.decodeIfPresent(Bool.self, forKey: .staminaTrackingEnabled) ?? false @@ -604,6 +610,8 @@ struct RemoteProcess: Codable, Identifiable { monitoringPath: String?, launchPath: String?, preferredLaunchType: String?, + launchArgsEnabled: Bool = false, + launchArgs: String? = nil, lastPlayedTimestamp: Double?, userCycleHours: Int?, staminaTrackingEnabled: Bool, @@ -623,6 +631,8 @@ struct RemoteProcess: Codable, Identifiable { self.monitoringPath = monitoringPath self.launchPath = launchPath self.preferredLaunchType = preferredLaunchType + self.launchArgsEnabled = launchArgsEnabled + self.launchArgs = launchArgs self.lastPlayedTimestamp = lastPlayedTimestamp self.userCycleHours = userCycleHours self.staminaTrackingEnabled = staminaTrackingEnabled diff --git a/clients/macos/Sources/RemoteWindowAccessor.swift b/clients/macos/Sources/RemoteWindowAccessor.swift index cdf4c30a..3be7f16c 100644 --- a/clients/macos/Sources/RemoteWindowAccessor.swift +++ b/clients/macos/Sources/RemoteWindowAccessor.swift @@ -181,8 +181,10 @@ struct RemoteSettingsWindowAccessor: NSViewRepresentable { window.titlebarAppearsTransparent = true window.titleVisibility = .visible window.isMovableByWindowBackground = true + window.identifier = NSUserInterfaceItemIdentifier(RemoteAppDelegate.settingsWindowIdentifier) + window.isReleasedWhenClosed = false window.delegate = RemoteSettingsWindowDelegate.shared - RemoteAppDelegate.prepareSettingsWindow(window) + RemoteAppDelegate.registerSettingsWindow(window) } } diff --git a/current_plans.md b/current_plans.md index 377835bf..7095b1ab 100644 --- a/current_plans.md +++ b/current_plans.md @@ -33,8 +33,71 @@ - 약 1주 이상 실제 자동 출석체크 운용에서 문제 없이 동작해 v1 구현 성공 기준을 충족한 것으로 정리한다. - 해당 구현은 `main`에 병합되었으며 현재 릴리스 기준은 `v1.2.5` 태그로 관리한다. -2. host: 개별 게임별로 [프로세스 실행] 방식으로 실행 시, 함께 인자를 전달할 수 있는 opt-in 기능을 추가한다. (예: 젠레스존제로를 실행 시, 인자로서 --launcher-mode를 전달하도록 지원) - +2. host & remote: 개별 게임별 `프로세스 실행` 인자 전달 opt-in 기능. + + ### 목표 + + - 게임별 설정에서 직접 실행 파일을 실행할 때 추가 실행 인자를 함께 전달할 수 있게 한다. + - host GUI와 remote launch API가 같은 저장값과 같은 적용 규칙을 사용한다. + - 기본 동작은 기존과 동일하게 유지하고, 사용자가 명시적으로 켠 프로세스에만 인자를 적용한다. + - 1차 사용 사례는 젠레스 존 제로를 `프로세스 선호` 방식으로 실행할 때 `-use-d3d12`를 함께 전달하는 것이다. + + ### 현재 구조 확인 + + - 실행 버튼 흐름은 `src/gui/main_window.py`의 `handle_launch_button_in_row()`에서 `preferred_launch_type`에 따라 실행 대상을 고른 뒤 `Launcher.launch_process(...)`를 호출한다. + - 실제 실행은 `src/core/launcher.py`의 `Launcher.launch_process(launch_command: str, args: str | list[str] | None = None)`가 담당하며, 실행 대상과 추가 인자를 분리해 받는다. + - 프로세스 저장 모델은 `ManagedProcess`, `ProcessSchema`, `ProcessCreateSchema`, SQLAlchemy `Process` 모델에 새 실행 인자 필드를 추가한다. + - 프로세스 추가/편집 UI는 `src/gui/dialogs.py`의 `ProcessDialog`에서 실행 경로, 실행 방식, 직접 실행 인자 opt-in을 저장한다. + + ### v1 범위 + + - 인자는 resolved target이 실제 실행 파일/직접 실행 대상인 경우에만 적용한다. + - `바로가기(.lnk)`, `.url`, 런처 프로토콜, 프리셋 launcher 경로에는 v1에서 인자를 붙이지 않는다. + - 이유: 각 경로는 OS shell/런처가 해석하며 인자 전달 의미가 불안정하다. + - remote API도 direct target일 때만 저장된 인자를 적용하며, macOS client는 새 필드를 decode/cache만 한다. + - 저장 형식은 `launch_args_enabled: bool`과 `launch_args: str`의 두 필드로 둔다. + - opt-in이 꺼져 있으면 `launch_args` 값이 있어도 실행에 사용하지 않는다. + - 앞뒤 공백을 trim하고, 빈 문자열은 인자 없음으로 취급한다. + - 줄바꿈/CR/NUL 문자는 거부하고 최대 길이는 512자로 제한한다. + - 실행 시에는 경로와 인자를 문자열로 단순 결합하지 않고, Windows에서는 `ShellExecuteW`의 parameter 인자로 분리 전달하는 방향을 우선한다. + - 비-Windows fallback은 `[launch_path] + parsed_args` 형태의 `subprocess.Popen` 리스트 실행으로 정리한다. + + ### 구현 계획 고정본 + + 1. 저장 모델 확장 + - `ManagedProcess.__init__`, `from_dict`, `to_dict` 호환 경로에 `launch_args_enabled`, `launch_args`를 추가한다. + - `src/data/schemas.py`의 process schema와 `src/data/models.py`의 `managed_processes` 테이블 모델에 동일 필드를 추가한다. + - `src/data/beholder.py` 관리 필드명/검증에 실행 인자 필드를 추가하고, trim 후 빈 문자열은 무시하며 newline/CR/NUL 금지와 512자 제한을 적용한다. + + 2. 편집 UI 추가 + - `ProcessDialog`의 실행 방식 섹션 근처에 `직접 실행 인자 사용` checkbox와 인자 입력 field를 추가한다. + - tooltip에는 “프로세스 선호/직접 실행에만 적용, 바로가기/URL에는 적용 안 됨”을 명시한다. + - 프리셋 자동 적용 시에는 인자를 자동으로 켜지 않고, 저장된 프로세스 단위 설정만 반영한다. + + 3. 실행 대상 결정 로직 정리 + - `handle_launch_button_in_row()`에서 실제 target이 직접 실행 대상인지 판별한다. + - resolved target이 직접 실행 대상일 때만 `launch_args_enabled`와 `launch_args`를 읽어 `Launcher.launch_process(target, args=...)`로 전달한다. + - `_launch_with_specific_path(..., use_shortcut=False)` 우클릭 직접 실행도 동일한 인자 적용 규칙을 사용한다. + - shortcut/launcher fallback으로 실행되는 경우에는 인자를 전달하지 않는다. + + 4. remote 실행 경로 반영 + - `/remote/processes/{process_id}/launch`에서 resolved target이 직접 실행 대상이고 launcher mode가 아니면 저장된 인자를 전달한다. + - audit metadata에는 `launch_args_applied` 여부를 기록한다. + - remote 상태 revision fingerprint에 `launch_args_enabled`, `launch_args`를 포함해 client cache/refresh가 변경을 감지하게 한다. + - macOS client 모델은 `launch_args_enabled`, `launch_args`를 decode/cache하되 편집 UI는 v1 범위에서 제외한다. + + 5. `Launcher` 인터페이스 확장 + - `launch_process(launch_command: str, args: str | list[str] | None = None)` 형태로 확장한다. + - Windows `ShellExecuteW` 호출은 `file=launch_command`, `params=args_string_or_none`으로 분리한다. + - 비-Windows 실행은 `subprocess.Popen([launch_command, *parsed_args])`로 실행한다. + - `.lnk`, `.url`, protocol 처리 분기는 기존 동작을 유지하고 args가 들어와도 무시하거나 명시적으로 로그만 남긴다. + + 6. 테스트 및 검증 + - `ManagedProcess.from_dict`가 기존 데이터에 대해 기본값을 채우는지 테스트한다. + - schema/model/beholder가 새 필드를 허용하고 위험한 줄바꿈/과도한 길이를 거르는지 테스트한다. + - `Launcher.launch_process`가 Windows ShellExecute parameter 분리와 비-Windows list 실행을 사용하는지 mock 기반으로 검증한다. + - `ProcessDialog.get_data()`가 checkbox/input 상태를 정확히 반환하는지 GUI test를 추가한다. + - `MainWindow`와 remote launch API 실행 흐름에서 direct 실행에는 인자가 전달되고 shortcut/url/launcher 실행에는 전달되지 않는지 단위 테스트를 추가한다. 3. host & client: openSSH 의존성을 덜기 위해, host에 sleep/shutdown/restart 제어 기능을 만들고, client가 http 요청을 통해 이것을 제어하도록 구성하여 원격 전원 관리 기능에서 openSSH 의존성을 제거. diff --git a/docs/development/windows-gui-modernization.md b/docs/development/windows-gui-modernization.md new file mode 100644 index 00000000..9c6c4b40 --- /dev/null +++ b/docs/development/windows-gui-modernization.md @@ -0,0 +1,63 @@ +# Windows GUI 현대화 검증 가이드 + +## 실행 모드 + +Windows 호스트의 기본 presentation은 modernized Qt Widgets다. 동일 backend와 +수명주기를 사용하는 독립 Qt Quick 후보는 다음 중 하나로 실행한다. + +```powershell +$env:HH_UI_RENDERER = 'qml' +python homework_helper.pyw +``` + +```powershell +python homework_helper.pyw --ui-renderer=qml +``` + +지원 값은 `widgets`, `qml`뿐이다. QML 로드에 실패하면 안정성을 위해 Widgets로 +복귀하며 원인은 GUI 로그에 기록된다. GUI 로그에는 binding, renderer, variant +식별자가 포함된다. + +제품 기본 onedir는 Qt Quick 모듈을 제외해 Widgets 설치 크기를 QML 후보와 분리한다. +QML 후보 설치본은 별도 출력 디렉터리에서 다음과 같이 만든다. + +```powershell +$env:HH_INCLUDE_QML = '1' +$env:HH_UI_RENDERER = 'qml' +python -m PyInstaller homework_helper.spec --noconfirm +``` + +`HH_INCLUDE_QML=1` 없이 만든 기본 설치본에서 QML을 요청하면 Widgets로 복귀한다. +두 설치본의 크기와 실행 성능을 각각 기준선과 비교해야 한다. + +## PySide6 빌드 경계 + +- Python 3.14 및 `PySide6==6.11.1`을 사용하는 깨끗한 가상환경에서 빌드한다. +- 릴리스 사전검사는 PySide6가 없거나 PyQt6가 함께 설치된 환경을 차단한다. +- PyInstaller onedir와 Inno Setup 구조는 유지한다. +- `src`와 `homework_helper.pyw`에는 PyQt6, `pyqtSignal`, `pyqtSlot`, sip 의존성을 허용하지 않는다. +- `QAbstractNativeEventFilter`의 실제 `MSG` 변환은 Windows 11 실기기에서 확인한다. + +## 채택 게이트 + +PyQt6 기준 설치본과 각 후보를 같은 Windows 11 호스트에서 각각 10회 cold start, +5분 idle 및 2시간 soak로 측정한다. + +- private bytes/RSS 중앙값 증가는 `max(5%, 8 MiB)` 이내 +- idle CPU 증가는 `0.2%p` 이내 +- 시작 및 주요 상호작용 p95 증가는 10% 이내 +- 설치 크기 증가는 5% 이내 +- thread, handle, GDI 객체가 soak 동안 지속적으로 증가하지 않을 것 + +절전·최대절전 복귀, 다중 모니터/DPI, tray/IPC, provider timeout, 종료 중 late +signal, 녹화 및 원격 클라이언트를 함께 확인한다. QML은 이 게이트를 통과하고 +블라인드 시각 평가에서 Widgets보다 최소 두 범주 이상 우수할 때만 제품 기본값으로 +승격한다. + +## LGPL 배포 체크리스트 + +- 설치본에 Qt/PySide6 저작권 고지와 LGPL 전문을 포함한다. +- 사용한 Qt/PySide6 정확한 버전과 대응 소스 취득 경로를 제공한다. +- onedir의 동적 Qt 라이브러리를 사용자가 교체할 수 있는지 설치·업데이트 정책과 함께 확인한다. +- Qt 라이브러리 자체 수정 여부와 재링크·교체를 방해하는 서명 또는 설치 제한을 검토한다. +- 상용 배포 전 최종 의무 범위는 별도 법률 검토로 확정한다. diff --git a/homework_helper.pyw b/homework_helper.pyw index e327f594..7c588120 100644 --- a/homework_helper.pyw +++ b/homework_helper.pyw @@ -13,6 +13,8 @@ import tempfile import glob import shutil import threading +from contextlib import contextmanager +from dataclasses import dataclass from typing import List, Optional, Dict, Any from src.utils.app_paths import ( @@ -63,15 +65,42 @@ os.environ["QT_FONT_DPI"] = "96" print(f"[DPI] OS DPI 무시, 사용자 배율 적용: {_user_scale * 100:.0f}%") # ============================================================================= +API_GRACEFUL_SHUTDOWN_TIMEOUT_SECONDS = 5.0 +RUN_DUE_TOTAL_DEADLINE_SECONDS = 60.0 +SINGLE_DAILY_CHECKIN_TOTAL_DEADLINE_SECONDS = 30.0 +RUN_DUE_MIN_PERSISTENCE_RESERVE_SECONDS = 5.0 +RUN_DUE_MAX_PERSISTENCE_RESERVE_SECONDS = 10.0 +RUN_DUE_PERSISTENCE_RESERVE_PER_TARGET_SECONDS = 0.5 + +# Fault recovery is deliberately method-and-path exact. The packaged incident +# collector uses the ping/health probes below plus local filesystem metadata; +# no wildcard HTTP diagnostics or backup subpaths need database access. +_FAULTED_ROUTE_ALLOWLIST = frozenset({ + ("GET", "/api/gui/ping"), + ("GET", "/api/gui/health"), + ("GET", "/api/beholder/backups"), + ("POST", "/api/beholder/backups/restore-preview"), + ("POST", "/api/beholder/backups/restore"), +}) + + +def _faulted_route_allowed(method: str, path: str) -> bool: + return (str(method).upper(), str(path)) in _FAULTED_ROUTE_ALLOWLIST + api_server_process = None +api_server_shutdown_event = None _restart_in_progress = False # 권한 변경으로 인한 재시작 시 True로 설정 # 새로 분리된 모듈 imports from src.utils.admin import check_admin_requirement, is_admin from src.gui.main_window import MainWindow -from src.core.instance_manager import run_with_single_instance_check, SingleInstanceApplication -from PyQt6.QtWidgets import QApplication, QMessageBox -from PyQt6.QtGui import QFontDatabase, QFont +from src.gui.presentation import PresentationController, resolve_ui_renderer +from src.core.instance_manager import ( + SingleInstanceApplication, + run_with_single_instance_check, +) +from PySide6.QtWidgets import QApplication, QMessageBox +from PySide6.QtGui import QFontDatabase, QFont from src.utils.common import get_bundle_resource_path from src.api.client import ApiClient from src.api.runtime_config import gui_health_url, resolve_api_port, resolve_local_api_base_url @@ -287,7 +316,7 @@ def _is_existing_api_server_reusable() -> bool: if not _process_matches_create_time(parent_pid, metadata.get("parent_create_time")): print( f"기존 API 서버 parent_pid {parent_pid}가 사라졌거나 재사용된 PID입니다. " - "orphan 서버를 재사용하지 않고 재시작합니다." + "orphan 서버를 자동 종료·재시작하지 않습니다." ) return False return True @@ -410,14 +439,25 @@ def _terminate_process_id( return False -def _terminate_existing_api_server(timeout: float = 5.0) -> None: - """Stop stale API server processes before starting a fresh server.""" +def _terminate_existing_api_server(timeout: float = 5.0) -> bool: + """Stop only processes that can be identified as this app's API server.""" global api_server_process known_pids: set[int] = set() pid_file_pid = _read_server_pid_file() if pid_file_pid: known_pids.add(pid_file_pid) + metadata = _read_server_metadata_file() + if metadata: + try: + metadata_pid = int(metadata.get("pid")) + except (TypeError, ValueError): + metadata_pid = None + if metadata_pid and _process_matches_create_time( + metadata_pid, + metadata.get("process_create_time"), + ): + known_pids.add(metadata_pid) api_listener_pids = _find_api_listener_pids(resolve_api_port()) known_pids.update(api_listener_pids) @@ -432,19 +472,28 @@ def _terminate_existing_api_server(timeout: float = 5.0) -> None: if api_server_process.pid and not api_server_process.is_alive(): known_pids.discard(api_server_process.pid) + all_stopped = True for process_id in sorted(known_pids): - _terminate_process_id( + stopped = _terminate_process_id( process_id, timeout=timeout, api_listener_pids=api_listener_pids, ) + all_stopped = stopped and all_stopped - try: - os.remove(_server_pid_file_path()) - except FileNotFoundError: - pass - except OSError as exc: - print(f"stale PID 파일 삭제 실패: {exc}") + if _find_api_listener_pids(resolve_api_port()): + all_stopped = False + + if all_stopped: + for stale_path in (_server_pid_file_path(), _server_metadata_file_path()): + try: + os.remove(stale_path) + except FileNotFoundError: + pass + except OSError as exc: + print(f"API 서버 메타데이터 정리 실패: {exc}") + all_stopped = False + return all_stopped def _multiprocessing_parent_pid() -> int | None: @@ -472,6 +521,14 @@ def _is_loopback_api_host(host: str | None) -> bool: def _desired_child_api_bind_host() -> tuple[str | None, str]: """Return the bind host that the GUI parent should pass to the API child.""" + try: + from src.api.beholder_routes import database_coordinator + + if database_coordinator.snapshot().mode == "faulted": + print("DB fault 상태에서는 GUI 부모가 DB 설정을 읽지 않고 localhost 바인딩을 강제합니다.") + return "127.0.0.1", "database_faulted" + except Exception as exc: + print(f"DB fault 상태 확인 실패: {exc}") explicit_host = os.environ.get("HH_API_HOST") remote_server_mode_enabled = False try: @@ -500,23 +557,52 @@ def _desired_child_api_bind_host() -> tuple[str | None, str]: def start_api_server() -> bool: """FastAPI 서버를 독립 프로세스로 실행합니다 (multiprocessing.Process 방식).""" - global api_server_process + global api_server_process, api_server_shutdown_event + started_process_here = False try: # 이미 서버가 실행 중인지 확인 - if is_server_running(): + server_listening = is_server_running() + if server_listening: if _is_existing_server_healthy() and _is_existing_api_server_reusable(): print("기존 API 서버가 정상 응답 중입니다. 재사용합니다.") return True + _terminate_existing_api_server(timeout=API_GRACEFUL_SHUTDOWN_TIMEOUT_SECONDS) + if is_server_running(): + raise RuntimeError( + "API 포트를 점유한 프로세스를 HomeworkHelper API 서버로 확인할 수 없어 " + "자동 종료하지 않았습니다." + ) - print("기존 API 서버가 응답하지 않거나 현재 GUI에서 재사용할 수 없습니다. 종료 후 재시작합니다...") - _terminate_existing_api_server(timeout=5.0) + metadata = _read_server_metadata_file() + if metadata: + try: + existing_process_id = int(metadata.get("pid")) + except (TypeError, ValueError): + existing_process_id = None + if ( + existing_process_id + and existing_process_id != os.getpid() + and _process_matches_create_time( + existing_process_id, + metadata.get("process_create_time"), + ) + ): + _terminate_existing_api_server(timeout=API_GRACEFUL_SHUTDOWN_TIMEOUT_SECONDS) + if _process_matches_create_time( + existing_process_id, + metadata.get("process_create_time"), + ): + raise RuntimeError( + f"기존 HomeworkHelper API 프로세스 PID {existing_process_id}를 " + "종료하지 못했습니다." + ) print("API 서버를 독립 프로세스로 시작합니다...") - # multiprocessing.Process를 사용하여 서버 프로세스 생성 - # daemon=True: 부모 프로세스(GUI) 종료 시 서버도 자동 종료 - # SQLite WAL 모드가 DB 무결성 보장 + # 부모가 소유한 Event를 통해 uvicorn에 graceful shutdown을 요청한다. + # daemon 프로세스의 암묵적 강제 종료에 의존하지 않는다. import multiprocessing + api_server_shutdown_event = multiprocessing.Event() child_bind_host, child_bind_source = _desired_child_api_bind_host() env_had_api_host = "HH_API_HOST" in os.environ previous_api_host = os.environ.get("HH_API_HOST") @@ -526,9 +612,11 @@ def start_api_server() -> bool: try: api_server_process = multiprocessing.Process( target=run_server_main, - daemon=True + args=(api_server_shutdown_event,), + daemon=False, ) api_server_process.start() + started_process_here = True finally: if child_bind_host: if env_had_api_host: @@ -538,9 +626,15 @@ def start_api_server() -> bool: print(f"API 서버가 독립 프로세스 PID {api_server_process.pid}로 시작되었습니다.") # 서버가 준비될 때까지 대기 - return wait_for_server_ready() + if wait_for_server_ready(): + return True + print("API 서버 readiness 확인에 실패해 정상 종료를 요청합니다.") + stop_api_server() + return False except Exception as e: + if started_process_here: + stop_api_server() print(f"API 서버 시작 실패: {e}") message = f"API 서버 시작에 실패했습니다.\n\n{e}" try: @@ -556,7 +650,7 @@ def start_api_server() -> bool: print(message, file=sys.stderr) return False -def run_server_main(): +def run_server_main(shutdown_event=None): """uvicorn 서버만 실행하는 함수. GUI에서 multiprocessing으로 호출하거나, SSH testbench가 ``--testbench-server`` @@ -567,6 +661,10 @@ def run_server_main(): import logging from logging.handlers import RotatingFileHandler from sqlalchemy import text + from src.gui.runtime_logging import RedactingFormatter + + if shutdown_event is None: + shutdown_event = threading.Event() # multiprocessing 환경에서 stdout/stderr가 None일 수 있으므로 재설정 if sys.stdout is None: @@ -589,7 +687,7 @@ def run_server_main(): logger.setLevel(logging.INFO) # 로그 포맷 설정 - formatter = logging.Formatter( + formatter = RedactingFormatter( '%(asctime)s - %(name)s - %(levelname)s - %(message)s', datefmt='%Y-%m-%d %H:%M:%S' ) @@ -643,6 +741,7 @@ def run_server_main(): json.dump( { "pid": os.getpid(), + "process_create_time": _process_create_time(os.getpid()), "parent_pid": parent_process_id, "parent_create_time": _process_create_time(parent_process_id), "started_at": time.time(), @@ -665,48 +764,71 @@ def run_server_main(): logger.error(f"PID/메타데이터 파일 생성 실패: {e}") # --- main.py의 내용을 여기로 통합 --- - from fastapi import FastAPI, Depends, HTTPException, Header + from fastapi import FastAPI, Depends, HTTPException, Header, Request from fastapi.responses import JSONResponse from pydantic import BaseModel from sqlalchemy.orm import Session from src.data import crud, models, schemas, beholder from src.data.database import SessionLocal, engine, auto_migrate_database, backup_database + from src.data.database_coordination import ( + DatabaseAccessUnavailable, + database_access_exception_handler, + database_access_error_response, + ) + from src.api.beholder_routes import database_coordinator + from src.core.daily_checkin_singleflight import ( + DailyCheckInAlreadyInFlight, + bounded_provider_timeout_seconds, + daily_checkin_singleflight, + monotonic_deadline, + remaining_deadline_seconds, + ) - # DB 백업 (마이그레이션 전, 이전 세션의 최종 상태 보존) - backup_database() + database_faulted_at_startup = database_coordinator.snapshot().mode == "faulted" + if database_faulted_at_startup: + logger.error( + "DB fault sentinel이 유지되어 startup migration/checkpoint/probe를 건너뜁니다. " + "ping, health, backup restore 및 진단 경로만 사용하십시오." + ) + else: + # DB 백업 (마이그레이션 전, 이전 세션의 최종 상태 보존) + backup_database() - # 자동 마이그레이션 실행 (새 컬럼 추가) - auto_migrate_database() + # 자동 마이그레이션 실행 (새 컬럼 추가) + auto_migrate_database() - # 테이블 생성 (새 DB인 경우) - models.Base.metadata.create_all(bind=engine) + # 테이블 생성 (새 DB인 경우) + models.Base.metadata.create_all(bind=engine) - # 데이터베이스 무결성 확인 및 복구 - logger.info("데이터베이스 무결성 확인 중...") - try: - with engine.connect() as conn: - # WAL 복구 체크포인트 - conn.execute(text("PRAGMA wal_checkpoint(RECOVER)")) - conn.commit() + # 데이터베이스 무결성 확인 및 복구 + logger.info("데이터베이스 무결성 확인 중...") + try: + with engine.connect() as conn: + # WAL 복구 체크포인트 + conn.execute(text("PRAGMA wal_checkpoint(RECOVER)")) + conn.commit() - # 무결성 검사 - result = conn.execute(text("PRAGMA integrity_check")) - integrity_result = result.scalar() - if integrity_result != "ok": - logger.warning(f"데이터베이스 무결성 검사 실패: {integrity_result}") - else: - logger.info("데이터베이스 무결성 확인 완료.") - except Exception as e: - logger.error(f"데이터베이스 복구 중 오류: {e}", exc_info=True) + # 무결성 검사 + result = conn.execute(text("PRAGMA integrity_check")) + integrity_result = result.scalar() + if integrity_result != "ok": + logger.warning(f"데이터베이스 무결성 검사 실패: {integrity_result}") + else: + logger.info("데이터베이스 무결성 확인 완료.") + except Exception as e: + logger.error(f"데이터베이스 복구 중 오류: {e}", exc_info=True) - # 데이터베이스 테이블 생성 - # 기존 데이터 호환을 위해 필요한 컬럼이 없으면 추가 - try: - ensure_process_table_schema() - except Exception as e: - logger.error(f"테이블 스키마 보정 실패: {e}", exc_info=True) + # 데이터베이스 테이블 생성 + # 기존 데이터 호환을 위해 필요한 컬럼이 없으면 추가 + try: + ensure_process_table_schema() + except Exception as e: + logger.error(f"테이블 스키마 보정 실패: {e}", exc_info=True) def resolve_api_bind_host() -> str: + if database_coordinator.snapshot().mode == "faulted": + logger.warning("DB fault 상태에서는 명시적 HH_API_HOST를 무시하고 localhost로 제한합니다.") + return "127.0.0.1" explicit_host = os.environ.get("HH_API_HOST") if explicit_host: logger.info(f"API 바인딩 설정 확인: HH_API_HOST={explicit_host}") @@ -727,14 +849,17 @@ def run_server_main(): # 주기적 WAL checkpoint 백그라운드 스레드 def periodic_checkpoint(interval=60): """주기적으로 WAL checkpoint 수행""" - while True: + while not shutdown_event.wait(interval): try: - time.sleep(interval) - from src.api.beholder_routes import database_access_gate - with database_access_gate(): + lease = database_coordinator.try_acquire_request("periodic_wal_checkpoint") + if lease is None: + logger.info("DB maintenance 중이므로 WAL checkpoint를 다음 주기로 연기합니다.") + continue + with lease: with engine.connect() as conn: conn.execute(text("PRAGMA wal_checkpoint(PASSIVE)")) conn.commit() + database_coordinator.record_checkpoint() logger.info("WAL checkpoint 완료") except Exception as e: logger.error(f"Checkpoint 오류: {e}", exc_info=True) @@ -756,14 +881,15 @@ def run_server_main(): logger.info(f"서버 종료 절차 시작: {reason} (Signal: {signum})") - try: - logger.info("최종 WAL checkpoint 수행 중...") - with engine.connect() as conn: - conn.execute(text("PRAGMA wal_checkpoint(TRUNCATE)")) - conn.commit() - logger.info("WAL checkpoint 완료") - except Exception as e: - logger.error(f"최종 WAL checkpoint 실패: {e}", exc_info=True) + if database_coordinator.snapshot().mode != "faulted": + try: + logger.info("최종 WAL checkpoint 수행 중...") + with engine.connect() as conn: + conn.execute(text("PRAGMA wal_checkpoint(TRUNCATE)")) + conn.commit() + logger.info("WAL checkpoint 완료") + except Exception as e: + logger.error(f"최종 WAL checkpoint 실패: {e}", exc_info=True) try: engine.dispose() @@ -791,9 +917,9 @@ def run_server_main(): logger.info("=" * 60) def shutdown_handler(signum, frame): - """종료 신호 처리 - 안전하게 종료""" - shutdown_api_resources("signal", signum=signum) - sys.exit(0) + """종료 신호를 uvicorn의 graceful shutdown 요청으로 변환한다.""" + logger.info(f"서버 종료 신호 수신 (Signal: {signum})") + shutdown_event.set() def start_parent_watchdog(parent_pid: int | None) -> None: if not parent_pid: @@ -810,19 +936,18 @@ def run_server_main(): def watch_parent() -> None: try: import psutil - while True: - time.sleep(5) + while not shutdown_event.wait(5.0): try: parent = psutil.Process(parent_pid) if parent_create_time is not None and abs(parent.create_time() - parent_create_time) > 0.001: raise psutil.NoSuchProcess(parent_pid) except psutil.NoSuchProcess: logger.warning( - "부모 GUI PID %s가 사라졌습니다. stale API 서버 방지를 위해 종료합니다.", + "부모 GUI PID %s가 사라졌습니다. API 정상 종료를 요청합니다.", parent_pid, ) - shutdown_api_resources(f"parent_pid_{parent_pid}_gone") - os._exit(0) + shutdown_event.set() + return except Exception as e: logger.warning(f"부모 GUI PID {parent_pid} 확인 실패: {e}") except Exception as e: @@ -862,6 +987,7 @@ def run_server_main(): logger.warning(f"API 바인딩 메타데이터 갱신 실패: {e}") app = FastAPI() + app.add_exception_handler(DatabaseAccessUnavailable, database_access_exception_handler) loopback_hosts = {"127.0.0.1", "localhost", "::1", "testclient"} remote_exposed = api_host not in {"127.0.0.1", "localhost", "::1"} @@ -874,6 +1000,17 @@ def run_server_main(): or path.startswith("/api/dashboard/resource-icons/") ) + @app.middleware("http") + async def database_fault_allowlist_middleware(request, call_next): + """Keep a faulted database quarantined behind the recovery allowlist.""" + + if ( + database_coordinator.snapshot().mode == "faulted" + and not _faulted_route_allowed(request.method, request.url.path) + ): + return database_access_error_response(DatabaseAccessUnavailable("faulted")) + return await call_next(request) + @app.middleware("http") async def remote_exposure_boundary_middleware(request, call_next): """Expose only /remote/* to non-loopback peers when the API binds externally.""" @@ -988,6 +1125,75 @@ def run_server_main(): raise HTTPException(status_code=400, detail="프로세스와 출석 게임 ID가 일치하지 않습니다.") return process, descriptor + @dataclass(frozen=True) + class _DailyCheckInTargetSnapshot: + """Provider I/O에 ORM session을 동반하지 않는 immutable target.""" + + process_id: str + process_name: str + user_preset_id: str | None + descriptor: Any + + def _daily_checkin_target_snapshot( + db: Session, + process_id: str, + game_id: str | None = None, + ) -> _DailyCheckInTargetSnapshot: + process, descriptor = _get_daily_checkin_process_or_error(db, process_id, game_id) + return _DailyCheckInTargetSnapshot( + process_id=str(process.id), + process_name=str(process.name), + user_preset_id=getattr(process, "user_preset_id", None), + descriptor=descriptor, + ) + + def _configure_database_deadline( + db: Session, + deadline: float, + *, + reserve_seconds: float = 0.0, + phases_remaining: int = 1, + ) -> float: + """Bound SQLite lock waits to one share of the remaining request time.""" + + remaining = max( + remaining_deadline_seconds(deadline) - max(float(reserve_seconds), 0.0), + 0.0, + ) + phase_budget = remaining / max(int(phases_remaining), 1) + busy_timeout_ms = max(0, min(5_000, int(phase_budget * 1000.0))) + db.execute(text(f"PRAGMA busy_timeout={busy_timeout_ms}")) + return remaining + + @contextmanager + def _database_session( + route_name: str, + *, + deadline: float | None = None, + reserve_seconds: float = 0.0, + phases_remaining: int = 1, + ): + """Open one short DB phase under an ownerless request lease.""" + + lease = database_coordinator.acquire_request(route_name) + db = None + try: + db = SessionLocal() + if deadline is not None: + _configure_database_deadline( + db, + deadline, + reserve_seconds=reserve_seconds, + phases_remaining=phases_remaining, + ) + yield db + finally: + try: + if db is not None: + db.close() + finally: + lease.release() + def _daily_checkin_next_run_at(status: str, attempted_at: float, period_end: float) -> float: from src.core import daily_checkin @@ -1044,17 +1250,44 @@ def run_server_main(): logger.warning("provider health 기록 실패(%s 결과는 유지): %s", context, exc, exc_info=True) return None - def _execute_and_record_daily_checkin(db: Session, process, descriptor, trigger: str): + def _serialize_daily_checkin_log(log_row) -> dict[str, Any]: + if hasattr(schemas.DailyCheckInLogSchema, "model_validate"): + return schemas.DailyCheckInLogSchema.model_validate(log_row).model_dump() + return {column.name: getattr(log_row, column.name) for column in log_row.__table__.columns} + + def _record_daily_checkin_result( + db: Session, + target: _DailyCheckInTargetSnapshot, + result, + trigger: str, + *, + deadline: float | None = None, + persistence_phases_remaining: int = 1, + ) -> dict[str, Any]: from src.core import daily_checkin - result = daily_checkin.execute_daily_checkin(descriptor) + phases_remaining = max(int(persistence_phases_remaining), 1) + + def prepare_persistence_phase() -> None: + nonlocal phases_remaining + if deadline is not None: + _configure_database_deadline( + db, + deadline, + phases_remaining=phases_remaining, + ) + remaining_deadline_seconds(deadline) + phases_remaining = max(phases_remaining - 1, 1) + + descriptor = target.descriptor period_start, period_end = daily_checkin.checkin_period_timestamps(descriptor, result.attempted_at) + prepare_persistence_phase() log_row = crud.create_daily_checkin_log( db, schemas.DailyCheckInLogCreate( - process_id=process.id, - process_name=process.name, - user_preset_id=getattr(process, "user_preset_id", None), + process_id=target.process_id, + process_name=target.process_name, + user_preset_id=target.user_preset_id, provider=descriptor.provider, game_id=descriptor.game_id, game_name=descriptor.game_name, @@ -1068,7 +1301,10 @@ def run_server_main(): raw_debug_json=daily_checkin.raw_debug_json(result.raw_debug), ), ) - setting = crud.get_daily_checkin_setting(db, process.id) + if deadline is not None: + remaining_deadline_seconds(deadline) + prepare_persistence_phase() + setting = crud.get_daily_checkin_setting(db, target.process_id) if setting is None: enabled = None elif daily_checkin.setting_matches_descriptor(setting, descriptor): @@ -1077,9 +1313,9 @@ def run_server_main(): enabled = False crud.upsert_daily_checkin_setting( db, - process_id=process.id, - process_name=process.name, - user_preset_id=getattr(process, "user_preset_id", None), + process_id=target.process_id, + process_name=target.process_name, + user_preset_id=target.user_preset_id, provider=descriptor.provider, game_id=descriptor.game_id, game_name=descriptor.game_name, @@ -1091,6 +1327,9 @@ def run_server_main(): last_success_at=result.attempted_at if result.status in daily_checkin.SUCCESS_STATUSES else None, next_run_at=_daily_checkin_next_run_at(result.status, result.attempted_at, period_end), ) + if deadline is not None: + remaining_deadline_seconds(deadline) + prepare_persistence_phase() _try_record_provider_health( db, context="출석 실행", @@ -1098,17 +1337,28 @@ def run_server_main(): reason=result.status, message=result.message, source=f"daily_checkin:{trigger or 'manual_run'}", - process_id=process.id, + process_id=target.process_id, game_id=descriptor.game_id, detected_at=result.attempted_at, ) - return log_row + if deadline is not None: + remaining_deadline_seconds(deadline) + prepare_persistence_phase() + return _serialize_daily_checkin_log(log_row) - def _probe_daily_checkin_payload(db: Session, process, descriptor) -> dict[str, Any]: + def _record_daily_checkin_probe( + db: Session, + target: _DailyCheckInTargetSnapshot, + result, + *, + deadline: float | None = None, + ) -> dict[str, Any]: from src.core import daily_checkin - result = daily_checkin.probe_daily_checkin_status(descriptor) + descriptor = target.descriptor period_start, period_end = daily_checkin.checkin_period_timestamps(descriptor, result.attempted_at) + if deadline is not None: + _configure_database_deadline(db, deadline) _try_record_provider_health( db, context="상태 조회", @@ -1116,14 +1366,14 @@ def run_server_main(): reason=result.status, message=result.message, source="daily_checkin_status_probe", - process_id=process.id, + process_id=target.process_id, game_id=descriptor.game_id, detected_at=result.attempted_at, ) return { - "process_id": process.id, - "process_name": process.name, - "user_preset_id": getattr(process, "user_preset_id", None), + "process_id": target.process_id, + "process_name": target.process_name, + "user_preset_id": target.user_preset_id, "provider": descriptor.provider, "game_id": descriptor.game_id, "game_name": descriptor.game_name, @@ -1146,15 +1396,34 @@ def run_server_main(): }, ) + @app.exception_handler(DatabaseAccessUnavailable) + async def database_access_unavailable_handler(request, exc): + return database_access_error_response(exc) + + @app.exception_handler(DailyCheckInAlreadyInFlight) + async def daily_checkin_in_flight_handler(request, exc): + return JSONResponse( + status_code=409, + content={ + "detail": "daily check-in request already in progress", + "code": exc.code, + }, + ) + # Dependency - def get_db(): - from src.api.beholder_routes import database_access_gate - with database_access_gate(): + def get_db(request: Request): + route_name = f"{request.method} {request.url.path}" + lease = database_coordinator.acquire_request(route_name) + db = None + try: db = SessionLocal() + yield db + finally: try: - yield db + if db is not None: + db.close() finally: - db.close() + lease.release() def _dashboard_static_health() -> dict[str, Any]: from src.api.dashboard.static_files import dashboard_static_dir @@ -1185,13 +1454,31 @@ def run_server_main(): db_ready = False db_error: str | None = None db_started_at = time.perf_counter() - try: - with engine.connect() as conn: - conn.execute(text("SELECT 1")) - db_ready = True - except Exception as e: - db_error = str(e) + access_snapshot = database_coordinator.snapshot() + if access_snapshot.mode == "normal": + try: + with database_coordinator.acquire_request("GET /api/gui/health probe"): + with engine.connect() as conn: + conn.execute(text("SELECT 1")) + db_ready = True + except DatabaseAccessUnavailable as exc: + access_snapshot = database_coordinator.snapshot() + db_error = exc.code + except Exception as e: + db_error = str(e) + elif access_snapshot.mode in {"draining", "maintenance"}: + db_error = "database_maintenance" + else: + db_error = "database_faulted" db_probe_ms = (time.perf_counter() - db_started_at) * 1000 + access_snapshot = database_coordinator.snapshot() + if access_snapshot.mode != "normal": + db_ready = False + db_error = ( + "database_faulted" + if access_snapshot.mode == "faulted" + else "database_maintenance" + ) static_started_at = time.perf_counter() dashboard_static = _dashboard_static_health() @@ -1209,6 +1496,7 @@ def run_server_main(): "db_ready": db_ready, "db_error": db_error, "db_probe_ms": round(db_probe_ms, 2), + "database_access": access_snapshot.to_dict(), "dashboard_static_ready": dashboard_static["ready"], "dashboard_static_path": dashboard_static["path"], "static_probe_ms": round(static_probe_ms, 2), @@ -1418,62 +1706,228 @@ def run_server_main(): return crud.get_daily_checkin_logs(db, process_id=process_id, game_id=game_id, limit=limit) @app.post("/daily-checkin/run", response_model=schemas.DailyCheckInLogSchema) - def run_daily_checkin( - request: schemas.DailyCheckInRunRequest, - db: Session = Depends(get_db), - ): + def run_daily_checkin(request: schemas.DailyCheckInRunRequest): """단일 등록 게임의 출석 POST를 즉시 실행하고 로그에 기록합니다.""" - process, descriptor = _get_daily_checkin_process_or_error(db, request.process_id, request.game_id) - return _execute_and_record_daily_checkin(db, process, descriptor, request.trigger or "manual_run") + from src.core import daily_checkin + + deadline = monotonic_deadline(SINGLE_DAILY_CHECKIN_TOTAL_DEADLINE_SECONDS) + with _database_session( + "POST /daily-checkin/run snapshot", + deadline=deadline, + phases_remaining=2, + ) as db: + target = _daily_checkin_target_snapshot(db, request.process_id, request.game_id) + period_start, _ = daily_checkin.checkin_period_timestamps(target.descriptor) + with daily_checkin_singleflight.acquire_or_raise( + "claim", + target.descriptor.provider, + target.process_id, + target.descriptor.game_id, + period_start, + ): + provider_timeout = bounded_provider_timeout_seconds( + deadline, + SINGLE_DAILY_CHECKIN_TOTAL_DEADLINE_SECONDS, + ) + result = daily_checkin.execute_daily_checkin( + target.descriptor, + timeout_seconds=provider_timeout, + ) + with _database_session( + "POST /daily-checkin/run persist", + deadline=deadline, + phases_remaining=4, + ) as db: + return _record_daily_checkin_result( + db, + target, + result, + request.trigger or "manual_run", + deadline=deadline, + persistence_phases_remaining=4, + ) @app.post("/daily-checkin/status", response_model=schemas.DailyCheckInStatusProbeSchema) - def probe_daily_checkin_status( - request: schemas.DailyCheckInStatusProbeRequest, - db: Session = Depends(get_db), - ): + def probe_daily_checkin_status(request: schemas.DailyCheckInStatusProbeRequest): """단일 등록 게임의 출석 상태를 POST 없이 조회합니다.""" - process, descriptor = _get_daily_checkin_process_or_error(db, request.process_id, request.game_id) - return _probe_daily_checkin_payload(db, process, descriptor) + from src.core import daily_checkin + + deadline = monotonic_deadline(SINGLE_DAILY_CHECKIN_TOTAL_DEADLINE_SECONDS) + with _database_session( + "POST /daily-checkin/status snapshot", + deadline=deadline, + phases_remaining=2, + ) as db: + target = _daily_checkin_target_snapshot(db, request.process_id, request.game_id) + period_start, _ = daily_checkin.checkin_period_timestamps(target.descriptor) + with daily_checkin_singleflight.acquire_or_raise( + "status", + target.descriptor.provider, + target.process_id, + target.descriptor.game_id, + period_start, + ): + provider_timeout = bounded_provider_timeout_seconds( + deadline, + SINGLE_DAILY_CHECKIN_TOTAL_DEADLINE_SECONDS, + ) + result = daily_checkin.probe_daily_checkin_status( + target.descriptor, + timeout_seconds=provider_timeout, + ) + with _database_session( + "POST /daily-checkin/status persist", + deadline=deadline, + ) as db: + return _record_daily_checkin_probe( + db, + target, + result, + deadline=deadline, + ) @app.post("/daily-checkin/run-due") - def run_due_daily_checkins( - request: schemas.DailyCheckInRunDueRequest, - db: Session = Depends(get_db), - ): + def run_due_daily_checkins(request: schemas.DailyCheckInRunDueRequest): """현재 구간에서 due 상태인 opt-in 게임들의 자동 출석을 실행합니다.""" from src.core import daily_checkin + deadline = monotonic_deadline(RUN_DUE_TOTAL_DEADLINE_SECONDS) logs = [] skipped = [] trigger = request.trigger or "periodic" now_ts = time.time() - for setting in crud.get_enabled_daily_checkin_settings(db): - process = crud.get_process_by_id(db=db, process_id=setting.process_id) - if process is None: - skipped.append({"process_id": setting.process_id, "reason": "process_missing"}) - continue - descriptor = daily_checkin.descriptor_for_process(process) - if descriptor is None or descriptor.game_id != setting.game_id: - skipped.append({"process_id": setting.process_id, "reason": "unsupported_or_changed"}) - continue + targets: list[tuple[_DailyCheckInTargetSnapshot, float]] = [] + with _database_session( + "POST /daily-checkin/run-due snapshot", + deadline=deadline, + reserve_seconds=RUN_DUE_MIN_PERSISTENCE_RESERVE_SECONDS, + ) as db: + enabled_settings = crud.get_enabled_daily_checkin_settings(db) + for index, setting in enumerate(enabled_settings): + _configure_database_deadline( + db, + deadline, + reserve_seconds=RUN_DUE_MIN_PERSISTENCE_RESERVE_SECONDS, + phases_remaining=max((len(enabled_settings) - index) * 3, 1), + ) + process = crud.get_process_by_id(db=db, process_id=setting.process_id) + if process is None: + skipped.append({"process_id": setting.process_id, "reason": "process_missing"}) + continue + descriptor = daily_checkin.descriptor_for_process(process) + if descriptor is None or descriptor.game_id != setting.game_id: + skipped.append({"process_id": setting.process_id, "reason": "unsupported_or_changed"}) + continue - period_start, period_end = daily_checkin.checkin_period_timestamps(descriptor, now_ts) - period_logs = crud.get_daily_checkin_logs_for_period( - db, - process_id=process.id, - game_id=descriptor.game_id, - period_start=period_start, - period_end=period_end, - ) - if not daily_checkin.should_attempt_daily_checkin(period_logs, now_ts=now_ts): - skipped.append({"process_id": process.id, "game_id": descriptor.game_id, "reason": "not_due"}) - continue + period_start, period_end = daily_checkin.checkin_period_timestamps(descriptor, now_ts) + period_logs = crud.get_daily_checkin_logs_for_period( + db, + process_id=process.id, + game_id=descriptor.game_id, + period_start=period_start, + period_end=period_end, + ) + if not daily_checkin.should_attempt_daily_checkin(period_logs, now_ts=now_ts): + skipped.append({"process_id": process.id, "game_id": descriptor.game_id, "reason": "not_due"}) + continue + targets.append( + ( + _DailyCheckInTargetSnapshot( + process_id=str(process.id), + process_name=str(process.name), + user_preset_id=getattr(process, "user_preset_id", None), + descriptor=descriptor, + ), + period_start, + ) + ) - log_row = _execute_and_record_daily_checkin(db, process, descriptor, trigger) - if hasattr(schemas.DailyCheckInLogSchema, "model_validate"): - logs.append(schemas.DailyCheckInLogSchema.model_validate(log_row).model_dump()) - else: # pydantic v1 fallback - logs.append({column.name: getattr(log_row, column.name) for column in log_row.__table__.columns}) + remaining_after_snapshot = remaining_deadline_seconds(deadline) + desired_persistence_reserve = min( + RUN_DUE_MAX_PERSISTENCE_RESERVE_SECONDS, + max( + RUN_DUE_MIN_PERSISTENCE_RESERVE_SECONDS, + len(targets) * RUN_DUE_PERSISTENCE_RESERVE_PER_TARGET_SECONDS, + ), + ) + persistence_reserve = min( + desired_persistence_reserve, + remaining_after_snapshot / 2.0, + ) + provider_deadline = deadline - persistence_reserve + pending_results = [] + active_leases = [] + provider_budget_exhausted = remaining_deadline_seconds(provider_deadline) <= 0.0 + try: + for target, period_start in targets: + lease = daily_checkin_singleflight.try_acquire( + "claim", + target.descriptor.provider, + target.process_id, + target.descriptor.game_id, + period_start, + ) + if lease is None: + skipped.append( + { + "process_id": target.process_id, + "game_id": target.descriptor.game_id, + "reason": "in_flight", + } + ) + continue + active_leases.append(lease) + provider_limit = 10.0 if target.descriptor.provider == daily_checkin.PROVIDER_NIKKE_BLABLALINK else 30.0 + provider_timeout = bounded_provider_timeout_seconds(provider_deadline, provider_limit) + if provider_budget_exhausted or provider_timeout <= 0.0: + provider_budget_exhausted = True + result = daily_checkin.DailyCheckInAttemptResult( + provider=target.descriptor.provider, + game_id=target.descriptor.game_id, + game_name=target.descriptor.game_name, + status="network_error", + attempted_at=time.time(), + message="run-due deadline exhausted before provider call", + post_called=False, + raw_debug={"deadline_exhausted": True}, + ) + else: + result = daily_checkin.execute_daily_checkin( + target.descriptor, + timeout_seconds=provider_timeout, + ) + if remaining_deadline_seconds(provider_deadline) <= 0.0: + provider_budget_exhausted = True + pending_results.append((target, result)) + + if pending_results: + with _database_session( + "POST /daily-checkin/run-due persist", + deadline=deadline, + phases_remaining=max(len(pending_results) * 4, 1), + ) as db: + for index, (target, result) in enumerate(pending_results): + if remaining_deadline_seconds(deadline) <= 0.0: + logger.warning( + "run-due persistence deadline exhausted; SQLite lock waits are disabled " + "while preserving the fixed network_error log contract" + ) + logs.append( + _record_daily_checkin_result( + db, + target, + result, + trigger, + deadline=deadline, + persistence_phases_remaining=max( + (len(pending_results) - index) * 4, + 1, + ), + ) + ) + finally: + for lease in reversed(active_leases): + lease.release() return {"logs": logs, "skipped": skipped, "attempted": len(logs)} @app.delete("/processes/{process_id}") @@ -1769,21 +2223,67 @@ def run_server_main(): import uvicorn - # uvicorn.run에 문자열 대신 app 객체를 직접 전달합니다. logger.info(f"API 서버 바인딩: {api_host}:{api_port}") + server = uvicorn.Server( + uvicorn.Config(app, host=api_host, port=api_port, log_level="warning") + ) + + def watch_shutdown_request() -> None: + shutdown_event.wait() + logger.info("API graceful shutdown Event 수신") + server.should_exit = True + + shutdown_monitor = threading.Thread( + target=watch_shutdown_request, + name="api-shutdown-event-monitor", + daemon=True, + ) + shutdown_monitor.start() try: - uvicorn.run(app, host=api_host, port=api_port, log_level="warning") + server.run() finally: + shutdown_event.set() shutdown_api_resources("uvicorn_returned") -def stop_api_server(): - """독립 프로세스로 실행된 API 서버를 종료합니다.""" - global api_server_process - if api_server_process and api_server_process.is_alive(): - print(f"API 서버(PID: {api_server_process.pid}) 종료 중...") - _terminate_existing_api_server(timeout=5.0) +def stop_api_server(timeout: float = API_GRACEFUL_SHUTDOWN_TIMEOUT_SECONDS) -> bool: + """소유한 API child를 정상 종료하고 필요할 때만 강제 종료합니다.""" + global api_server_process, api_server_shutdown_event + + process = api_server_process + if process is None: + return True + + wait_seconds = max(0.0, float(timeout)) + if process.is_alive() and api_server_shutdown_event is not None: + print(f"API 서버(PID: {process.pid}) 정상 종료 요청 (상한 {wait_seconds:.1f}초)...") + api_server_shutdown_event.set() + process.join(timeout=wait_seconds) + + if process.is_alive(): + print(f"API 서버(PID: {process.pid}) 종료 요청...") + process.terminate() + process.join(timeout=wait_seconds) + + if process.is_alive(): + print(f"API 서버(PID: {process.pid}) 강제 종료...") + process.kill() + process.join(timeout=wait_seconds) + + stopped = not process.is_alive() + if stopped: api_server_process = None + api_server_shutdown_event = None + for stale_path in (_server_pid_file_path(), _server_metadata_file_path()): + try: + os.remove(stale_path) + except FileNotFoundError: + pass + except OSError as exc: + print(f"API 서버 메타데이터 정리 실패: {exc}") print("API 서버 종료 완료.") + else: + print(f"API 서버(PID: {process.pid}) 종료 실패.") + return stopped def ensure_process_table_schema(): """ @@ -2047,6 +2547,10 @@ def start_main_application(instance_manager: SingleInstanceApplication): # 메인 윈도우 생성 (인스턴스 매니저 전달) main_window = MainWindow(api_client_instance, instance_manager=instance_manager) + renderer = resolve_ui_renderer(sys.argv[1:]) + presentation = PresentationController(main_window, renderer) + # QObject 부모 관계 외에도 Python 수명주기를 명시적으로 고정합니다. + main_window._presentation_controller = presentation # === Graceful Shutdown: signal 및 atexit 핸들러 등록 === def gui_signal_handler(signum, frame): @@ -2079,9 +2583,10 @@ def start_main_application(instance_manager: SingleInstanceApplication): # IPC 서버 시작 (다른 인스턴스로부터의 활성화 요청 처리용) instance_manager.start_ipc_server(main_window_to_activate=main_window) - main_window.show() # 메인 윈도우 표시 + presentation.show() # 선택된 Widgets/QML presentation 표시 exit_code = app.exec() # 애플리케이션 이벤트 루프 시작 - stop_api_server() # GUI 종료 후 API 서버 명시 종료 + if not stop_api_server(): + print("API 서버 종료에 실패했습니다.") sys.exit(exit_code) # 종료 코드로 시스템 종료 if __name__ == "__main__": @@ -2093,6 +2598,17 @@ if __name__ == "__main__": run_server_main() sys.exit(0) + # Command-only and server-only branches intentionally return before GUI + # logging, schema migration, administrator checks, or Qt initialization. + try: + import logging + from src.gui.runtime_logging import configure_gui_logging + + gui_log_path = configure_gui_logging() + logging.getLogger(__name__).info("GUI logging initialized: %s", gui_log_path) + except Exception as exc: + print(f"[GUI] 순환 로그 초기화 실패: {type(exc).__name__}", file=sys.stderr) + # 디버깅: _MEIPASS 경로 확인 if getattr(sys, 'frozen', False): meipass_path = getattr(sys, '_MEIPASS', 'N/A') @@ -2109,13 +2625,18 @@ if __name__ == "__main__": # 앱 업데이트 시 스키마 구조가 변경된 경우 자동으로 마이그레이션합니다. # 사용자에게는 보이지 않으며, 실패 시에만 경고를 표시합니다. try: - from src.migration import SchemaMigrator - print("\n=== 스키마 버전 체크 ===") - migrator = SchemaMigrator() - if not migrator.check_and_migrate(): - print("⚠️ 스키마 마이그레이션 실패 - 일부 기능이 제한될 수 있습니다.") + from src.api.beholder_routes import database_coordinator + + if database_coordinator.snapshot().mode == "faulted": + print("DB fault sentinel이 유지되어 GUI-side schema migration을 건너뜁니다.") else: - print("=== 스키마 체크 완료 ===\n") + from src.migration import SchemaMigrator + print("\n=== 스키마 버전 체크 ===") + migrator = SchemaMigrator() + if not migrator.check_and_migrate(): + print("⚠️ 스키마 마이그레이션 실패 - 일부 기능이 제한될 수 있습니다.") + else: + print("=== 스키마 체크 완료 ===\n") except Exception as e: print(f"스키마 마이그레이션 체크 중 오류: {e}") # 마이그레이션 실패해도 앱은 계속 실행 (기존 기능은 동작) diff --git a/homework_helper.spec b/homework_helper.spec index 9b18e399..3b8c56db 100644 --- a/homework_helper.spec +++ b/homework_helper.spec @@ -3,10 +3,20 @@ # HomeworkHelper - PyInstaller spec (onedir 모드) # Label Studio Helper 분리 후 정리된 버전 +import os import sys from pathlib import Path +include_qml = os.environ.get('HH_INCLUDE_QML', '').strip().lower() in {'1', 'true', 'yes', 'on'} +qml_hiddenimports = [ + 'PySide6.QtQml', 'PySide6.QtQuick', 'PySide6.QtQuickControls2', +] if include_qml else [] +qml_excludes = [] if include_qml else [ + 'PySide6.QtQml', 'PySide6.QtQuick', 'PySide6.QtQuickControls2', +] + + def collect_tree(src, dest, excludes=()): src_path = Path(src) rows = [] @@ -40,7 +50,8 @@ a = Analysis( 'uvicorn', 'fastapi', 'sqlalchemy', 'starlette', # GUI - 'PyQt6', 'PyQt6.QtWidgets', 'PyQt6.QtCore', 'PyQt6.QtGui', + 'PySide6', 'PySide6.QtWidgets', 'PySide6.QtCore', 'PySide6.QtGui', + 'PySide6.QtNetwork', *qml_hiddenimports, # Windows 'win32api', 'win32security', 'win32process', 'win32con', 'win32com.client', @@ -67,6 +78,7 @@ a = Analysis( # 영상/이미지 처리 (LSH로 이동) 'cv2', 'av', 'skimage', 'scipy', 'matplotlib', 'numpy', 'imageio', + *qml_excludes, ], noarchive=False, optimize=0, diff --git a/installer.iss b/installer.iss index 7f21d8cd..8c45b787 100644 --- a/installer.iss +++ b/installer.iss @@ -274,7 +274,20 @@ begin if IsAppRunning() then begin - if MsgBox('HomeworkHelper가 현재 실행 중입니다.' + #13#10 + #13#10 + + if WizardSilent then + begin + // 자동 업데이트에서는 사용자 입력을 기다리지 않고 같은 종료 경로를 사용합니다. + KillAllAppProcesses(); + Sleep(1000); + + if IsAppRunning() then + begin + Log('무인 설치 중 HomeworkHelper 프로세스를 종료하지 못했습니다.'); + Result := False; + Exit; + end; + end + else if MsgBox('HomeworkHelper가 현재 실행 중입니다.' + #13#10 + #13#10 + '설치를 계속하려면 프로그램을 종료해야 합니다.' + #13#10 + '자동으로 종료하고 계속 진행하시겠습니까?', mbConfirmation, MB_YESNO) = IDYES then diff --git a/requirements.txt b/requirements.txt index 61dd8fa1..844f9d1a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ # HomeworkHelper 의존성 # === GUI === -PyQt6 +PySide6==6.11.1 # === Windows === pywin32; platform_system == "Windows" @@ -37,4 +37,5 @@ pycryptodome>=3.20.0 # === Build/Dev === pyinstaller pytest +httpx2 tqdm diff --git a/reset_windows_python314_venv.py b/reset_windows_python314_venv.py new file mode 100644 index 00000000..5bcd536b --- /dev/null +++ b/reset_windows_python314_venv.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +"""One-time Windows setup: install Python 3.14 and recreate project .venv.""" + +from __future__ import annotations + +import ctypes +import os +import shutil +import subprocess +import sys +from pathlib import Path +from typing import NoReturn + + +PYTHON_PACKAGE_ID = "Python.Python.3.14" +PYTHON_SELECTOR = "-3.14" + + +def fail(message: str) -> NoReturn: + print(f"[ERROR] {message}", file=sys.stderr) + raise SystemExit(1) + + +def run(command: list[str]) -> None: + print(f"> {' '.join(command)}") + completed = subprocess.run(command, check=False) + if completed.returncode != 0: + fail(f"Command failed with exit code {completed.returncode}: {command[0]}") + + +def captured(command: list[str]) -> str: + completed = subprocess.run(command, check=False, capture_output=True, text=True) + if completed.returncode != 0: + detail = (completed.stderr or completed.stdout).strip() + fail(detail or f"Command failed with exit code {completed.returncode}: {command[0]}") + return completed.stdout.strip() + + +def is_inside(path: Path, parent: Path) -> bool: + try: + path.resolve().relative_to(parent.resolve()) + except ValueError: + return False + return True + + +def assert_no_links(root: Path) -> None: + for current_root, directories, files in os.walk(root, topdown=True, followlinks=False): + current = Path(current_root) + for name in [*directories, *files]: + candidate = current / name + is_junction = getattr(candidate, "is_junction", lambda: False) + if candidate.is_symlink() or is_junction(): + fail(f"Refusing to remove a link or junction inside .venv: {candidate}") + + +def find_python_launcher() -> str: + launcher = shutil.which("py") + if launcher: + return launcher + + windows_directory = os.environ.get("WINDIR") + if windows_directory: + system_launcher = Path(windows_directory) / "py.exe" + if system_launcher.is_file(): + return str(system_launcher) + fail("Python Launcher (py.exe) was not found after installing Python 3.14.") + + +def main() -> int: + if os.name != "nt": + fail("This script can only run on Windows.") + if not ctypes.windll.shell32.IsUserAnAdmin(): + fail("Run this script from PowerShell or Command Prompt as Administrator.") + + project_root = Path(__file__).resolve().parent + venv_path = project_root / ".venv" + if venv_path.parent != project_root: + fail(f"Unsafe .venv path: {venv_path}") + if is_inside(Path(sys.executable), venv_path): + fail( + "This script is running from the .venv that it must replace. " + "Deactivate it and run: py -3.13 reset_windows_python314_venv.py" + ) + + winget = shutil.which("winget") + if not winget: + fail("winget was not found. Install or update Microsoft App Installer and retry.") + + print("[1/3] Installing or updating system Python 3.14.") + run( + [ + winget, + "install", + "--id", + PYTHON_PACKAGE_ID, + "--exact", + "--source", + "winget", + "--scope", + "machine", + "--silent", + "--accept-package-agreements", + "--accept-source-agreements", + "--disable-interactivity", + ] + ) + + launcher = find_python_launcher() + installed_version = captured( + [launcher, PYTHON_SELECTOR, "-c", "import platform; print(platform.python_version())"] + ) + if not installed_version.startswith("3.14."): + fail(f"Python 3.14 verification failed. Detected version: {installed_version}") + print(f" Detected Python version: {installed_version}") + + if venv_path.exists(): + if not venv_path.is_dir(): + fail(f".venv is not a directory: {venv_path}") + if venv_path.is_symlink() or getattr(venv_path, "is_junction", lambda: False)(): + fail(f".venv is a link or junction: {venv_path}") + assert_no_links(venv_path) + + print("[2/3] Existing virtual environment to remove:") + print(f" {venv_path}") + if input("Type RECREATE to continue: ") != "RECREATE": + fail("Virtual environment recreation was cancelled.") + + shutil.rmtree(venv_path) + if venv_path.exists(): + fail(f"Failed to remove .venv: {venv_path}") + else: + print("[2/3] Existing .venv was not found; skipping removal.") + + print("[3/3] Creating the project .venv with Python 3.14.") + run([launcher, PYTHON_SELECTOR, "-m", "venv", str(venv_path)]) + + venv_python = venv_path / "Scripts" / "python.exe" + if not venv_python.is_file(): + fail(f"Python executable was not found in .venv: {venv_python}") + venv_version = captured( + [str(venv_python), "-c", "import platform; print(platform.python_version())"] + ) + if not venv_version.startswith("3.14."): + fail(f"Recreated .venv version verification failed: {venv_version}") + + print() + print(f"Completed: .venv Python {venv_version}") + print("Run python build.py or .venv\\Scripts\\python.exe build.py.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/api/beholder_routes.py b/src/api/beholder_routes.py index 775906a7..4533be92 100644 --- a/src/api/beholder_routes.py +++ b/src/api/beholder_routes.py @@ -2,57 +2,117 @@ from __future__ import annotations +import logging import os import sqlite3 import threading import time +import uuid from pathlib import Path from typing import Any from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import JSONResponse from pydantic import BaseModel from sqlalchemy.orm import Session -from src.data import beholder, crud, models +from src.data import beholder, crud, models, schemas from src.data.database import Base, SessionLocal, auto_migrate_database, base_dir, data_dir, db_path, engine +from src.data.database_coordination import ( + DatabaseAccessUnavailable, + DatabaseDrainTimeout, + DatabaseFaultStatePersistenceError, + create_database_coordinator, + database_access_error_response, + database_drain_timeout_response, +) router = APIRouter(prefix="/api/beholder", tags=["beholder"]) +logger = logging.getLogger(__name__) +_lifecycle_failure_lock = threading.Lock() -_RESTORE_LOCK = threading.RLock() +database_coordinator = create_database_coordinator(data_dir) -def database_access_gate(): - """Serialize normal DB access with destructive backup restore swaps.""" - return _RESTORE_LOCK +def database_access_gate(route_name: str = "legacy_database_request"): + """Compatibility entry point returning a concurrent ownerless DB lease.""" + + return database_coordinator.acquire_request(route_name) def _require_valid_sqlite_backup(path: str | Path) -> None: + conn = None try: - with sqlite3.connect(f"file:{Path(path)}?mode=ro", uri=True) as conn: - result = conn.execute("PRAGMA integrity_check").fetchone() - if not result or str(result[0]).lower() != "ok": - raise HTTPException(status_code=422, detail="선택한 백업 DB integrity check가 실패했습니다.") - conn.execute("PRAGMA wal_checkpoint(PASSIVE)").fetchall() + conn = sqlite3.connect(f"file:{Path(path)}?mode=ro", uri=True) + result = conn.execute("PRAGMA integrity_check").fetchone() + if not result or str(result[0]).lower() != "ok": + raise HTTPException(status_code=422, detail="선택한 백업 DB integrity check가 실패했습니다.") except HTTPException: raise except sqlite3.Error as exc: raise HTTPException(status_code=422, detail=f"백업 DB를 안전하게 열 수 없습니다: {exc}") from exc + finally: + if conn is not None: + conn.close() def _copy_sqlite_database(source: str | Path, target: str | Path) -> None: - with sqlite3.connect(f"file:{Path(source)}?mode=ro", uri=True) as src, sqlite3.connect(target) as dst: + src = sqlite3.connect(f"file:{Path(source)}?mode=ro", uri=True) + dst = sqlite3.connect(target) + try: src.backup(dst) + finally: + dst.close() + src.close() def _checkpoint_live_database(path: str | Path) -> None: if not os.path.exists(path): return + conn = None try: - with sqlite3.connect(path) as conn: - conn.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchall() + conn = sqlite3.connect(path, timeout=0.25) + conn.execute("PRAGMA busy_timeout=250") + result = conn.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone() + if result is None or len(result) < 3: + raise HTTPException( + status_code=500, + detail="현재 DB WAL checkpoint 결과를 확인할 수 없어 복구를 중단했습니다.", + ) + busy, _wal_pages, _checkpointed_pages = result + if int(busy) != 0: + raise HTTPException( + status_code=500, + detail="현재 DB를 읽는 미조정 연결이 남아 있어 복구를 중단했습니다.", + ) + except HTTPException: + raise except sqlite3.Error as exc: raise HTTPException(status_code=500, detail=f"현재 DB WAL 정리에 실패해 복구를 중단했습니다: {exc}") from exc + finally: + if conn is not None: + conn.close() + + +def _remove_database_sidecars(path: str | Path) -> None: + for suffix in ("-wal", "-shm"): + sidecar = str(path) + suffix + if os.path.exists(sidecar): + os.remove(sidecar) + + +def _cleanup_temporary_database(path: str | Path) -> None: + for candidate in (str(path), str(path) + "-wal", str(path) + "-shm"): + if os.path.exists(candidate): + os.remove(candidate) + + +def _strict_prepare_live_database() -> None: + engine.dispose() + Base.metadata.create_all(bind=engine) + auto_migrate_database(strict=True) + _require_valid_sqlite_backup(db_path) BACKUP_SUMMARY_TABLES = { @@ -65,12 +125,17 @@ def _checkpoint_live_database(path: str | Path) -> None: def get_db(): - with database_access_gate(): + lease = database_coordinator.acquire_request("beholder") + db = None + try: db = SessionLocal() + yield db + finally: try: - yield db + if db is not None: + db.close() finally: - db.close() + lease.release() class ResolveRequest(BaseModel): @@ -83,6 +148,15 @@ class RuntimeHeartbeatRequest(BaseModel): shutdown: bool = False +class LifecycleFailureRequest(BaseModel): + kind: str + process_id: str + process_name: str + runtime_token: str + attempts: int + error_type: str + + class OpenSessionReconcileRequest(BaseModel): running_process_ids: list[str] = [] @@ -131,6 +205,89 @@ def update_runtime_heartbeat(payload: RuntimeHeartbeatRequest, db: Session = Dep } +def _record_lifecycle_failure_incident( + db: Session, + *, + kind: str, + process_id: str, + process_name: str, + runtime_token: str, + attempts: int, + error_type: str, +) -> models.BeholderIncident: + """Return the one incident associated with a stable lifecycle event.""" + + target = f"process_id={process_id};kind={kind};token={runtime_token}" + existing = ( + db.query(models.BeholderIncident) + .filter( + models.BeholderIncident.operation_kind == "runtime_lifecycle_persistence_failure", + models.BeholderIncident.target_summary == target, + ) + .order_by(models.BeholderIncident.id.desc()) + .first() + ) + if existing is None: + operation = beholder.BeholderOperation( + kind="runtime_lifecycle_persistence_failure", + actor="process_monitor", + evidence={"process_id": process_id, "kind": kind}, + ) + existing = beholder.create_incident( + db, + severity=beholder.SEVERITY_WARNING, + operation=operation, + target_summary=target, + suspected_cause=( + f"게임 {kind} 기록을 {max(0, attempts)}회 시도했으나 " + f"완료를 확인하지 못했습니다 ({error_type[:80]})." + ), + current_state_summary="OS 실행 상태 표시는 즉시 반영됐지만 DB lifecycle 확정은 보류됐습니다.", + proposed_change_summary="다음 앱 시작 시 open-session reconcile로 실제 실행 상태를 다시 확인합니다.", + risk_score=55, + risk_factors=["runtime_state_ambiguous"], + safe_recommendation="앱과 API 연결을 확인한 뒤 재시작하여 lifecycle reconcile을 수행하세요.", + user_title=f"{process_name} 실행 기록을 확정하지 못했습니다", + user_summary="게임 실행 표시는 유지되지만 플레이 기록 저장 여부를 다시 확인해야 합니다.", + user_impact="동일 token의 실패 사건은 한 건만 표시하며 별도 재생 상태는 저장하지 않습니다.", + recommended_action="quarantine", + available_actions=[ + { + "id": "quarantine", + "label": "재시작 후 재확인", + "description": "현재 상태를 보존하고 다음 startup reconcile에서 확인합니다.", + "recommended": True, + }, + { + "id": "deny", + "label": "확인 완료", + "description": "진단만 확인하고 사건을 닫습니다.", + }, + ], + ) + return existing + + +@router.post("/runtime/lifecycle-failure") +def record_lifecycle_failure( + payload: LifecycleFailureRequest, + db: Session = Depends(get_db), +) -> dict[str, Any]: + """Persist an exhausted GUI lifecycle retry as a user-visible incident.""" + + with _lifecycle_failure_lock: + existing = _record_lifecycle_failure_incident( + db, + kind=payload.kind, + process_id=payload.process_id, + process_name=payload.process_name, + runtime_token=payload.runtime_token, + attempts=payload.attempts, + error_type=payload.error_type, + ) + return {"ok": True, "incident": beholder.incident_to_dict(existing)} + + @router.post("/open-sessions/reconcile") def reconcile_open_sessions(payload: OpenSessionReconcileRequest, db: Session = Depends(get_db)) -> dict[str, Any]: incidents = beholder.create_open_session_recovery_incidents( @@ -204,9 +361,31 @@ def _backup_files() -> list[dict[str, Any]]: return files +def _acquire_backup_read_lease(route_name: str): + """Coordinate live-DB summaries with normal restore and fault recovery. + + Faulted mode exposes only this explicit recovery read admission. Both + lease types share the same active counter, so a restore cannot enter its + replace section until every summary connection has closed. + """ + + if database_coordinator.snapshot().mode == "faulted": + return database_coordinator.acquire_fault_recovery_read(route_name) + return database_coordinator.acquire_request(route_name) + + @router.get("/backups") -def list_backups() -> dict[str, Any]: - return {"backups": _backup_files(), "current_db_path": db_path, "current": _db_summary(db_path)} +def list_backups() -> Any: + try: + lease = _acquire_backup_read_lease("GET /api/beholder/backups") + except DatabaseAccessUnavailable as exc: + return database_access_error_response(exc) + with lease: + return { + "backups": _backup_files(), + "current_db_path": db_path, + "current": _db_summary(db_path), + } class RestoreRequest(BaseModel): @@ -214,59 +393,162 @@ class RestoreRequest(BaseModel): @router.post("/backups/restore-preview") -def restore_preview(payload: RestoreRequest) -> dict[str, Any]: - files = {item["slot"]: item for item in _backup_files()} - if payload.slot not in files: - raise HTTPException(status_code=404, detail="선택한 백업을 찾을 수 없습니다.") - current = _db_summary(db_path) - backup = files[payload.slot] - return { - "backup": backup, - "current": current, - "impact": { - "will_replace_current_db": True, - "previous_snapshot_will_be_created": os.path.exists(db_path), - "summary": ( - f"현재 DB를 backup.{payload.slot}의 내용으로 교체합니다. " - "복구 직전 현재 DB는 별도 snapshot으로 보존됩니다." - ), - }, - } +def restore_preview(payload: RestoreRequest) -> Any: + try: + lease = _acquire_backup_read_lease("POST /api/beholder/backups/restore-preview") + except DatabaseAccessUnavailable as exc: + return database_access_error_response(exc) + with lease: + files = {item["slot"]: item for item in _backup_files()} + if payload.slot not in files: + raise HTTPException(status_code=404, detail="선택한 백업을 찾을 수 없습니다.") + current = _db_summary(db_path) + backup = files[payload.slot] + return { + "backup": backup, + "current": current, + "impact": { + "will_replace_current_db": True, + "previous_snapshot_will_be_created": os.path.exists(db_path), + "summary": ( + f"현재 DB를 backup.{payload.slot}의 내용으로 교체합니다. " + "복구 직전 현재 DB는 별도 snapshot으로 보존됩니다." + ), + }, + } @router.post("/backups/restore") -def restore_backup(payload: RestoreRequest) -> dict[str, Any]: +def restore_backup(payload: RestoreRequest) -> Any: files = {item["slot"]: item for item in _backup_files()} if payload.slot not in files: raise HTTPException(status_code=404, detail="선택한 백업을 찾을 수 없습니다.") source = files[payload.slot]["path"] _require_valid_sqlite_backup(source) + timestamp = int(time.time() * 1000) + operation_id = f"{timestamp}.{uuid.uuid4().hex}" + before_path = os.path.join(data_dir, f"app_data.before_beholder_restore.{operation_id}.db") + restore_tmp = os.path.join(data_dir, f"app_data.restore_tmp.{operation_id}.db") + rollback_tmp = os.path.join(data_dir, f"app_data.rollback_tmp.{operation_id}.db") - with _RESTORE_LOCK: - before_path = os.path.join(data_dir, f"app_data.before_beholder_restore.{int(time.time())}.db") - restore_tmp = os.path.join(data_dir, f"app_data.restore_tmp.{int(time.time() * 1000)}.db") - if os.path.exists(db_path): - _copy_sqlite_database(db_path, before_path) - try: - _copy_sqlite_database(source, restore_tmp) - _require_valid_sqlite_backup(restore_tmp) - - # Close pooled SQLite handles before replacing the live DB; otherwise - # Windows file locks or stale pooled connections can make restore - # appear successful while the app continues to serve the old database. - engine.dispose() - _checkpoint_live_database(db_path) - for suffix in ("-wal", "-shm"): - sidecar = db_path + suffix - if os.path.exists(sidecar): - os.remove(sidecar) - os.replace(restore_tmp, db_path) - engine.dispose() - Base.metadata.create_all(bind=engine) - auto_migrate_database() - finally: - for path in (restore_tmp, restore_tmp + "-wal", restore_tmp + "-shm"): - if os.path.exists(path): - os.remove(path) + # Copy and validate the selected backup before rejecting normal DB traffic. + try: + _copy_sqlite_database(source, restore_tmp) + _require_valid_sqlite_backup(restore_tmp) + except Exception: + _cleanup_temporary_database(restore_tmp) + raise + + snapshot = database_coordinator.snapshot() + try: + if snapshot.mode == "faulted": + maintenance = database_coordinator.begin_fault_recovery("beholder_backup_restore") + else: + maintenance = database_coordinator.begin_maintenance("beholder_backup_restore") + except DatabaseDrainTimeout as exc: + _cleanup_temporary_database(restore_tmp) + return database_drain_timeout_response(exc) + except DatabaseAccessUnavailable as exc: + _cleanup_temporary_database(restore_tmp) + return database_access_error_response(exc) + except DatabaseFaultStatePersistenceError as exc: + _cleanup_temporary_database(restore_tmp) + logger.exception("DB restore durable guard 생성 실패") + return JSONResponse( + status_code=500, + content={ + "detail": "database restore failed before live database replacement", + "code": "database_restore_failed", + "restore_error": str(exc), + }, + ) + + live_replaced = False + try: + with maintenance: + try: + engine.dispose() + if os.path.exists(db_path): + _copy_sqlite_database(db_path, before_path) + _require_valid_sqlite_backup(before_path) + _checkpoint_live_database(db_path) + _remove_database_sidecars(db_path) + os.replace(restore_tmp, db_path) + live_replaced = True + _strict_prepare_live_database() + except Exception as restore_exc: + engine.dispose() + if live_replaced: + if os.path.exists(before_path): + try: + _copy_sqlite_database(before_path, rollback_tmp) + _require_valid_sqlite_backup(rollback_tmp) + _remove_database_sidecars(db_path) + os.replace(rollback_tmp, db_path) + _strict_prepare_live_database() + except Exception as rollback_exc: + try: + maintenance.mark_faulted("database_restore_rollback_failed") + except DatabaseFaultStatePersistenceError: + logger.exception( + "DB restore rollback 실패 sentinel 저장 실패; durable guard를 유지합니다." + ) + return JSONResponse( + status_code=500, + content={ + "detail": "database restore and rollback failed", + "code": "database_restore_rollback_failed", + "restore_error": str(restore_exc), + "rollback_error": str(rollback_exc), + }, + ) + return JSONResponse( + status_code=500, + content={ + "detail": "database restore failed and previous database was restored", + "code": "database_restore_rolled_back", + "restore_error": str(restore_exc), + }, + ) + try: + maintenance.mark_faulted("database_restore_rollback_unavailable") + except DatabaseFaultStatePersistenceError: + logger.exception( + "DB restore rollback 부재 sentinel 저장 실패; durable guard를 유지합니다." + ) + return JSONResponse( + status_code=500, + content={ + "detail": "database restore failed and no rollback snapshot was available", + "code": "database_restore_rollback_unavailable", + "restore_error": str(restore_exc), + }, + ) + return JSONResponse( + status_code=500, + content={ + "detail": "database restore failed before live database replacement", + "code": "database_restore_failed", + "restore_error": str(restore_exc), + }, + ) + except DatabaseFaultStatePersistenceError as exc: + logger.exception("DB restore 성공 후 fault sentinel 정리 실패") + return JSONResponse( + status_code=500, + content={ + "detail": "database restored but safety state could not be cleared", + "code": "database_restore_sentinel_clear_failed", + "database_restored": bool(live_replaced), + "sentinel_clear_error": str(exc), + "follow_up": "복원된 DB는 유지됩니다. fault sentinel을 확인한 뒤 복구를 다시 실행하세요.", + "restored_from": source, + "previous_snapshot": before_path, + }, + ) + finally: + engine.dispose() + for temporary in (restore_tmp, rollback_tmp): + _cleanup_temporary_database(temporary) return {"ok": True, "restored_from": source, "previous_snapshot": before_path} diff --git a/src/api/client.py b/src/api/client.py index 66f3ecbd..b1e749da 100644 --- a/src/api/client.py +++ b/src/api/client.py @@ -1,6 +1,7 @@ # api_client.py import requests +from dataclasses import dataclass from typing import Any, List, Optional import uuid from src.api.runtime_config import resolve_local_api_base_url @@ -16,6 +17,298 @@ def __init__(self, response: requests.Response, incident: dict[str, Any]): super().__init__(incident.get("safe_recommendation") or response.text, response=response) +class DatabaseMaintenanceResponse(requests.HTTPError): + """A background request was rejected while DB maintenance drains traffic.""" + + def __init__(self, response: requests.Response, retry_after_seconds: int = 2): + self.retry_after_seconds = int(retry_after_seconds) + super().__init__("database_maintenance", response=response) + + +class DatabaseFaultedResponse(requests.HTTPError): + """DB access is intentionally disabled until a verified restore succeeds.""" + + +@dataclass(frozen=True, slots=True) +class BackgroundHttpResult: + status_code: int + payload: Any + elapsed_seconds: float + + +class BackgroundApiTransport: + """Pure, cache-free HTTP transport intended for Qt worker threads. + + A fresh Session is used per request so suspend/resume cannot leave a pooled + socket adapter wedged. This class never mutates GUI-owned model caches. + """ + + def __init__(self, base_url: str = "http://127.0.0.1:8000"): + self.base_url = resolve_local_api_base_url(base_url) + + def request_json( + self, + method: str, + path: str, + *, + timeout: float, + params: dict[str, Any] | None = None, + json_body: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + ) -> BackgroundHttpResult: + import time + + started_at = time.monotonic() + with requests.Session() as session: + response = session.request( + method, + f"{self.base_url}{path}", + params=params, + json=json_body, + headers=headers, + timeout=max(0.001, float(timeout)), + ) + try: + payload = response.json() + except ValueError: + payload = None + code = payload.get("code") if isinstance(payload, dict) else None + if response.status_code == 503 and code == "database_maintenance": + retry_after = payload.get("retry_after_seconds", 2) + raise DatabaseMaintenanceResponse(response, int(retry_after or 2)) + if response.status_code == 503 and code == "database_faulted": + raise DatabaseFaultedResponse("database_faulted", response=response) + if response.status_code == 409 and isinstance(payload, dict): + incident = payload.get("beholder_incident") + if isinstance(incident, dict): + raise BeholderIncidentRequired(response, incident) + response.raise_for_status() + return BackgroundHttpResult( + status_code=response.status_code, + payload=payload, + elapsed_seconds=time.monotonic() - started_at, + ) + + def get_json(self, path: str, *, timeout: float = 10.0) -> BackgroundHttpResult: + return self.request_json("GET", path, timeout=timeout) + + def post_json( + self, + path: str, + payload: dict[str, Any], + *, + timeout: float = 10.0, + headers: dict[str, str] | None = None, + ) -> BackgroundHttpResult: + return self.request_json("POST", path, timeout=timeout, json_body=payload, headers=headers) + + def put_json( + self, + path: str, + payload: dict[str, Any], + *, + timeout: float = 10.0, + headers: dict[str, str] | None = None, + ) -> BackgroundHttpResult: + return self.request_json("PUT", path, timeout=timeout, json_body=payload, headers=headers) + + def delete_json( + self, + path: str, + *, + timeout: float = 10.0, + headers: dict[str, str] | None = None, + ) -> BackgroundHttpResult: + return self.request_json("DELETE", path, timeout=timeout, headers=headers) + + def start_session( + self, + *, + app_instance_id: str, + process_id: str, + process_name: str, + pid: int, + process_create_time: float, + timeout: float = 10.0, + ) -> dict[str, Any]: + lease_token = ( + f"{app_instance_id}:{process_id}:{int(pid)}:{float(process_create_time):.6f}" + ) + result = self.post_json( + "/sessions", + { + "process_id": process_id, + "process_name": process_name, + "start_timestamp": float(process_create_time), + "session_owner": "process_monitor", + "lease_token": lease_token, + "runtime_evidence": { + "current_process_running": True, + "app_instance_id": app_instance_id, + "pid": int(pid), + "process_create_time": float(process_create_time), + }, + }, + timeout=timeout, + headers={ + "X-HH-Beholder-Actor": "process_monitor", + "X-HH-Beholder-Operation": "runtime_start", + }, + ) + return result.payload if isinstance(result.payload, dict) else {} + + def patch_json( + self, + path: str, + payload: dict[str, Any], + *, + timeout: float = 10.0, + headers: dict[str, str] | None = None, + ) -> BackgroundHttpResult: + return self.request_json("PATCH", path, timeout=timeout, json_body=payload, headers=headers) + + def end_session( + self, + *, + session_id: int, + end_timestamp: float, + stamina_at_end: int | None = None, + resource_percent_at_end: float | None = None, + timeout: float = 10.0, + ) -> dict[str, Any]: + payload: dict[str, Any] = { + "end_timestamp": float(end_timestamp), + "session_duration": 0, + "close_reason": "process_exit", + } + if stamina_at_end is not None: + payload["stamina_at_end"] = int(stamina_at_end) + if resource_percent_at_end is not None: + payload["resource_percent_at_end"] = float(resource_percent_at_end) + result = self.put_json( + f"/sessions/{int(session_id)}/end", + payload, + timeout=timeout, + headers={ + "X-HH-Beholder-Actor": "process_monitor", + "X-HH-Beholder-Operation": "runtime_stop", + }, + ) + return result.payload if isinstance(result.payload, dict) else {} + + def update_process_stamina( + self, + process_id: str, + stamina_current: int, + stamina_max: int, + stamina_updated_at: float, + *, + timeout: float = 10.0, + ) -> None: + """Persist stamina fields without touching an ``ApiClient`` cache.""" + self.patch_json( + f"/processes/{process_id}/stamina", + { + "stamina_current": int(stamina_current), + "stamina_max": int(stamina_max), + "stamina_updated_at": float(stamina_updated_at), + }, + timeout=timeout, + headers={ + "X-HH-Beholder-Actor": "hoyolab_slow_followup", + "X-HH-Beholder-Operation": "process_stamina_refresh", + }, + ) + + def update_process_resource( + self, + process_id: str, + resource_percent: float | None, + resource_updated_at: float | None, + resource_status: str | None, + resource_label: str | None = None, + *, + timeout: float = 10.0, + ) -> None: + """Persist external-resource fields without mutating GUI state.""" + self.patch_json( + f"/processes/{process_id}/resource", + { + "resource_percent": resource_percent, + "resource_updated_at": resource_updated_at, + "resource_status": resource_status, + "resource_label": resource_label, + }, + timeout=timeout, + headers={ + "X-HH-Beholder-Actor": "resource_tracker", + "X-HH-Beholder-Operation": "process_resource_update", + }, + ) + + def update_session_stamina( + self, + session_id: int, + stamina_at_end: int, + *, + timeout: float = 10.0, + ) -> None: + self.request_json( + "PATCH", + f"/sessions/{int(session_id)}/stamina", + params={"stamina_at_end": int(stamina_at_end)}, + timeout=timeout, + headers={ + "X-HH-Beholder-Actor": "hoyolab_slow_followup", + "X-HH-Beholder-Operation": "hoyolab_session_stamina_rewrite", + }, + ) + + def update_session_resource( + self, + session_id: int, + resource_percent_at_end: float, + *, + timeout: float = 10.0, + ) -> None: + self.request_json( + "PATCH", + f"/sessions/{int(session_id)}/resource", + params={"resource_percent_at_end": float(resource_percent_at_end)}, + timeout=timeout, + headers={ + "X-HH-Beholder-Actor": "resource_slow_followup", + "X-HH-Beholder-Operation": "resource_session_percent_rewrite", + }, + ) + + def update_provider_credential_health( + self, + payload: dict[str, Any], + *, + timeout: float = 10.0, + ) -> None: + provider = str(payload["provider"]) + self.post_json( + f"/provider-health/{provider}", + dict(payload), + timeout=timeout, + ) + + def run_due_daily_checkins( + self, + *, + trigger: str, + timeout: float = 65.0, + ) -> dict[str, Any]: + result = self.post_json( + "/daily-checkin/run-due", + {"trigger": str(trigger)}, + timeout=timeout, + ) + return result.payload if isinstance(result.payload, dict) else {} + + class ApiClient: """ FastAPI 서버와 통신하여 데이터를 CRUD하는 클라이언트. @@ -51,7 +344,8 @@ def _raise_for_status(self, response: requests.Response) -> None: body = {} incident = body.get("beholder_incident") if incident: - self.latest_beholder_incident = incident + if incident.get("status") == "pending": + self.latest_beholder_incident = incident raise BeholderIncidentRequired(response, incident) response.raise_for_status() @@ -452,7 +746,7 @@ def run_daily_checkin( response = requests.post( f"{self.base_url}/daily-checkin/run", json={"process_id": process_id, "game_id": game_id, "trigger": trigger}, - timeout=45, + timeout=35, ) self._raise_for_status(response) return response.json() @@ -471,7 +765,7 @@ def probe_daily_checkin_status( response = requests.post( f"{self.base_url}/daily-checkin/status", json={"process_id": process_id, "game_id": game_id}, - timeout=30, + timeout=35, ) self._raise_for_status(response) return response.json() @@ -485,7 +779,7 @@ def run_due_daily_checkins(self, *, trigger: str = "periodic") -> dict[str, Any] response = requests.post( f"{self.base_url}/daily-checkin/run-due", json={"trigger": trigger}, - timeout=90, + timeout=65, ) self._raise_for_status(response) payload = response.json() @@ -648,16 +942,30 @@ def save_global_settings(self, updated_settings: GlobalSettings, actor: str = "s # --- ProcessSession 관련 메서드 --- - def start_session(self, process_id: str, process_name: str, start_timestamp: float) -> Optional[ProcessSession]: + def start_session( + self, + process_id: str, + process_name: str, + start_timestamp: float, + *, + pid: int | None = None, + process_create_time: float | None = None, + ) -> Optional[ProcessSession]: """새로운 프로세스 세션 시작""" try: + create_time = float(process_create_time if process_create_time is not None else start_timestamp) + stable_pid = int(pid or 0) data = { "process_id": process_id, "process_name": process_name, "start_timestamp": start_timestamp, + "session_owner": "process_monitor", + "lease_token": f"{self.app_instance_id}:{process_id}:{stable_pid}:{create_time:.6f}", "runtime_evidence": { "current_process_running": True, "app_instance_id": self.app_instance_id, + "pid": stable_pid, + "process_create_time": create_time, }, } response = requests.post( diff --git a/src/api/dashboard/frontend/src/App.tsx b/src/api/dashboard/frontend/src/App.tsx index ce28101a..d77cd872 100644 --- a/src/api/dashboard/frontend/src/App.tsx +++ b/src/api/dashboard/frontend/src/App.tsx @@ -38,7 +38,9 @@ const secondsToText = (seconds = 0) => { const mins = Math.round(seconds / 60); const hourLabel = (hour: number) => `${hour < 12 ? 'AM' : 'PM'} ${hour % 12 === 0 ? 0 : hour % 12}시`; const preferenceText = (value?: string) => ({ weekday: '평일 선호', weekend: '주말 선호', balanced: '평일/주말 균형', none: '데이터 없음' }[value || 'none'] || '데이터 없음'); const deltaText = (delta: any) => !delta ? '이전 기간 데이터 없음' : delta.percent === null ? (delta.current ? '이전 기간 0분' : '변화 없음') : `${delta.change > 0 ? '+' : ''}${delta.percent}%`; -const fetchJson = async (url: string) => { const response = await fetch(url); const contentType = response.headers.get('content-type') || ''; if (!contentType.includes('application/json')) throw new Error(`API가 JSON이 아닌 응답을 반환했습니다 (${response.status})`); const body = await response.json(); if (!response.ok || body.detail) throw new Error(body.detail || `API 요청 실패 (${response.status})`); return body; }; +const REQUEST_TIMEOUT_MS = 10_000; +const fetchJson = async (url: string, signal?: AbortSignal) => { const response = await fetch(url, { signal }); const contentType = response.headers.get('content-type') || ''; if (!contentType.includes('application/json')) throw new Error(`API가 JSON이 아닌 응답을 반환했습니다 (${response.status})`); const body = await response.json(); if (!response.ok || body.detail) throw new Error(body.detail || `API 요청 실패 (${response.status})`); return body; }; +const requestErrorText = (error: unknown) => error instanceof DOMException && error.name === 'AbortError' ? '요청 시간이 10초를 초과했습니다.' : error instanceof Error ? error.message : '알 수 없는 API 오류가 발생했습니다.'; const iconUrl = (g: GameMetric, size = 128) => `/api/dashboard/icons/${g.icon_process_id || g.process_ids?.[0] || g.process_id}?size=${size}`; function EChart({ option, className = 'chart', onBrush }: { option: echarts.EChartsCoreOption; className?: string; onBrush?: (event: any) => void }) { @@ -60,19 +62,66 @@ export default function App() { const [data, setData] = React.useState(null); const [error, setError] = React.useState(null); const [drawer, setDrawer] = React.useState(null); + const [reloadKey, setReloadKey] = React.useState(0); + const rangeRequest = React.useRef(null); - React.useEffect(() => { fetchJson('/api/analytics/games').then((body) => setAllGames(body.games || [])).catch((e) => setError(e.message)); }, []); + React.useEffect(() => () => rangeRequest.current?.abort(), []); + + React.useEffect(() => { + rangeRequest.current?.abort(); + rangeRequest.current = null; + const controller = new AbortController(); + const timeout = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + let active = true; + fetchJson('/api/analytics/games', controller.signal) + .then((body) => { if (active) setAllGames(body.games || []); }) + .catch((e) => { if (active) setError(requestErrorText(e)); }) + .finally(() => window.clearTimeout(timeout)); + return () => { active = false; window.clearTimeout(timeout); controller.abort(); }; + }, [reloadKey]); React.useEffect(() => { + rangeRequest.current?.abort(); + rangeRequest.current = null; + const controller = new AbortController(); + const timeout = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + let active = true; const q = new URLSearchParams({ start: range.start, end: range.end, ...(gameId !== 'all' ? { game_id: gameId } : {}) }); setError(null); - Promise.all(['timeline','summary','patterns','sessions'].map((n) => fetchJson(`/api/analytics/${n}?${q}`))).then(([timeline, summary, patterns, sessions]) => { + Promise.all(['timeline','summary','patterns','sessions'].map((n) => fetchJson(`/api/analytics/${n}?${q}`, controller.signal))).then(([timeline, summary, patterns, sessions]) => { + if (!active) return; setData({ timeline, summary, patterns, sessions }); if (timeline.range) setRange((current) => current.start === timeline.range.start && current.end === timeline.range.end ? current : timeline.range); - }).catch((e) => setError(e.message)); - }, [range.start, range.end, gameId]); + }).catch((e) => { if (active) setError(requestErrorText(e)); }).finally(() => window.clearTimeout(timeout)); + return () => { active = false; window.clearTimeout(timeout); controller.abort(); }; + }, [range.start, range.end, gameId, reloadKey]); - const quick = (name: string) => { const now = new Date(); if (name === '7d') setRange({ start: fmtDate(addDays(now, -6)), end: fmtDate(now) }); if (name === '30d') setRange({ start: fmtDate(addDays(now, -29)), end: fmtDate(now) }); if (name === 'month') setRange({ start: fmtDate(new Date(now.getFullYear(), now.getMonth(), 1)), end: fmtDate(now) }); if (name === 'all') { const q = new URLSearchParams(gameId !== 'all' ? { game_id: gameId } : {}); fetchJson(`/api/analytics/range?${q}`).then((bounds: ApiRange) => setRange(bounds)).catch((e) => setError(e.message)); } }; - if (!data) return
플레이 데이터를 불러오는 중…{error && ` ${error}`}
; + const quick = (name: string) => { + rangeRequest.current?.abort(); + rangeRequest.current = null; + const now = new Date(); + if (name === '7d') setRange({ start: fmtDate(addDays(now, -6)), end: fmtDate(now) }); + if (name === '30d') setRange({ start: fmtDate(addDays(now, -29)), end: fmtDate(now) }); + if (name === 'month') setRange({ start: fmtDate(new Date(now.getFullYear(), now.getMonth(), 1)), end: fmtDate(now) }); + if (name !== 'all') return; + + const controller = new AbortController(); + rangeRequest.current = controller; + const timeout = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + const q = new URLSearchParams(gameId !== 'all' ? { game_id: gameId } : {}); + setError(null); + fetchJson(`/api/analytics/range?${q}`, controller.signal) + .then((bounds: ApiRange) => { + if (rangeRequest.current === controller) setRange(bounds); + }) + .catch((e) => { + if (rangeRequest.current === controller) setError(requestErrorText(e)); + }) + .finally(() => { + window.clearTimeout(timeout); + if (rangeRequest.current === controller) rangeRequest.current = null; + }); + }; + if (!data) return
{error ? <>API 오류: {error} : '플레이 데이터를 불러오는 중…'}
; const games: GameMetric[] = data.summary.metrics.games || []; const timelineGames: GameMetric[] = data.timeline.games || games; @@ -92,5 +141,5 @@ export default function App() { const heatmapOption: echarts.EChartsCoreOption = { tooltip: { formatter: (p: any) => `${data.patterns.weekdays[p.value[1]]} ${hourLabel(p.value[0])}
${secondsToText(p.value[2])}` }, grid: { left: 62, right: 20, top: 24, bottom: 58 }, xAxis: { type: 'category', data: data.patterns.hours.map(hourLabel), axisLabel: { interval: 1, rotate: 35 } }, yAxis: { type: 'category', data: data.patterns.weekdays, axisLabel: { color: (value: string) => value === '토' || value === '일' ? '#fbbf24' : '#cbd5e1', fontWeight: (value: string) => value === '토' || value === '일' ? 800 : 500 } }, visualMap: { min: 0, max: Math.max(3600, ...data.patterns.heatmap.map((h: any) => h.total_seconds)), calculable: true, orient: 'horizontal', left: 'center', bottom: 4, inRange: { color: ['#111827', '#312e81', '#7c3aed', '#22d3ee'] } }, series: [{ type: 'heatmap', data: data.patterns.heatmap.map((h: any) => ({ value: [h.hour, h.weekday, h.total_seconds], itemStyle: h.weekday >= 5 ? { borderColor: 'rgba(251,191,36,.28)', borderWidth: 1, opacity: 0.96 } : undefined })) }] }; const onBrush = (event: any) => { const area = event.batch?.[0]?.areas?.[0]; if (!area?.coordRange?.length) return; const [a, b] = area.coordRange.map((n: number) => Math.max(0, Math.min(data.timeline.days.length - 1, Math.round(n)))).sort((x: number, y: number) => x - y); setRange({ start: data.timeline.days[a].date, end: data.timeline.days[b].date }); }; - return
Timeline + Insights

플레이 데이터 전용 분석 대시보드

정규화된 게임 그룹, 부드러운 영역선, 게임별 인사이트를 한 화면에서 확인합니다.

setRange((r) => ({ ...r, start: e.target.value }))}/>~ setRange((r) => ({ ...r, end: e.target.value }))}/>
{error &&
API 오류: {error}
}

연속 플레이 타임라인

{timelineGames.length ? :
선택한 기간에 플레이 기록이 없습니다.
}

게임별 누적 통계

{games.slice(0,8).map((g) => setGameId(g.game_key)}>)}
{g.display_name}{secondsToText(g.total_seconds)}{Math.round((g.share || 0) * 100)}%

요일/시간대 패턴

세션 상세

{data.sessions.sessions.slice(0,10).map((s: any) => setDrawer(s)}>)}
{s.display_name || s.process_name}{new Date(s.start_timestamp * 1000).toLocaleString()}{secondsToText(s.duration_seconds)}{s.is_active ? ' · 진행 중' : ''}

게임별 인사이트

{games.slice(0,4).map((g) =>
{g.display_name}{secondsToText(g.total_seconds)}
)}
{drawer &&
setDrawer(null)}>
}
; + return
Timeline + Insights

플레이 데이터 전용 분석 대시보드

정규화된 게임 그룹, 부드러운 영역선, 게임별 인사이트를 한 화면에서 확인합니다.

setRange((r) => ({ ...r, start: e.target.value }))}/>~ setRange((r) => ({ ...r, end: e.target.value }))}/>
{error &&
API 오류: {error}
}

연속 플레이 타임라인

{timelineGames.length ? :
선택한 기간에 플레이 기록이 없습니다.
}

게임별 누적 통계

{games.slice(0,8).map((g) => setGameId(g.game_key)}>)}
{g.display_name}{secondsToText(g.total_seconds)}{Math.round((g.share || 0) * 100)}%

요일/시간대 패턴

세션 상세

{data.sessions.sessions.slice(0,10).map((s: any) => setDrawer(s)}>)}
{s.display_name || s.process_name}{new Date(s.start_timestamp * 1000).toLocaleString()}{secondsToText(s.duration_seconds)}{s.is_active ? ' · 진행 중' : ''}

게임별 인사이트

{games.slice(0,4).map((g) =>
{g.display_name}{secondsToText(g.total_seconds)}
)}
{drawer &&
setDrawer(null)}>
}
; } diff --git a/src/api/dashboard/routes.py b/src/api/dashboard/routes.py index 1b15e981..8362e7bb 100644 --- a/src/api/dashboard/routes.py +++ b/src/api/dashboard/routes.py @@ -288,8 +288,8 @@ def _completed_session_filter(start_ts: float | None = None, end_ts: float | Non return clauses -def _query_sessions(db: Any, start_dt: dt.datetime, end_dt: dt.datetime) -> list[models.ProcessSession]: - start_ts = start_dt.timestamp() +def _query_sessions(db: Any, start_dt: dt.datetime | None, end_dt: dt.datetime) -> list[models.ProcessSession]: + start_ts = start_dt.timestamp() if start_dt is not None else None end_ts = end_dt.timestamp() query = db.query(models.ProcessSession).filter(*_completed_session_filter(start_ts, end_ts)) return query.order_by(models.ProcessSession.start_timestamp.asc()).all() @@ -353,7 +353,8 @@ def _sessions_for_range( game_id: str | None, show_unregistered: bool, ) -> tuple[dt.date, dt.date, dt.datetime, dt.datetime, list[models.ProcessSession]]: - sessions = _filter_registered(db, _query_sessions(db, start_dt, end_dt), show_unregistered) + query_start = None if start_date <= EPOCH_DATE else start_dt + sessions = _filter_registered(db, _query_sessions(db, query_start, end_dt), show_unregistered) sessions = _filter_sessions_by_game_key(sessions, game_id) start_date, end_date, start_dt, end_dt = _normalize_all_time_range(start_date, end_date, start_dt, end_dt, sessions) return start_date, end_date, start_dt, end_dt, sessions diff --git a/src/api/remote_routes.py b/src/api/remote_routes.py index 18ecb306..e1de25b1 100644 --- a/src/api/remote_routes.py +++ b/src/api/remote_routes.py @@ -20,7 +20,7 @@ from sqlalchemy.orm import Session from src.api.runtime_config import resolve_api_port -from src.core.launcher import Launcher +from src.core.launcher import Launcher, launch_target_accepts_args from src.core.remote_audit import RemoteAuditLogger from src.core.remote_pairing import RemoteDeviceRegistry from src.core.remote_debug_log import load_config as load_remote_log_config, save_config as save_remote_log_config, write_event as write_remote_log @@ -306,6 +306,15 @@ def _resolve_launch_target(process: Any, requested_mode: str | None = None) -> t return (getattr(process, "launch_path", None) or getattr(process, "monitoring_path", None), "auto") +def _resolve_launch_args(process: Any, mode: str, target: str | None) -> str | None: + if mode == "launcher" or not launch_target_accepts_args(target): + return None + if not bool(getattr(process, "launch_args_enabled", False)): + return None + value = str(getattr(process, "launch_args", "") or "").strip() + return value or None + + def _normalize_executable_path(value: str | None) -> str | None: if not value: return None @@ -855,6 +864,8 @@ def _state_fingerprint(db: Session, power_status: dict[str, Any], *, processes: "monitoring_path": getattr(process, "monitoring_path", None), "launch_path": getattr(process, "launch_path", None), "preferred_launch_type": getattr(process, "preferred_launch_type", None), + "launch_args_enabled": getattr(process, "launch_args_enabled", None), + "launch_args": getattr(process, "launch_args", None), "last_played_timestamp": last_played, "user_cycle_hours": getattr(process, "user_cycle_hours", None), "user_preset_id": getattr(process, "user_preset_id", None), @@ -1419,7 +1430,8 @@ def launch_remote_process( settings = crud.get_settings(db) launcher = launcher_factory(bool(getattr(settings, "run_as_admin", False))) - ok = bool(launcher.launch_process(target)) + launch_args = _resolve_launch_args(process, mode, target) + ok = bool(launcher.launch_process(target, args=launch_args)) command = f"process.launch.{mode}" result_status = "accepted" if ok else "failed" auditor.record( @@ -1429,7 +1441,7 @@ def launch_remote_process( target_id=getattr(process, "id", process_id), target_name=getattr(process, "name", None), target=target, - metadata={"mode": mode}, + metadata={"mode": mode, "launch_args_applied": bool(launch_args)}, ) return RemoteCommandResult( accepted=ok, diff --git a/src/core/daily_checkin.py b/src/core/daily_checkin.py index bf29c047..fd71496e 100644 --- a/src/core/daily_checkin.py +++ b/src/core/daily_checkin.py @@ -275,13 +275,25 @@ def raw_debug_json(value: Any) -> str: return json.dumps({"debug_repr": str(value)}, ensure_ascii=False, sort_keys=True) -def execute_daily_checkin(descriptor: DailyCheckInDescriptor) -> DailyCheckInAttemptResult: +def execute_daily_checkin( + descriptor: DailyCheckInDescriptor, + *, + timeout_seconds: float = 30.0, +) -> DailyCheckInAttemptResult: """Run the provider POST flow for a single game and normalize the result.""" attempted_at = time.time() if descriptor.provider == PROVIDER_HOYOLAB: - return _execute_hoyolab_daily_checkin(descriptor, attempted_at=attempted_at) + return _execute_hoyolab_daily_checkin( + descriptor, + attempted_at=attempted_at, + timeout_seconds=timeout_seconds, + ) if descriptor.provider == PROVIDER_NIKKE_BLABLALINK: - return _execute_nikke_daily_checkin(descriptor, attempted_at=attempted_at) + return _execute_nikke_daily_checkin( + descriptor, + attempted_at=attempted_at, + timeout_seconds=timeout_seconds, + ) return DailyCheckInAttemptResult( provider=descriptor.provider, game_id=descriptor.game_id, @@ -294,13 +306,25 @@ def execute_daily_checkin(descriptor: DailyCheckInDescriptor) -> DailyCheckInAtt ) -def probe_daily_checkin_status(descriptor: DailyCheckInDescriptor) -> DailyCheckInAttemptResult: +def probe_daily_checkin_status( + descriptor: DailyCheckInDescriptor, + *, + timeout_seconds: float = 30.0, +) -> DailyCheckInAttemptResult: """Read the provider's current daily check-in status without claiming.""" attempted_at = time.time() if descriptor.provider == PROVIDER_HOYOLAB: - return _probe_hoyolab_daily_checkin(descriptor, attempted_at=attempted_at) + return _probe_hoyolab_daily_checkin( + descriptor, + attempted_at=attempted_at, + timeout_seconds=timeout_seconds, + ) if descriptor.provider == PROVIDER_NIKKE_BLABLALINK: - return _probe_nikke_daily_checkin(descriptor, attempted_at=attempted_at) + return _probe_nikke_daily_checkin( + descriptor, + attempted_at=attempted_at, + timeout_seconds=timeout_seconds, + ) return DailyCheckInAttemptResult( provider=descriptor.provider, game_id=descriptor.game_id, @@ -317,11 +341,15 @@ def _execute_hoyolab_daily_checkin( descriptor: DailyCheckInDescriptor, *, attempted_at: float, + timeout_seconds: float, ) -> DailyCheckInAttemptResult: try: from src.services.hoyolab import get_hoyolab_service - results = get_hoyolab_service().claim_daily_rewards([descriptor.game_id]) + results = get_hoyolab_service().claim_daily_rewards( + [descriptor.game_id], + timeout_seconds=timeout_seconds, + ) result = results[0] if results else None if result is None: return DailyCheckInAttemptResult( @@ -371,11 +399,15 @@ def _probe_hoyolab_daily_checkin( descriptor: DailyCheckInDescriptor, *, attempted_at: float, + timeout_seconds: float, ) -> DailyCheckInAttemptResult: try: from src.services.hoyolab import get_hoyolab_service - results = get_hoyolab_service().get_daily_reward_status([descriptor.game_id]) + results = get_hoyolab_service().get_daily_reward_status( + [descriptor.game_id], + timeout_seconds=timeout_seconds, + ) result = results[0] if results else None if result is None: return DailyCheckInAttemptResult( @@ -420,11 +452,12 @@ def _execute_nikke_daily_checkin( descriptor: DailyCheckInDescriptor, *, attempted_at: float, + timeout_seconds: float, ) -> DailyCheckInAttemptResult: try: from src.services.nikke import get_nikke_service - status = get_nikke_service().claim_daily_checkin() + status = get_nikke_service().claim_daily_checkin(timeout_seconds=timeout_seconds) raw_debug = getattr(status, "raw_debug", {}) or {} return DailyCheckInAttemptResult( provider=descriptor.provider, @@ -458,11 +491,12 @@ def _probe_nikke_daily_checkin( descriptor: DailyCheckInDescriptor, *, attempted_at: float, + timeout_seconds: float, ) -> DailyCheckInAttemptResult: try: from src.services.nikke import get_nikke_service - status = get_nikke_service().get_daily_checkin_status() + status = get_nikke_service().get_daily_checkin_status(timeout_seconds=timeout_seconds) raw_debug = getattr(status, "raw_debug", {}) or {} return DailyCheckInAttemptResult( provider=descriptor.provider, diff --git a/src/core/daily_checkin_coordinator.py b/src/core/daily_checkin_coordinator.py index ee94c899..c7977d85 100644 --- a/src/core/daily_checkin_coordinator.py +++ b/src/core/daily_checkin_coordinator.py @@ -5,33 +5,33 @@ import time from typing import Any -from PyQt6.QtCore import QObject, QRunnable, QThreadPool, QTimer, pyqtSignal +from PySide6.QtCore import QObject, QRunnable, QThreadPool, QTimer, Signal, Slot +from src.api.client import BackgroundApiTransport from src.core import daily_checkin from src.core import credential_health +from src.gui.work_coordinator import retain_detached_qthreadpool +from src.core.provider_activity import provider_activity logger = logging.getLogger(__name__) class _DailyCheckInSignals(QObject): - finished = pyqtSignal(str, object) + finished = Signal(str, object) class _RunDueDailyCheckInsTask(QRunnable): - def __init__(self, data_manager, trigger: str, signals: _DailyCheckInSignals): + def __init__(self, transport: BackgroundApiTransport, trigger: str, signals: _DailyCheckInSignals): super().__init__() - self._data_manager = data_manager + self._transport = transport self._trigger = trigger self._signals = signals def run(self) -> None: payload: dict[str, Any] = {"logs": [], "skipped": [], "attempted": 0} try: - runner = getattr(self._data_manager, "run_due_daily_checkins", None) - if callable(runner): - payload = runner(trigger=self._trigger) or payload - else: - payload["error"] = "daily check-in API client is not available" + with provider_activity("daily_checkin", self._trigger): + payload = self._transport.run_due_daily_checkins(trigger=self._trigger) or payload except (KeyboardInterrupt, SystemExit): raise except Exception as exc: @@ -46,10 +46,11 @@ class DailyCheckInCoordinator(QObject): def __init__(self, data_manager, notifier, parent: QObject | None = None): super().__init__(parent) self._data_manager = data_manager + self._transport = BackgroundApiTransport(getattr(data_manager, "base_url", None)) self._notifier = notifier - self._pool = QThreadPool(self) + self._pool = QThreadPool() self._pool.setMaxThreadCount(1) - self._signals = _DailyCheckInSignals(self) + self._signals = _DailyCheckInSignals() self._signals.finished.connect(self._on_finished) self._in_flight = False self._shutting_down = False @@ -73,17 +74,25 @@ def maybe_run_periodic(self) -> None: self._last_periodic_at = now self._start_due_run("periodic") - def shutdown(self) -> None: + def shutdown(self, deadline_ms: int = 2000) -> bool: self._shutting_down = True - self._pool.waitForDone() + try: + self._signals.finished.disconnect(self._on_finished) + except (TypeError, RuntimeError): + pass + drained = self._pool.waitForDone(max(0, int(deadline_ms))) + if not drained: + retain_detached_qthreadpool(self._pool) + return drained def _start_due_run(self, trigger: str) -> None: if self._shutting_down or self._in_flight: return self._in_flight = True - task = _RunDueDailyCheckInsTask(self._data_manager, trigger, self._signals) + task = _RunDueDailyCheckInsTask(self._transport, trigger, self._signals) self._pool.start(task) + @Slot(str, object) def _on_finished(self, trigger: str, payload: object) -> None: self._in_flight = False if not isinstance(payload, dict): diff --git a/src/core/daily_checkin_singleflight.py b/src/core/daily_checkin_singleflight.py new file mode 100644 index 00000000..4f9889db --- /dev/null +++ b/src/core/daily_checkin_singleflight.py @@ -0,0 +1,161 @@ +"""Thread-owner-independent coordination helpers for daily check-in runs.""" +from __future__ import annotations + +import threading +import time +from dataclasses import dataclass, field +from typing import Callable, TypeAlias + + +DailyCheckInFlightKey: TypeAlias = tuple[str, str, str, str, float] + + +def make_daily_checkin_flight_key( + operation: object, + provider: object, + process_id: object, + game_id: object, + period_start: object, +) -> DailyCheckInFlightKey: + """Return the canonical key shared by manual and run-due entrypoints.""" + return ( + str(operation or ""), + str(provider or ""), + str(process_id or ""), + str(game_id or ""), + float(period_start), + ) + + +class DailyCheckInAlreadyInFlight(RuntimeError): + """Raised when the same daily check-in operation is already executing.""" + + def __init__(self, key: DailyCheckInFlightKey): + super().__init__("daily_checkin_in_flight") + self.key = key + self.code = "daily_checkin_in_flight" + + +@dataclass +class DailyCheckInFlightLease: + """Idempotent lease that may be released by any thread.""" + + key: DailyCheckInFlightKey + _release_callback: Callable[[DailyCheckInFlightKey], None] + _released: bool = False + _release_lock: threading.Lock = field( + default_factory=threading.Lock, + repr=False, + compare=False, + ) + + def release(self) -> None: + with self._release_lock: + if self._released: + return + self._released = True + self._release_callback(self.key) + + def __enter__(self) -> "DailyCheckInFlightLease": + return self + + def __exit__(self, exc_type, exc, traceback) -> None: + self.release() + + +class DailyCheckInSingleFlight: + """Non-blocking keyed admission controller for provider operations.""" + + def __init__(self) -> None: + self._lock = threading.Lock() + self._active: set[DailyCheckInFlightKey] = set() + + def try_acquire( + self, + operation: object, + provider: object, + process_id: object, + game_id: object, + period_start: object, + ) -> DailyCheckInFlightLease | None: + key = make_daily_checkin_flight_key( + operation, + provider, + process_id, + game_id, + period_start, + ) + with self._lock: + if key in self._active: + return None + self._active.add(key) + return DailyCheckInFlightLease(key, self._release) + + def acquire_or_raise( + self, + operation: object, + provider: object, + process_id: object, + game_id: object, + period_start: object, + ) -> DailyCheckInFlightLease: + lease = self.try_acquire( + operation, + provider, + process_id, + game_id, + period_start, + ) + if lease is None: + raise DailyCheckInAlreadyInFlight( + make_daily_checkin_flight_key( + operation, + provider, + process_id, + game_id, + period_start, + ) + ) + return lease + + def _release(self, key: DailyCheckInFlightKey) -> None: + with self._lock: + self._active.discard(key) + + def snapshot(self) -> tuple[DailyCheckInFlightKey, ...]: + with self._lock: + return tuple(sorted(self._active)) + + +def monotonic_deadline( + timeout_seconds: float, + *, + now: Callable[[], float] = time.monotonic, +) -> float: + """Create an absolute monotonic deadline for a bounded run-due batch.""" + return now() + max(float(timeout_seconds), 0.0) + + +def remaining_deadline_seconds( + deadline: float, + *, + now: Callable[[], float] = time.monotonic, +) -> float: + """Return remaining batch time without ever producing a negative value.""" + return max(float(deadline) - now(), 0.0) + + +def bounded_provider_timeout_seconds( + deadline: float, + maximum_seconds: float, + *, + now: Callable[[], float] = time.monotonic, +) -> float: + """Clamp one provider call to both its own limit and the batch deadline.""" + return min( + remaining_deadline_seconds(deadline, now=now), + max(float(maximum_seconds), 0.0), + ) + + +daily_checkin_singleflight = DailyCheckInSingleFlight() diff --git a/src/core/hoyolab_reconcile.py b/src/core/hoyolab_reconcile.py index cefcc65d..6de3fa13 100644 --- a/src/core/hoyolab_reconcile.py +++ b/src/core/hoyolab_reconcile.py @@ -3,14 +3,17 @@ import logging import time from dataclasses import dataclass -from typing import Callable, Optional +from typing import Optional -from PyQt6.QtCore import QObject, QRunnable, QThreadPool, QTimer, pyqtSignal, pyqtSlot +from PySide6.QtCore import QObject, QRunnable, QThreadPool, QTimer, Signal, Slot +from src.api.client import BackgroundApiTransport from src.core import credential_health from src.core.provider_health_persist import ProviderHealthPersistTask from src.core.process_monitor import ProcessLifecycleEvent, ProcessMonitor from src.data.data_models import ManagedProcess +from src.gui.work_coordinator import retain_detached_qthreadpool +from src.core.provider_activity import provider_activity logger = logging.getLogger(__name__) @@ -37,11 +40,11 @@ class _ReconcileJob: class _StaminaFetchSignals(QObject): - finished = pyqtSignal(str, int, int, object) + finished = Signal(str, int, int, object) class _StaminaPersistSignals(QObject): - finished = pyqtSignal(str, int, int, object) + finished = Signal(str, int, int, object) class _StaminaFetchTask(QRunnable): @@ -75,7 +78,8 @@ def run(self) -> None: payload["provider_status"] = "auth_required" payload["error"] = "HoYoLab 인증 정보가 없습니다." else: - stamina = service.get_stamina(self._game_id) + with provider_activity("hoyolab", "stamina_fetch"): + stamina = service.get_stamina(self._game_id) payload["stamina"] = stamina if stamina is not None: payload["fetched_at"] = stamina.updated_at.timestamp() @@ -114,8 +118,8 @@ def __init__( exit_timestamp: float, allow_session_correction: bool, applied_session_stamina: Optional[int], - data_manager, - should_abort: Callable[[], bool], + process_changed: bool, + transport: BackgroundApiTransport, signals: _StaminaPersistSignals, ): super().__init__() @@ -130,8 +134,8 @@ def __init__( self._exit_timestamp = exit_timestamp self._allow_session_correction = allow_session_correction self._applied_session_stamina = applied_session_stamina - self._data_manager = data_manager - self._should_abort = should_abort + self._process_changed = bool(process_changed) + self._transport = transport self._signals = signals def run(self) -> None: @@ -143,56 +147,20 @@ def run(self) -> None: "persist_succeeded": False, } try: - if self._should_abort(): - result["aborted"] = True - return - - live_process = self._data_manager.get_process_by_id(self._process_id) - if live_process is None: - result["error"] = "process missing during persistence" - return - - updated_process = ManagedProcess.from_dict(live_process.to_dict()) - process_changed = ( - updated_process.stamina_current != self._fetched_current - or updated_process.stamina_max != self._fetched_max - or updated_process.stamina_updated_at != self._fetched_at - ) - updated_process.stamina_current = self._fetched_current - updated_process.stamina_max = self._fetched_max - updated_process.stamina_updated_at = self._fetched_at - process_persist_succeeded = True - - if process_changed: - if self._should_abort(): - result["aborted"] = True - return - if hasattr(self._data_manager, "update_process_stamina"): - process_persist_succeeded = self._data_manager.update_process_stamina( - self._process_id, - self._fetched_current, - self._fetched_max, - self._fetched_at, - ) - else: - process_persist_succeeded = self._data_manager.update_process_runtime_state(updated_process) - if process_persist_succeeded: - logger.info( - "[HoYoLab] 재동기화 반영: '%s' %s/%s", - self._process_name, - self._fetched_current, - self._fetched_max, - ) - else: - logger.warning( - "[HoYoLab] 재동기화 저장 실패: '%s' %s/%s", - self._process_name, - self._fetched_current, - self._fetched_max, - ) - result["error"] = "process persistence failed" + if self._process_changed: + self._transport.update_process_stamina( + self._process_id, + self._fetched_current, + self._fetched_max, + self._fetched_at, + ) + logger.info( + "[HoYoLab] 재동기화 반영: '%s' %s/%s", + self._process_name, + self._fetched_current, + self._fetched_max, + ) - session_persist_succeeded = True if self._allow_session_correction and self._session_id is not None: recovered = int( max(0.0, self._fetched_at - self._exit_timestamp) @@ -205,32 +173,18 @@ def run(self) -> None: result["corrected_exit_current"] = corrected_exit_current if corrected_exit_current != self._applied_session_stamina: - if self._should_abort(): - result["aborted"] = True - return - session_persist_succeeded = self._data_manager.update_session_stamina( + self._transport.update_session_stamina( self._session_id, corrected_exit_current, ) - if session_persist_succeeded: - logger.info( - "[HoYoLab] 세션 보정 반영: '%s' session=%s stamina=%s", - self._process_name, - self._session_id, - corrected_exit_current, - ) - else: - logger.warning( - "[HoYoLab] 세션 보정 저장 실패: '%s' session=%s stamina=%s", - self._process_name, - self._session_id, - corrected_exit_current, - ) - result["error"] = "session persistence failed" - - result["persist_succeeded"] = ( - process_persist_succeeded and session_persist_succeeded - ) + logger.info( + "[HoYoLab] 세션 보정 반영: '%s' session=%s stamina=%s", + self._process_name, + self._session_id, + corrected_exit_current, + ) + + result["persist_succeeded"] = True except (KeyboardInterrupt, SystemExit): raise except Exception as exc: @@ -261,19 +215,20 @@ def __init__(self, data_manager, process_monitor: ProcessMonitor, notifier=None, """프로세스 lifecycle과 서버 재조회 결과를 연결할 상태와 워커를 준비합니다.""" super().__init__(parent) self._data_manager = data_manager + self._transport = BackgroundApiTransport(getattr(data_manager, "base_url", None)) self._process_monitor = process_monitor self._notifier = notifier self._lifecycle_tokens: dict[str, int] = {} self._jobs: dict[str, _ReconcileJob] = {} self._shutting_down = False - self._pool = QThreadPool(self) + self._pool = QThreadPool() self._pool.setMaxThreadCount(1) - self._health_pool = QThreadPool(self) + self._health_pool = QThreadPool() self._health_pool.setMaxThreadCount(1) - self._signals = _StaminaFetchSignals(self) + self._signals = _StaminaFetchSignals() self._signals.finished.connect(self._on_fetch_finished) - self._persist_signals = _StaminaPersistSignals(self) + self._persist_signals = _StaminaPersistSignals() self._persist_signals.finished.connect(self._on_persist_finished) def handle_process_started(self, event: ProcessLifecycleEvent) -> None: @@ -347,13 +302,27 @@ def schedule_startup_refreshes(self) -> None: self._jobs[process.id] = job self._schedule_attempt(process.id, 0) - def shutdown(self) -> None: + def shutdown(self, deadline_ms: int = 2000) -> bool: """앱 종료 시 예약된 재동기화 작업을 중단합니다.""" self._shutting_down = True for process_id in list(self._jobs): self._finish_job(process_id, "shutdown") - self._pool.waitForDone() - self._health_pool.waitForDone() + for signals, receiver in ( + (self._signals, self._on_fetch_finished), + (self._persist_signals, self._on_persist_finished), + ): + try: + signals.finished.disconnect(receiver) + except (TypeError, RuntimeError): + pass + deadline = time.monotonic() + max(0, int(deadline_ms)) / 1000.0 + primary = self._pool.waitForDone(max(0, int((deadline - time.monotonic()) * 1000))) + health = self._health_pool.waitForDone(max(0, int((deadline - time.monotonic()) * 1000))) + if not primary: + retain_detached_qthreadpool(self._pool) + if not health: + retain_detached_qthreadpool(self._health_pool) + return primary and health def _advance_lifecycle_token(self, process_id: str) -> int: """같은 프로세스의 이전 시작/종료 시퀀스를 무효화할 새 토큰을 발급합니다.""" @@ -427,7 +396,7 @@ def _start_attempt(self, process_id: str, lifecycle_token: int) -> None: ) ) - @pyqtSlot(str, int, int, object) + @Slot(str, int, int, object) def _on_fetch_finished( self, process_id: str, @@ -479,12 +448,11 @@ def _on_fetch_finished( exit_timestamp=job.exit_timestamp, allow_session_correction=job.allow_session_correction, applied_session_stamina=job.applied_session_stamina, - data_manager=self._data_manager, - should_abort=lambda pid=job.process_id, token=job.lifecycle_token, seq=job.request_seq: self._should_abort_persistence( - pid, - token, - seq, + process_changed=( + process.stamina_current != stamina.current + or process.stamina_max != stamina.max ), + transport=self._transport, signals=self._persist_signals, ) ) @@ -529,7 +497,7 @@ def _record_provider_health_from_status( self._health_pool.start( ProviderHealthPersistTask( - self._data_manager, + self._transport, payload, context="HoYoLab stamina_tracking", ) @@ -552,7 +520,7 @@ def _send_provider_health_notification(self, process: ManagedProcess, payload: d except Exception as exc: logger.debug("[HoYoLab] provider health 알림 전송 실패: %s", exc, exc_info=True) - @pyqtSlot(str, int, int, object) + @Slot(str, int, int, object) def _on_persist_finished( self, process_id: str, @@ -606,6 +574,13 @@ def _on_persist_finished( self._schedule_attempt(process_id, self.RECONCILE_INTERVAL_MS) return + signature = data.get("signature") + fetched_at = data.get("fetched_at") + if isinstance(signature, tuple) and len(signature) == 2 and isinstance(fetched_at, (int, float)): + process.stamina_current = int(signature[0]) + process.stamina_max = int(signature[1]) + process.stamina_updated_at = float(fetched_at) + corrected_exit_current = data.get("corrected_exit_current") if corrected_exit_current is not None: job.applied_session_stamina = corrected_exit_current @@ -614,7 +589,6 @@ def _on_persist_finished( self._finish_job(process_id, "startup refresh completed") return - signature = data.get("signature") if isinstance(signature, tuple) and len(signature) == 2: if job.observed_signature == signature: job.stable_hits += 1 @@ -643,17 +617,6 @@ def _on_persist_finished( self._schedule_attempt(process_id, self.RECONCILE_INTERVAL_MS) - def _should_abort_persistence(self, process_id: str, lifecycle_token: int, request_seq: int) -> bool: - """저장 작업이 더 이상 현재 job에 유효하지 않은지 확인합니다.""" - job = self._jobs.get(process_id) - return ( - self._shutting_down - or job is None - or job.lifecycle_token != lifecycle_token - or job.request_seq != request_seq - or process_id in self._process_monitor.active_monitored_processes - ) - def _finish_job(self, process_id: str, reason: str) -> None: """프로세스의 active reconcile job과 연결된 타이머를 정리하고 종료합니다.""" job = self._jobs.pop(process_id, None) diff --git a/src/core/instance_manager.py b/src/core/instance_manager.py index 745c100f..97821f60 100644 --- a/src/core/instance_manager.py +++ b/src/core/instance_manager.py @@ -1,11 +1,118 @@ # instance_manager.py import sys -from PyQt6.QtCore import QSharedMemory, QObject -from PyQt6.QtNetwork import QLocalServer, QLocalSocket -from PyQt6.QtWidgets import QMessageBox +import hashlib +import ntpath +import os +from dataclasses import dataclass +from enum import Enum, IntEnum + +from PySide6.QtCore import QSharedMemory, QObject +from PySide6.QtNetwork import QLocalServer, QLocalSocket +from PySide6.QtWidgets import QMessageBox # 애플리케이션 고유 키 (다른 애플리케이션과 충돌하지 않도록 유니크하게 설정하세요) APP_UNIQUE_KEY = "HomeworkHelper_App_UUID_v1.0_KHS_UniqueInstanceKey" +IPC_PROTOCOL_VERSION = "HHIPC1" + + +@dataclass(frozen=True) +class InstanceIdentity: + normalized_executable_path: str + digest: str + server_name: str + + +def normalize_executable_path(executable_path: str | os.PathLike[str], *, windows: bool | None = None) -> str: + """Return the stable executable identity path used by shared memory and IPC.""" + raw_path = os.fspath(executable_path) + windows = os.name == "nt" if windows is None else bool(windows) + if windows: + if os.name == "nt": + raw_path = os.path.realpath(os.path.abspath(raw_path)) + return ntpath.normcase(ntpath.normpath(ntpath.abspath(raw_path))) + return os.path.normcase(os.path.realpath(os.path.abspath(raw_path))) + + +def instance_identity( + executable_path: str | os.PathLike[str] | None = None, + *, + windows: bool | None = None, +) -> InstanceIdentity: + normalized_path = normalize_executable_path(executable_path or sys.executable, windows=windows) + digest = hashlib.sha256(normalized_path.encode("utf-8")).hexdigest()[:20] + return InstanceIdentity( + normalized_executable_path=normalized_path, + digest=digest, + server_name=f"{APP_UNIQUE_KEY}_{digest}", + ) + + +class InstanceCommand(str, Enum): + SHOW_WINDOW = "show_window" + + +class InstanceCommandResult(IntEnum): + SUCCESS = 0 + NO_RUNNING_INSTANCE = 3 + TIMEOUT = 4 + UNSAFE_TARGET = 5 + + +def encode_instance_command(command: InstanceCommand, identity: InstanceIdentity) -> bytes: + return f"{IPC_PROTOCOL_VERSION} {identity.digest} {command.value}\n".encode("utf-8") + + +def parse_instance_message(payload: bytes | str) -> tuple[str | None, InstanceCommand | None]: + """Parse one versioned, newline-delimited IPC command.""" + if isinstance(payload, bytes): + payload = payload.decode("utf-8", errors="replace") + line = payload.splitlines()[0].strip() if payload.splitlines() else payload.strip() + parts = line.split() + if len(parts) == 3 and parts[0] == IPC_PROTOCOL_VERSION: + try: + return parts[1], InstanceCommand(parts[2]) + except ValueError: + return parts[1], None + return None, None + + +def send_instance_command( + command: InstanceCommand | str, + *, + executable_path: str | os.PathLike[str] | None = None, + connect_timeout_ms: int = 500, + ack_timeout_ms: int = 1500, +) -> InstanceCommandResult: + """Send a command to the primary process and wait for its explicit ACK.""" + try: + parsed_command = command if isinstance(command, InstanceCommand) else InstanceCommand(command) + except ValueError: + return InstanceCommandResult.UNSAFE_TARGET + + identity = instance_identity(executable_path) + ipc_socket = QLocalSocket() + ipc_socket.connectToServer(identity.server_name) + if not ipc_socket.waitForConnected(connect_timeout_ms): + return InstanceCommandResult.NO_RUNNING_INSTANCE + + ipc_socket.write(encode_instance_command(parsed_command, identity)) + if not ipc_socket.waitForBytesWritten(min(ack_timeout_ms, 500)): + ipc_socket.abort() + return InstanceCommandResult.TIMEOUT + if not ipc_socket.waitForReadyRead(ack_timeout_ms): + ipc_socket.abort() + return InstanceCommandResult.TIMEOUT + + response = bytes(ipc_socket.readAll()).decode("utf-8", errors="replace").strip() + ipc_socket.disconnectFromServer() + if response == f"ack:{identity.digest}:{parsed_command.value}": + return InstanceCommandResult.SUCCESS + if response == "error:unsafe_target": + return InstanceCommandResult.UNSAFE_TARGET + if response.startswith("ack:"): + return InstanceCommandResult.UNSAFE_TARGET + return InstanceCommandResult.TIMEOUT + class SingleInstanceApplication(QObject): """ @@ -14,7 +121,12 @@ class SingleInstanceApplication(QObject): """ _instance_manager_singleton = None # 클래스 레벨에서 싱글턴 인스턴스 관리 (선택적) - def __init__(self, application_name: str = "Application"): + def __init__( + self, + application_name: str = "Application", + *, + executable_path: str | os.PathLike[str] | None = None, + ): super().__init__() # 이 클래스의 인스턴스는 애플리케이션 전체에서 하나만 존재해야 함 if SingleInstanceApplication._instance_manager_singleton is not None: @@ -25,10 +137,12 @@ def __init__(self, application_name: str = "Application"): SingleInstanceApplication._instance_manager_singleton = self self.application_name = application_name - self._shared_memory = QSharedMemory(APP_UNIQUE_KEY) + self._identity = instance_identity(executable_path) + self._executable_path = self._identity.normalized_executable_path + self._shared_memory = QSharedMemory(self._identity.server_name) self._local_server = None self._main_window_ref = None # 활성화할 메인 윈도우 참조 - self._active_client_socket = None # 서버에 연결된 클라이언트 소켓 참조 + self._active_client_sockets = set() def is_primary_instance(self) -> bool: """이것이 주 인스턴스인지 확인합니다. 공유 메모리를 연결하거나 생성합니다.""" @@ -59,21 +173,14 @@ def is_primary_instance(self) -> bool: def signal_existing_instance_and_exit(self): """이미 실행 중인 주 인스턴스에 활성화 신호를 보내고 현재 인스턴스를 종료합니다.""" print(f"{self.application_name}: 이미 실행 중인 인스턴스에 활성화 요청을 보냅니다...") - ipc_socket = QLocalSocket() - # 서버 이름은 공유 메모리 키와 동일하게 사용 - ipc_socket.connectToServer(APP_UNIQUE_KEY) - - # 연결 및 메시지 전송 (타임아웃 설정) - if ipc_socket.waitForConnected(500): # 0.5초 - print("IPC 소켓 연결 성공. 'show_window' 메시지 전송 중...") - ipc_socket.write(b"show_window\n") # 간단한 활성화 메시지 - if not ipc_socket.waitForBytesWritten(100): # 0.1초 - print(f"IPC 메시지 전송 실패: {ipc_socket.errorString()}") - ipc_socket.flush() # 데이터 즉시 전송 보장 - ipc_socket.disconnectFromServer() + result = send_instance_command( + InstanceCommand.SHOW_WINDOW, + executable_path=self._executable_path, + ) + if result == InstanceCommandResult.SUCCESS: print("활성화 요청 전송 완료.") else: - print(f"기존 인스턴스의 IPC 서버에 연결 실패: {ipc_socket.errorString()}") + print(f"기존 인스턴스의 IPC 요청 실패: {result.name}") QMessageBox.warning(None, f"{self.application_name} - 실행 중", "프로그램이 이미 실행 중이지만, 창을 자동으로 활성화할 수 없었습니다.\n" "이미 실행된 창을 직접 찾아주세요.") @@ -90,13 +197,13 @@ def start_ipc_server(self, main_window_to_activate): self._local_server.newConnection.connect(self._handle_ipc_new_connection) # 서버 리슨 시도 - if not self._local_server.listen(APP_UNIQUE_KEY): + if not self._local_server.listen(self._identity.server_name): # 리슨 실패 시 (예: 이전 비정상 종료로 인한 소켓 파일 문제) print(f"IPC 서버 listen 실패 (1차): {self._local_server.errorString()}") # 기존 서버 소켓 파일 제거 시도 (주로 Unix 계열에서 효과적) - if QLocalServer.removeServer(APP_UNIQUE_KEY): + if QLocalServer.removeServer(self._identity.server_name): print("기존 IPC 서버 소켓 파일 제거 시도 후 재시도...") - if self._local_server.listen(APP_UNIQUE_KEY): + if self._local_server.listen(self._identity.server_name): print("IPC 서버 listen 성공 (재시도 후).") return True # 재시도도 실패하거나 removeServer가 효과 없는 경우 (예: Windows) @@ -123,31 +230,56 @@ def _handle_ipc_new_connection(self): socket.deleteLater() # 소켓 자원 정리 return - # 기존 연결이 있다면 정리 후 새 연결 처리 (일반적으로는 동시에 여러 연결이 오지 않음) - if self._active_client_socket and self._active_client_socket.isOpen(): - self._active_client_socket.abort() # 기존 연결 강제 종료 - self._active_client_socket.deleteLater() - - self._active_client_socket = self._local_server.nextPendingConnection() - if self._active_client_socket: - print("IPC: 새 연결 수신됨. 메인 창 활성화를 시도합니다.") - # 이 예제에서는 연결 자체를 활성화 신호로 간주 (메시지 내용 확인 안 함) - # 필요시: self._active_client_socket.readyRead.connect(self._read_ipc_message) - if hasattr(self._main_window_ref, 'activate_and_show') and \ - callable(self._main_window_ref.activate_and_show): - self._main_window_ref.activate_and_show() + while self._local_server.hasPendingConnections(): + socket = self._local_server.nextPendingConnection() + if not socket: + continue + socket._hh_received_command = False + socket._hh_command_buffer = bytearray() + self._active_client_sockets.add(socket) + socket.readyRead.connect(lambda active=socket: self._read_ipc_message(active)) + socket.disconnected.connect(lambda active=socket: self._finish_ipc_connection(active)) + if socket.bytesAvailable(): + self._read_ipc_message(socket) + + def _write_ipc_response(self, socket, response: str): + socket.write((response + "\n").encode("utf-8")) + socket.flush() + + def _read_ipc_message(self, socket): + if socket not in self._active_client_sockets or socket._hh_received_command: + return + socket._hh_command_buffer.extend(bytes(socket.readAll())) + if b"\n" not in socket._hh_command_buffer: + return + socket._hh_received_command = True + message_identity, command = parse_instance_message(bytes(socket._hh_command_buffer)) + if message_identity != self._identity.digest: + self._write_ipc_response(socket, "error:unsafe_target") + socket.disconnectFromServer() + return + if command == InstanceCommand.SHOW_WINDOW: + callback = getattr(self._main_window_ref, "activate_and_show", None) + if not callable(callback): + self._write_ipc_response(socket, "error:unsafe_target") else: - print("오류: _main_window_ref에 activate_and_show 메소드가 없습니다.") - - self._active_client_socket.disconnectFromServer() - self._active_client_socket.deleteLater() # 소켓 자원 정리 - self._active_client_socket = None # 참조 해제 + callback() + self._write_ipc_response(socket, f"ack:{self._identity.digest}:show_window") + else: + self._write_ipc_response(socket, "error:unsafe_target") + socket.disconnectFromServer() + + def _finish_ipc_connection(self, socket): + if socket in self._active_client_sockets: + self._active_client_sockets.remove(socket) + socket.deleteLater() def cleanup(self): """애플리케이션 종료 시 IPC 서버 및 공유 메모리 관련 리소스를 정리합니다.""" # 중복 호출 방지 플래그 확인 if hasattr(self, '_cleanup_done') and self._cleanup_done: return + self._cleanup_done = True print("InstanceManager: 리소스 정리 시작...") @@ -156,6 +288,11 @@ def cleanup(self): if self._local_server and self._local_server.isListening(): self._local_server.close() print("IPC 서버가 닫혔습니다.") + for socket in tuple(self._active_client_sockets): + socket._hh_received_command = True + socket.abort() + socket.deleteLater() + self._active_client_sockets.clear() except RuntimeError: # Qt 객체가 이미 삭제된 경우 무시 print("IPC 서버가 이미 정리되었습니다.") @@ -175,8 +312,8 @@ def cleanup(self): # 만약 이 인스턴스가 공유 메모리를 create 했다면, QSharedMemory 객체가 소멸될 때 OS 레벨의 세그먼트도 정리됨 (참조 카운트 기반) print("InstanceManager: 리소스 정리 완료.") - # 중복 호출 방지 플래그 설정 - self._cleanup_done = True + if SingleInstanceApplication._instance_manager_singleton is self: + SingleInstanceApplication._instance_manager_singleton = None def run_with_single_instance_check(application_name: str, main_app_start_callback): @@ -197,4 +334,4 @@ def run_with_single_instance_check(application_name: str, main_app_start_callbac # (또는 MainWindow의 initiate_quit_sequence에서 cleanup 호출) else: # 이미 실행 중인 인스턴스가 있으므로, 해당 인스턴스에 신호를 보내고 현재 인스턴스는 종료 - instance_manager.signal_existing_instance_and_exit() \ No newline at end of file + instance_manager.signal_existing_instance_and_exit() diff --git a/src/core/launcher.py b/src/core/launcher.py index 03efef66..ab62725f 100644 --- a/src/core/launcher.py +++ b/src/core/launcher.py @@ -4,10 +4,58 @@ import os import ctypes import configparser # .url 파일 파싱을 위해 추가 +import re from typing import Optional, Tuple # 타입 힌트를 위해 추가 import psutil # 프로세스 관리를 위해 추가 from src.utils.launcher_utils import should_restart_launcher + +def launch_target_accepts_args(launch_command: str | None) -> bool: + """Return True when launch arguments can be passed directly to the target.""" + if not launch_command: + return False + value = str(launch_command).strip() + lower_value = value.lower() + if lower_value.endswith((".lnk", ".url")): + return False + has_uri_scheme = re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*:", value) + is_windows_drive_path = re.match(r"^[a-zA-Z]:", value) + if has_uri_scheme and not is_windows_drive_path: + return False + return True + + +def _windows_launch_args(args: str | list[str] | tuple[str, ...] | None) -> str | None: + if args is None: + return None + if isinstance(args, str): + value = args.strip() + else: + value = subprocess.list2cmdline([str(item) for item in args if str(item)]) + return value or None + + +def _posix_launch_args(args: str | list[str] | tuple[str, ...] | None) -> list[str]: + if args is None: + return [] + if isinstance(args, str): + value = args.strip() + return shlex.split(value, posix=True) if value else [] + return [str(item) for item in args if str(item)] + + +def _posix_launch_command_args(launch_command: str, extra_args: list[str]) -> list[str]: + command_value = str(launch_command).strip() + if os.path.exists(command_value): + return [command_value, *extra_args] + return [*shlex.split(command_value, posix=True), *extra_args] + + +def _redacted_posix_launch_args(popen_args: list[str], extra_args: list[str]) -> list[str]: + if not extra_args: + return popen_args + return [*popen_args[: -len(extra_args)], ""] + class Launcher: def __init__(self, run_as_admin: bool = False): self.run_as_admin = run_as_admin @@ -678,12 +726,19 @@ def _find_and_launch_game_launcher_as_admin(self, protocol_url: str) -> bool: print(f" 게임 런처 직접 실행 중 오류: {e}") return False - def launch_process(self, launch_command: str) -> bool: + def launch_process(self, launch_command: str, args: str | list[str] | tuple[str, ...] | None = None) -> bool: if not launch_command: print("오류: 실행할 경로 또는 명령어가 제공되지 않았습니다.") return False print(f"다음 명령어로 프로세스 실행 시도: {launch_command}") + accepts_args = launch_target_accepts_args(launch_command) + requested_args = _windows_launch_args(args) + launch_args = requested_args if accepts_args else None + if requested_args and not accepts_args: + print(" 실행 인자는 .lnk/.url/protocol 대상에 적용되지 않아 무시합니다.") + elif launch_args: + print(" 추가 실행 인자: ") try: # 1. .url 파일 처리 (Windows 우선) @@ -884,9 +939,10 @@ def launch_process(self, launch_command: str) -> bool: else: print(f" 일반 사용자 권한으로 실행합니다.") - print(f" ShellExecuteW 호출 시도: verb='{verb}', file='{launch_command}', params=None") + params_for_log = "" if launch_args else None + print(f" ShellExecuteW 호출 시도: verb='{verb}', file='{launch_command}', params={params_for_log!r}") - ret = shell32.ShellExecuteW(None, verb, launch_command, None, None, 1) # SW_SHOWNORMAL = 1 + ret = shell32.ShellExecuteW(None, verb, launch_command, launch_args, None, 1) # SW_SHOWNORMAL = 1 if ret > 32: print(f" '{launch_command}' 실행을 ShellExecuteW ({verb})로 요청했습니다. (반환 값: {ret})") return True @@ -901,9 +957,10 @@ def launch_process(self, launch_command: str) -> bool: return False else: try: - args = shlex.split(launch_command, posix=True) - subprocess.Popen(args) - print(f" 프로세스 실행 시도 완료 (비 Windows): {args}") + extra_args = _posix_launch_args(args) if accepts_args else [] + popen_args = _posix_launch_command_args(launch_command, extra_args) + subprocess.Popen(popen_args) + print(f" 프로세스 실행 시도 완료 (비 Windows): {_redacted_posix_launch_args(popen_args, extra_args)}") return True except Exception as e_shlex: print(f" 비 Windows 환경 shlex.split 또는 Popen 오류: {e_shlex}") @@ -914,4 +971,4 @@ def launch_process(self, launch_command: str) -> bool: return False except Exception as e: # 그 외 예외 처리 print(f"프로세스 실행 중 예기치 않은 예외 발생: {e}") - return False \ No newline at end of file + return False diff --git a/src/core/notifier.py b/src/core/notifier.py index 38daf4b9..8be46a5b 100644 --- a/src/core/notifier.py +++ b/src/core/notifier.py @@ -26,7 +26,7 @@ def __init__(self, _application_name: str): raise RuntimeError("windows_toasts is unavailable on this platform") from typing import Optional, Callable, Dict import urllib.parse -from PyQt6.QtCore import QObject, pyqtSignal +from PySide6.QtCore import QObject, Signal class NotificationSignalBridge(QObject): """ @@ -35,7 +35,7 @@ class NotificationSignalBridge(QObject): 시그널/슬롯 메커니즘을 통해 메인 스레드로 안전하게 전달합니다. """ # 메인 스레드에서 처리할 알림 활성화 이벤트 시그널 - notification_activated = pyqtSignal(str, str) # (task_id, source) + notification_activated = Signal(str, str) # (task_id, source) def on_notification_callback(self, event_args: ToastActivatedEventArgs): """ diff --git a/src/core/process_monitor.py b/src/core/process_monitor.py index cb06b930..2094bb31 100644 --- a/src/core/process_monitor.py +++ b/src/core/process_monitor.py @@ -89,6 +89,88 @@ class ProcessMonitorTickResult: stopped: List[ProcessLifecycleEvent] = field(default_factory=list) +@dataclass(frozen=True, slots=True) +class DetectedRuntimeProcess: + """GUI thread로 전달할 수 있는 불변 OS 프로세스 관측값입니다.""" + + process_id: str + pid: int + executable: str + create_time: float + + +@dataclass(frozen=True, slots=True) +class ProcessScanSnapshot: + detected: tuple[DetectedRuntimeProcess, ...] + observed_at: float + + +@dataclass(frozen=True, slots=True) +class ProcessScanTarget: + process_id: str + monitoring_path: str + + +def _normalize_process_path(path: Optional[str]) -> Optional[str]: + if not path: + return None + try: + return os.path.normcase(os.path.abspath(path)) + except Exception: + return path + + +def detect_running_process_ids(targets: tuple[ProcessScanTarget, ...]) -> set[str]: + """불변 대상 목록만으로 현재 실행 중인 관리 프로세스 ID를 찾습니다.""" + running_exes: set[str] = set() + for proc in psutil.process_iter(["exe"]): + try: + exe_path = _normalize_process_path(proc.info["exe"]) + if exe_path: + running_exes.add(exe_path) + except (psutil.NoSuchProcess, psutil.AccessDenied, TypeError, FileNotFoundError): + continue + return { + target.process_id + for target in targets + if (normalized := _normalize_process_path(target.monitoring_path)) and normalized in running_exes + } + + +def scan_running_processes(targets: tuple[ProcessScanTarget, ...]) -> ProcessScanSnapshot: + """DB/provider/cache 참조 없이 OS 프로세스 표를 불변 snapshot으로 읽습니다.""" + managed_paths = { + normalized: target.process_id + for target in targets + if (normalized := _normalize_process_path(target.monitoring_path)) + } + detected_by_id: dict[str, DetectedRuntimeProcess] = {} + for proc in psutil.process_iter(["pid", "exe", "create_time"]): + try: + executable = _normalize_process_path(proc.info.get("exe")) + process_id = managed_paths.get(executable) + if process_id is None or process_id in detected_by_id: + continue + detected_by_id[process_id] = DetectedRuntimeProcess( + process_id=process_id, + pid=int(proc.info.get("pid") or proc.pid), + executable=str(executable), + create_time=float(proc.info.get("create_time") or proc.create_time()), + ) + except ( + psutil.NoSuchProcess, + psutil.AccessDenied, + TypeError, + ValueError, + FileNotFoundError, + ): + continue + return ProcessScanSnapshot( + detected=tuple(detected_by_id[key] for key in sorted(detected_by_id)), + observed_at=time.time(), + ) + + class ProcessMonitor: def __init__(self, data_manager: ProcessesDataPort): """실행 중 프로세스 캐시를 초기화합니다.""" @@ -216,29 +298,29 @@ def _persist_resource_state(self, process: ManagedProcess) -> bool: def _normalize_path(self, path: Optional[str]) -> Optional[str]: """실행 파일 경로를 비교 가능한 절대 경로 형태로 정규화합니다.""" - if not path: - return None - try: - return os.path.normcase(os.path.abspath(path)) - except Exception: - return path + return _normalize_process_path(path) + + def process_scan_targets(self) -> tuple[ProcessScanTarget, ...]: + """GUI 소유 모델에서 스캔에 필요한 값만 확정해 반환합니다.""" + return tuple( + ProcessScanTarget(str(process.id), str(process.monitoring_path or "")) + for process in tuple(self.data_manager.managed_processes) + ) - def detect_running_process_ids(self) -> set[str]: + def detect_running_process_ids( + self, + targets: tuple[ProcessScanTarget, ...] | None = None, + ) -> set[str]: """Return managed process IDs currently visible in the OS process table.""" - running_exes: set[str] = set() - for proc in psutil.process_iter(['exe']): - try: - exe_path = self._normalize_path(proc.info['exe']) - if exe_path: - running_exes.add(exe_path) - except (psutil.NoSuchProcess, psutil.AccessDenied, TypeError, FileNotFoundError): - continue - running_ids: set[str] = set() - for managed_proc in self.data_manager.managed_processes: - normalized_monitoring_path = self._normalize_path(managed_proc.monitoring_path) - if normalized_monitoring_path and normalized_monitoring_path in running_exes: - running_ids.add(managed_proc.id) - return running_ids + scan_targets = targets if targets is not None else self.process_scan_targets() + return detect_running_process_ids(scan_targets) + + def scan_running_processes( + self, + targets: tuple[ProcessScanTarget, ...], + ) -> ProcessScanSnapshot: + """DB/provider/cache를 건드리지 않고 OS 프로세스 표만 읽습니다.""" + return scan_running_processes(targets) def check_and_update_statuses(self) -> ProcessMonitorTickResult: """시스템 프로세스 스냅샷과 내부 캐시를 비교해 시작/종료 이벤트를 기록합니다.""" diff --git a/src/core/provider_activity.py b/src/core/provider_activity.py new file mode 100644 index 00000000..662fcc0f --- /dev/null +++ b/src/core/provider_activity.py @@ -0,0 +1,28 @@ +"""In-memory provider overlap logging without admission or persistence.""" +from __future__ import annotations + +from contextlib import contextmanager +import logging +import threading +from typing import Iterator + +logger = logging.getLogger(__name__) + +_lock = threading.Lock() +_active = 0 + + +@contextmanager +def provider_activity(provider: str, operation: str) -> Iterator[None]: + global _active + with _lock: + _active += 1 + overlap = _active + logger.info("provider work start: provider=%s operation=%s overlap=%s", provider, operation, overlap) + try: + yield + finally: + with _lock: + _active = max(0, _active - 1) + remaining = _active + logger.info("provider work end: provider=%s operation=%s overlap=%s", provider, operation, remaining) diff --git a/src/core/provider_health_persist.py b/src/core/provider_health_persist.py index c04cb0a2..21a550e0 100644 --- a/src/core/provider_health_persist.py +++ b/src/core/provider_health_persist.py @@ -4,7 +4,7 @@ import logging from typing import Any -from PyQt6.QtCore import QRunnable +from PySide6.QtCore import QRunnable logger = logging.getLogger(__name__) @@ -13,26 +13,14 @@ class ProviderHealthPersistTask(QRunnable): """Persist provider credential health without blocking the Qt main thread.""" - def __init__(self, data_manager: Any, payload: dict[str, Any], *, context: str): + def __init__(self, transport: Any, payload: dict[str, Any], *, context: str): super().__init__() - self._data_manager = data_manager + self._transport = transport self.payload = dict(payload) self._context = context def run(self) -> None: - updater = getattr(self._data_manager, "update_provider_credential_health", None) - if not callable(updater): - return try: - updater( - self.payload["provider"], - self.payload["status"], - reason=self.payload["reason"], - message=self.payload["message"], - source=self.payload["source"], - process_id=self.payload["process_id"], - game_id=self.payload["game_id"], - detected_at=self.payload["detected_at"], - ) + self._transport.update_provider_credential_health(self.payload) except Exception as exc: # pragma: no cover - defensive around UI background persistence logger.warning("%s provider health 저장 실패: %s", self._context, exc, exc_info=True) diff --git a/src/core/resource_reconcile.py b/src/core/resource_reconcile.py index cc5683fa..af657ebd 100644 --- a/src/core/resource_reconcile.py +++ b/src/core/resource_reconcile.py @@ -4,10 +4,11 @@ import logging import time from dataclasses import dataclass -from typing import Callable, Optional +from typing import Optional -from PyQt6.QtCore import QObject, QRunnable, QThreadPool, QTimer, pyqtSignal, pyqtSlot +from PySide6.QtCore import QObject, QRunnable, QThreadPool, QTimer, Signal, Slot +from src.api.client import BackgroundApiTransport from src.core.process_monitor import ProcessLifecycleEvent, ProcessMonitor from src.core import credential_health from src.core.provider_health_persist import ProviderHealthPersistTask @@ -18,6 +19,8 @@ clamp_percent, is_nikke_outpost_resource, ) +from src.gui.work_coordinator import retain_detached_qthreadpool +from src.core.provider_activity import provider_activity logger = logging.getLogger(__name__) @@ -45,11 +48,11 @@ class _ResourceReconcileJob: class _ResourceFetchSignals(QObject): - finished = pyqtSignal(str, int, int, object) + finished = Signal(str, int, int, object) class _ResourcePersistSignals(QObject): - finished = pyqtSignal(str, int, int, object) + finished = Signal(str, int, int, object) class _ResourceFetchTask(QRunnable): @@ -76,7 +79,8 @@ def run(self) -> None: if is_nikke_outpost_resource(self._provider, self._resource_key): from src.services.nikke import get_nikke_service - snapshot = get_nikke_service().get_outpost_storage() + with provider_activity(self._provider, self._resource_key): + snapshot = get_nikke_service().get_outpost_storage() payload["snapshot"] = snapshot payload["fetched_at"] = snapshot.updated_at.timestamp() else: @@ -114,8 +118,8 @@ def __init__( exit_timestamp: float, allow_session_correction: bool, applied_session_percent: Optional[float], - data_manager, - should_abort: Callable[[], bool], + process_changed: bool, + transport: BackgroundApiTransport, signals: _ResourcePersistSignals, ): super().__init__() @@ -131,8 +135,8 @@ def __init__( self._exit_timestamp = exit_timestamp self._allow_session_correction = allow_session_correction self._applied_session_percent = applied_session_percent - self._data_manager = data_manager - self._should_abort = should_abort + self._process_changed = bool(process_changed) + self._transport = transport self._signals = signals def run(self) -> None: @@ -140,63 +144,28 @@ def run(self) -> None: result = { "signature": signature, "fetched_at": self._fetched_at, + "resource_label": self._fetched_label, + "resource_status": self._fetched_status, "corrected_exit_percent": self._applied_session_percent, "aborted": False, "persist_succeeded": False, } try: - if self._should_abort(): - result["aborted"] = True - return - - live_process = self._data_manager.get_process_by_id(self._process_id) - if live_process is None: - result["error"] = "process missing during persistence" - return - - updated_process = ManagedProcess.from_dict(live_process.to_dict()) - process_changed = ( - updated_process.resource_percent != self._fetched_percent - or updated_process.resource_updated_at != self._fetched_at - or updated_process.resource_status != self._fetched_status - or updated_process.resource_label != self._fetched_label - ) - updated_process.resource_percent = self._fetched_percent - updated_process.resource_updated_at = self._fetched_at - updated_process.resource_status = self._fetched_status - updated_process.resource_label = self._fetched_label - process_persist_succeeded = True - - if process_changed: - if self._should_abort(): - result["aborted"] = True - return - if hasattr(self._data_manager, "update_process_resource"): - process_persist_succeeded = self._data_manager.update_process_resource( - self._process_id, - self._fetched_percent, - self._fetched_at, - self._fetched_status, - self._fetched_label, - ) - else: - process_persist_succeeded = self._data_manager.update_process_runtime_state(updated_process) - if process_persist_succeeded: - logger.info( - "[Resource] 재동기화 반영: '%s' %s %.1f%%", - self._process_name, - self._fetched_label, - self._fetched_percent, - ) - else: - logger.warning( - "[Resource] 재동기화 저장 실패: '%s' %.1f%%", - self._process_name, - self._fetched_percent, - ) - result["error"] = "process persistence failed" + if self._process_changed: + self._transport.update_process_resource( + self._process_id, + self._fetched_percent, + self._fetched_at, + self._fetched_status, + self._fetched_label, + ) + logger.info( + "[Resource] 재동기화 반영: '%s' %s %.1f%%", + self._process_name, + self._fetched_label, + self._fetched_percent, + ) - session_persist_succeeded = True if self._allow_session_correction and self._session_id is not None: recovered = ( max(0.0, self._fetched_at - self._exit_timestamp) @@ -207,33 +176,18 @@ def run(self) -> None: result["corrected_exit_percent"] = corrected_exit_percent if corrected_exit_percent is not None and corrected_exit_percent != self._applied_session_percent: - if self._should_abort(): - result["aborted"] = True - return - if hasattr(self._data_manager, "update_session_resource"): - session_persist_succeeded = self._data_manager.update_session_resource( - self._session_id, - corrected_exit_percent, - ) - else: - session_persist_succeeded = False - if session_persist_succeeded: - logger.info( - "[Resource] 세션 보정 반영: '%s' session=%s percent=%.1f%%", - self._process_name, - self._session_id, - corrected_exit_percent, - ) - else: - logger.warning( - "[Resource] 세션 보정 저장 실패: '%s' session=%s percent=%s", - self._process_name, - self._session_id, - corrected_exit_percent, - ) - result["error"] = "session persistence failed" - - result["persist_succeeded"] = process_persist_succeeded and session_persist_succeeded + self._transport.update_session_resource( + self._session_id, + corrected_exit_percent, + ) + logger.info( + "[Resource] 세션 보정 반영: '%s' session=%s percent=%.1f%%", + self._process_name, + self._session_id, + corrected_exit_percent, + ) + + result["persist_succeeded"] = True except (KeyboardInterrupt, SystemExit): raise except Exception as exc: @@ -262,19 +216,20 @@ class NikkeResourceReconcileCoordinator(QObject): def __init__(self, data_manager, process_monitor: ProcessMonitor, notifier=None, parent: Optional[QObject] = None): super().__init__(parent) self._data_manager = data_manager + self._transport = BackgroundApiTransport(getattr(data_manager, "base_url", None)) self._process_monitor = process_monitor self._notifier = notifier self._lifecycle_tokens: dict[str, int] = {} self._jobs: dict[str, _ResourceReconcileJob] = {} self._shutting_down = False - self._pool = QThreadPool(self) + self._pool = QThreadPool() self._pool.setMaxThreadCount(1) - self._health_pool = QThreadPool(self) + self._health_pool = QThreadPool() self._health_pool.setMaxThreadCount(1) - self._signals = _ResourceFetchSignals(self) + self._signals = _ResourceFetchSignals() self._signals.finished.connect(self._on_fetch_finished) - self._persist_signals = _ResourcePersistSignals(self) + self._persist_signals = _ResourcePersistSignals() self._persist_signals.finished.connect(self._on_persist_finished) def handle_process_started(self, event: ProcessLifecycleEvent) -> None: @@ -348,12 +303,26 @@ def schedule_startup_refreshes(self) -> None: self._jobs[process.id] = job self._schedule_attempt(process.id, 0) - def shutdown(self) -> None: + def shutdown(self, deadline_ms: int = 2000) -> bool: self._shutting_down = True for process_id in list(self._jobs): self._finish_job(process_id, "shutdown") - self._pool.waitForDone() - self._health_pool.waitForDone() + for signals, receiver in ( + (self._signals, self._on_fetch_finished), + (self._persist_signals, self._on_persist_finished), + ): + try: + signals.finished.disconnect(receiver) + except (TypeError, RuntimeError): + pass + deadline = time.monotonic() + max(0, int(deadline_ms)) / 1000.0 + primary = self._pool.waitForDone(max(0, int((deadline - time.monotonic()) * 1000))) + health = self._health_pool.waitForDone(max(0, int((deadline - time.monotonic()) * 1000))) + if not primary: + retain_detached_qthreadpool(self._pool) + if not health: + retain_detached_qthreadpool(self._health_pool) + return primary and health def _advance_lifecycle_token(self, process_id: str) -> int: next_token = self._lifecycle_tokens.get(process_id, 0) + 1 @@ -423,7 +392,7 @@ def _start_attempt(self, process_id: str, lifecycle_token: int) -> None: ) ) - @pyqtSlot(str, int, int, object) + @Slot(str, int, int, object) def _on_fetch_finished(self, process_id: str, lifecycle_token: int, request_seq: int, payload: object) -> None: job = self._jobs.get(process_id) if ( @@ -473,12 +442,12 @@ def _on_fetch_finished(self, process_id: str, lifecycle_token: int, request_seq: exit_timestamp=job.exit_timestamp, allow_session_correction=job.allow_session_correction, applied_session_percent=job.applied_session_percent, - data_manager=self._data_manager, - should_abort=lambda pid=job.process_id, token=job.lifecycle_token, seq=job.request_seq: self._should_abort_persistence( - pid, - token, - seq, + process_changed=( + process.resource_percent != normalized_percent + or process.resource_status != status + or process.resource_label != (getattr(snapshot, "label", None) or NIKKE_OUTPOST_LABEL) ), + transport=self._transport, signals=self._persist_signals, ) ) @@ -518,7 +487,7 @@ def _record_provider_health_from_snapshot(self, process: ManagedProcess, snapsho self._health_pool.start( ProviderHealthPersistTask( - self._data_manager, + self._transport, payload, context="NIKKE resource_tracking", ) @@ -541,7 +510,7 @@ def _send_provider_health_notification(self, process: ManagedProcess, payload: d except Exception as exc: logger.debug("[Resource] provider health 알림 전송 실패: %s", exc, exc_info=True) - @pyqtSlot(str, int, int, object) + @Slot(str, int, int, object) def _on_persist_finished(self, process_id: str, lifecycle_token: int, request_seq: int, payload: object) -> None: job = self._jobs.get(process_id) if ( @@ -588,6 +557,14 @@ def _on_persist_finished(self, process_id: str, lifecycle_token: int, request_se self._schedule_attempt(process_id, self.RECONCILE_INTERVAL_MS) return + signature = data.get("signature") + fetched_at = data.get("fetched_at") + if isinstance(signature, tuple) and len(signature) == 1 and isinstance(fetched_at, (int, float)): + process.resource_percent = float(signature[0]) + process.resource_updated_at = float(fetched_at) + process.resource_status = str(data.get("resource_status") or "ok") + process.resource_label = str(data.get("resource_label") or NIKKE_OUTPOST_LABEL) + corrected_exit_percent = data.get("corrected_exit_percent") if corrected_exit_percent is not None: job.applied_session_percent = corrected_exit_percent @@ -596,7 +573,6 @@ def _on_persist_finished(self, process_id: str, lifecycle_token: int, request_se self._finish_job(process_id, "startup refresh completed") return - signature = data.get("signature") if isinstance(signature, tuple) and len(signature) == 1: if job.observed_signature == signature: job.stable_hits += 1 @@ -621,16 +597,6 @@ def _on_persist_finished(self, process_id: str, lifecycle_token: int, request_se self._schedule_attempt(process_id, self.RECONCILE_INTERVAL_MS) - def _should_abort_persistence(self, process_id: str, lifecycle_token: int, request_seq: int) -> bool: - job = self._jobs.get(process_id) - return ( - self._shutting_down - or job is None - or job.lifecycle_token != lifecycle_token - or job.request_seq != request_seq - or process_id in self._process_monitor.active_monitored_processes - ) - def _finish_job(self, process_id: str, reason: str) -> None: job = self._jobs.pop(process_id, None) if job is None: diff --git a/src/core/tailscale.py b/src/core/tailscale.py index b6651455..1d48fa49 100644 --- a/src/core/tailscale.py +++ b/src/core/tailscale.py @@ -312,6 +312,8 @@ def _hidden_subprocess_kwargs() -> dict[str, Any]: def _run_subprocess(args: Sequence[str], *, timeout_seconds: float, runner=None): kwargs = { "text": True, + "encoding": "utf-8", + "errors": "replace", "stdout": subprocess.PIPE, "stderr": subprocess.PIPE, "timeout": timeout_seconds, diff --git a/src/data/beholder.py b/src/data/beholder.py index 37e30b77..70647684 100644 --- a/src/data/beholder.py +++ b/src/data/beholder.py @@ -29,6 +29,7 @@ MAX_UNEVIDENCED_SESSION_SECONDS = 7 * 24 * 60 * 60 MAX_HEARTBEAT_GAP_SECONDS = 10 * 60 LEGACY_OPEN_SESSION_SECONDS = 24 * 60 * 60 +MAX_LAUNCH_ARGS_LENGTH = 512 GLOBAL_SETTINGS_TABLE = "global_settings" MANAGED_PROCESSES_TABLE = "managed_processes" WEB_SHORTCUTS_TABLE = "web_shortcuts" @@ -143,6 +144,8 @@ "obs_watch_output_dir": "OBS 출력 폴더 감시", "obs_recording_output_dir": "OBS 녹화 저장 폴더", "preferred_launch_type": "실행 방식", + "launch_args_enabled": "직접 실행 인자 사용", + "launch_args": "직접 실행 인자", "user_cycle_hours": "반복 주기", "default_volume": "기본 볼륨", "last_played_timestamp": "마지막 플레이 시각", @@ -225,16 +228,10 @@ def _operation_label(kind: str | None) -> str: def _compose_user_summary(incident: models.BeholderIncident) -> str: - actor = _actor_label(getattr(incident, "actor", None)) operation = _operation_label(getattr(incident, "operation_kind", None)) - target = getattr(incident, "target_summary", None) or "대상 데이터" - current = getattr(incident, "current_state_summary", None) or "현재 상태 정보 없음" - proposed = getattr(incident, "proposed_change_summary", None) or "변경 내용 정보 없음" - cause = getattr(incident, "suspected_cause", None) or "안전 근거가 부족합니다." return ( - f"{actor}가 {target}에 대해 '{operation}' 작업을 수행하려 했습니다. " - f"현재 상태는 {current}이며, 요청된 변경은 {proposed}입니다. " - f"비홀더는 {cause} 때문에 사용자 확인이 필요하다고 판단했습니다." + f"앱이 {operation} 작업을 시도했지만 현재 데이터와 충돌할 가능성이 있어 저장 전에 차단했습니다. " + "현재 데이터는 변경되지 않았습니다. 아래에서 처리 방법을 선택해 주세요." ) @@ -382,6 +379,42 @@ def create_incident( ) -> models.BeholderIncident: metadata = dict(resolution_metadata or {}) metadata.setdefault("override_scope", _override_scope(operation, target_summary=target_summary)) + matching_values = { + "operation_kind": operation.kind, + "actor": operation.actor, + "target_summary": target_summary, + "suspected_cause": suspected_cause, + "current_state_summary": current_state_summary, + "proposed_change_summary": proposed_change_summary, + "risk_factors": sorted(risk_factors), + "override_scope": metadata.get("override_scope") or {}, + } + + def matching_existing() -> list[models.BeholderIncident]: + candidates = db.query(models.BeholderIncident).filter( + models.BeholderIncident.status.in_((STATUS_PENDING, STATUS_DENIED)), + models.BeholderIncident.operation_kind == operation.kind, + models.BeholderIncident.actor == operation.actor, + models.BeholderIncident.target_summary == target_summary, + ).order_by(models.BeholderIncident.created_at.asc(), models.BeholderIncident.id.asc()).all() + return [ + item for item in candidates + if { + "operation_kind": item.operation_kind, + "actor": item.actor, + "target_summary": item.target_summary, + "suspected_cause": item.suspected_cause, + "current_state_summary": item.current_state_summary, + "proposed_change_summary": item.proposed_change_summary, + "risk_factors": sorted(item.risk_factors or []), + "override_scope": (item.resolution_metadata or {}).get("override_scope") or {}, + } == matching_values + ] + + existing = matching_existing() + if existing: + return existing[0] + incident = models.BeholderIncident( severity=severity, status=STATUS_PENDING, @@ -403,8 +436,29 @@ def create_incident( created_at=time.time(), ) db.add(incident) - db.commit() + try: + db.commit() + except OperationalError as exc: + db.rollback() + if "locked" not in str(exc).casefold(): + raise + # 동시에 먼저 commit한 동등 사건을 현재 요청의 결과로 사용합니다. + # busy timeout 뒤에도 canonical 행이 없다면 원래 lock 오류를 보존합니다. + existing = matching_existing() + if existing: + return existing[0] + raise db.refresh(incident) + # 서로 다른 API 요청이 같은 사건을 동시에 조회한 뒤 삽입했더라도 + # SQLite의 직렬화된 commit 이후 가장 이른 한 건만 남깁니다. 별도 + # fingerprint 컬럼이나 영속 lock은 만들지 않습니다. + existing = matching_existing() + canonical = existing[0] if existing else incident + if canonical.id != incident.id: + db.delete(incident) + db.commit() + db.refresh(canonical) + return canonical return incident @@ -625,6 +679,10 @@ def proposed(field: str) -> Any: if "preferred_launch_type" in changed_fields and update_data.get("preferred_launch_type") not in {"shortcut", "direct", "launcher"}: invalid.append("preferred_launch_type") + if "launch_args" in changed_fields: + value = str(update_data.get("launch_args") or "") + if "\n" in value or "\r" in value or "\x00" in value or len(value) > MAX_LAUNCH_ARGS_LENGTH: + invalid.append("launch_args") if "user_cycle_hours" in changed_fields: value = update_data.get("user_cycle_hours") if not _is_number(value) or float(value) <= 0 or float(value) > 8760: @@ -1077,6 +1135,43 @@ def guard_session_end(db: Session, session: models.ProcessSession, end_timestamp if risk_score >= 80: if consume_override_token(db, operation.override_token, operation): return + process_name = (getattr(session, "process_name", None) or "").strip() + if not process_name or process_name.casefold() in {"game", "unknown"}: + process_name = "삭제된 게임 항목" + started_at = time.strftime("%Y-%m-%d %H:%M", time.localtime(start)) + duration_seconds = max(0.0, float(getattr(session, "session_duration", 0.0) or 0.0)) + if duration_seconds < 90: + duration_label = "약 1분" + elif duration_seconds < 3600: + duration_label = f"약 {max(1, round(duration_seconds / 60))}분" + else: + duration_label = f"약 {duration_seconds / 3600:.1f}시간" + invalid_closed_state = status in {"abandoned", "quarantined", "closed"} + if invalid_closed_state: + user_title = "이미 끝난 플레이 기록의 재종료를 차단했습니다" + user_summary = ( + f"{process_name}의 {started_at} 플레이 기록은 이미 {duration_label} 동안 기록된 뒤 종료되어 있습니다. " + "앱이 같은 기록을 다시 종료하려 했기 때문에 변경을 차단했습니다." + ) + user_impact = "차단을 유지하면 기존 플레이 기록과 현재 데이터는 변경되지 않습니다." + safe_recommendation = "이미 종료된 기록이므로 차단을 유지하세요." + available_actions = [ + { + "id": "deny", + "label": "차단 유지", + "description": "기존 기록을 바꾸지 않고 같은 재종료 요청도 계속 차단합니다.", + "recommended": True, + } + ] + else: + user_title = "플레이 기록 종료 시간이 안전하지 않습니다" + user_summary = ( + f"{process_name}의 플레이 종료 요청이 현재 기록 상태와 맞지 않아 " + "플레이 시간이 크게 왜곡될 수 있습니다." + ) + user_impact = "저장하면 과도하게 길거나 잘못된 플레이 기록이 생길 수 있어 차단했습니다." + safe_recommendation = "차단을 유지하고 실제 플레이 기록을 확인하세요." + available_actions = None incident = create_incident( db, severity=SEVERITY_CRITICAL, @@ -1093,10 +1188,12 @@ def guard_session_end(db: Session, session: models.ProcessSession, end_timestamp ), risk_score=min(100, risk_score), risk_factors=risk_factors, - safe_recommendation="이번 변경은 저장하지 않았습니다. 백업/세션 상태를 확인한 뒤 수동으로 결정하세요.", - user_title="플레이 기록 종료 시간이 안전하지 않습니다", - user_summary="현재 기록 상태와 종료 요청이 맞지 않아 플레이 시간이 크게 왜곡될 수 있습니다.", - user_impact="저장하면 과도하게 긴 기록이나 음수 기록이 생길 수 있어 차단했습니다.", + safe_recommendation=safe_recommendation, + user_title=user_title, + user_summary=user_summary, + user_impact=user_impact, + recommended_action="deny", + available_actions=available_actions, ) raise BeholderBlocked(incident) diff --git a/src/data/crud.py b/src/data/crud.py index 5ef87473..1b23bdaa 100644 --- a/src/data/crud.py +++ b/src/data/crud.py @@ -13,10 +13,12 @@ import logging import os import psutil +import threading from src.data.database import base_dir logger = logging.getLogger(__name__) +_session_idempotency_lock = threading.Lock() def _require_snapshot(path: str | None, message: str) -> str: @@ -80,6 +82,7 @@ def create_process( override_token: str | None = None, ): process_data = _dump_schema(process) + _normalize_process_launch_args(process_data) provided_id = process_data.pop('id', None) process_id = provided_id if provided_id else str(uuid.uuid4()) guard_columns = {key for key, value in process_data.items() if key in beholder.PROCESS_EDITOR_FIELDS or value is not None} | {"id"} @@ -144,6 +147,7 @@ def update_process( db_process = get_process_by_id(db, process_id) if db_process: update_data = _dump_schema(process, exclude_unset=True) + _normalize_process_launch_args(update_data) update_data.pop("id", None) if actor == "process_editor": for runtime_field in beholder.PROCESS_RUNTIME_FIELDS: @@ -434,6 +438,13 @@ def _dump_schema(model: Any, **kwargs: Any) -> dict[str, Any]: return model.dict(**kwargs) +def _normalize_process_launch_args(data: dict[str, Any]) -> None: + if "launch_args" in data: + data["launch_args"] = str(data.get("launch_args") or "").strip() + if "launch_args_enabled" in data: + data["launch_args_enabled"] = bool(data.get("launch_args_enabled")) + + def _model_to_dict(model: Any) -> dict[str, Any]: return {column.name: getattr(model, column.name) for column in model.__table__.columns} @@ -979,6 +990,38 @@ def create_session( override_token: str | None = None, ): """새로운 프로세스 세션 시작 기록""" + lease_token = getattr(session, "lease_token", None) + if lease_token: + with _session_idempotency_lock: + existing = db.query(models.ProcessSession).filter( + models.ProcessSession.lease_token == lease_token + ).first() + if existing is not None: + return existing + return _create_session_once( + db, + session, + operation_kind=operation_kind, + actor=actor, + override_token=override_token, + ) + return _create_session_once( + db, + session, + operation_kind=operation_kind, + actor=actor, + override_token=override_token, + ) + + +def _create_session_once( + db: Session, + session: schemas.ProcessSessionCreate, + *, + operation_kind: str, + actor: str, + override_token: str | None, +): runtime_evidence = getattr(session, "runtime_evidence", None) or {} context = { **runtime_evidence, @@ -1035,6 +1078,15 @@ def end_session( """프로세스 세션 종료 기록""" db_session = db.query(models.ProcessSession).filter(models.ProcessSession.id == session_id).first() if db_session: + if db_session.end_timestamp is not None: + same_end = abs(float(db_session.end_timestamp) - float(end_timestamp)) <= 0.001 + same_stamina = stamina_at_end is None or db_session.stamina_at_end == stamina_at_end + same_resource = ( + resource_percent_at_end is None + or db_session.resource_percent_at_end == resource_percent_at_end + ) + if same_end and same_stamina and same_resource: + return db_session changed_fields = ["end_timestamp", "session_duration", "session_status", "close_reason", "heartbeat_timestamp"] proposed_values = { "end_timestamp": end_timestamp, diff --git a/src/data/data_models.py b/src/data/data_models.py index d25d828e..a91f078f 100644 --- a/src/data/data_models.py +++ b/src/data/data_models.py @@ -42,6 +42,8 @@ def __init__(self, original_launch_path: Optional[str] = None, # 원본 실행 경로 보존 # 실행 방식 선택: "auto" (기본), "shortcut" (바로가기 우선), "direct" (직접 실행 우선) preferred_launch_type: str = "shortcut", + launch_args_enabled: bool = False, + launch_args: str = "", # 사용자 설정 프리셋 ID user_preset_id: Optional[str] = None, # 사용자 설정 프리셋 ID (예: "zenless_zone_zero") # HoYoLab 스태미나 연동 필드 @@ -77,6 +79,8 @@ def __init__(self, # 실행 방식 선택 (auto, shortcut, direct) self.preferred_launch_type = preferred_launch_type + self.launch_args_enabled = bool(launch_args_enabled) + self.launch_args = str(launch_args or "").strip() # 사용자 설정 프리셋 ID self.user_preset_id = user_preset_id @@ -118,6 +122,10 @@ def from_dict(cls, data: Dict) -> 'ManagedProcess': # 실행 방식 선택 하위 호환성 if 'preferred_launch_type' not in data: data['preferred_launch_type'] = 'shortcut' + if 'launch_args_enabled' not in data: + data['launch_args_enabled'] = False + if 'launch_args' not in data: + data['launch_args'] = '' # 사용자 프리셋 ID 하위 호환성 (game_schema_id → user_preset_id 마이그레이션) if 'user_preset_id' not in data: data['user_preset_id'] = data.get('game_schema_id') # 기존 game_schema_id 값 복사 diff --git a/src/data/database.py b/src/data/database.py index 7267626d..58fe96e2 100644 --- a/src/data/database.py +++ b/src/data/database.py @@ -67,7 +67,7 @@ def set_sqlite_pragma(dbapi_conn, connection_record): # 앞으로 만들 DB 테이블 모델들은 모두 이 Base 클래스를 상속받아 만들어집니다. -def auto_migrate_database(): +def auto_migrate_database(*, strict: bool = False): """ 자동 마이그레이션 실행 - 새 컬럼이 없으면 추가합니다. @@ -94,6 +94,9 @@ def auto_migrate_database(): ("managed_processes", "resource_status", "TEXT", None), # Process 테이블 - 사용자 프리셋 ID ("managed_processes", "user_preset_id", "TEXT", None), # 사용자 설정 프리셋 ID + # Process 테이블 - 직접 실행 인자 + ("managed_processes", "launch_args_enabled", "INTEGER", "0"), + ("managed_processes", "launch_args", "TEXT", "''"), # GlobalSettings 테이블 - 스태미나 알림 설정 ("global_settings", "stamina_notify_enabled", "INTEGER", "1"), # Boolean -> INTEGER ("global_settings", "stamina_notify_threshold", "INTEGER", "20"), @@ -398,6 +401,8 @@ def auto_migrate_database(): print("[Migration] 자동 마이그레이션 완료") except Exception as e: + if strict: + raise print(f"[Migration] 마이그레이션 중 오류 (무시됨): {e}") diff --git a/src/data/database_coordination.py b/src/data/database_coordination.py new file mode 100644 index 00000000..02c174b3 --- /dev/null +++ b/src/data/database_coordination.py @@ -0,0 +1,545 @@ +"""Cross-thread-safe coordination for normal DB traffic and maintenance swaps. + +The coordinator deliberately never keeps a thread-owned lock across a request, +SQLAlchemy session, or FastAPI ``yield`` boundary. Request leases are counters +that may be released by a different worker thread and are idempotent. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import threading +import time +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Literal + +from fastapi.responses import JSONResponse + + +DatabaseAccessMode = Literal["normal", "draining", "maintenance", "faulted"] + + +@dataclass(frozen=True) +class DatabaseAccessSnapshot: + mode: DatabaseAccessMode + active_requests: int + maintenance_reason: str | None + maintenance_elapsed_ms: float | None + last_checkpoint_at: float | None + fault_code: str | None + fault_at: float | None + + def to_dict(self) -> dict[str, object]: + return asdict(self) + + +class DatabaseAccessUnavailable(RuntimeError): + """Raised when a new DB request is rejected by coordinator state.""" + + def __init__(self, mode: DatabaseAccessMode): + self.mode = mode + if mode == "faulted": + self.status_code = 503 + self.code = "database_faulted" + self.detail = "database access disabled after restore failure" + self.retry_after_seconds = None + else: + self.status_code = 503 + self.code = "database_maintenance" + self.detail = "database maintenance in progress" + self.retry_after_seconds = 2 + super().__init__(self.detail) + + +class DatabaseDrainTimeout(RuntimeError): + """Raised when active DB request leases do not drain before mutation.""" + + status_code = 409 + code = "database_drain_timeout" + + def __init__(self, active_requests: int): + self.active_requests = int(active_requests) + super().__init__("database drain timed out") + + +class DatabaseFaultStatePersistenceError(RuntimeError): + """Raised when a durable fault marker cannot be committed. + + A maintenance guard is written before live database mutation begins, so a + failure here still leaves restart admission fail-closed. Callers may keep + their existing public restore error contract while recording this internal + persistence failure. + """ + + def __init__(self, operation: str, cause: OSError): + self.operation = operation + self.cause = cause + super().__init__(f"database fault state {operation} failed: {cause}") + + +def database_access_error_response(exc: DatabaseAccessUnavailable) -> JSONResponse: + """Return the stable public response for a rejected DB request.""" + + body: dict[str, object] = {"detail": exc.detail, "code": exc.code} + headers: dict[str, str] = {} + if exc.retry_after_seconds is not None: + body["retry_after_seconds"] = exc.retry_after_seconds + headers["Retry-After"] = str(exc.retry_after_seconds) + return JSONResponse(status_code=exc.status_code, content=body, headers=headers) + + +def database_drain_timeout_response(exc: DatabaseDrainTimeout) -> JSONResponse: + return JSONResponse( + status_code=exc.status_code, + content={ + "detail": str(exc), + "code": exc.code, + "active_requests": exc.active_requests, + }, + ) + + +async def database_access_exception_handler(_request, exc: DatabaseAccessUnavailable) -> JSONResponse: + """FastAPI/Starlette exception handler preserving the top-level contract.""" + + return database_access_error_response(exc) + + +async def database_drain_timeout_exception_handler(_request, exc: DatabaseDrainTimeout) -> JSONResponse: + return database_drain_timeout_response(exc) + + +class DatabaseLease: + """Ownerless, idempotent request admission lease.""" + + def __init__(self, coordinator: "DatabaseMaintenanceCoordinator", route_name: str): + self._coordinator = coordinator + self.route_name = route_name + self._released = False + self._release_lock = threading.Lock() + + @property + def released(self) -> bool: + with self._release_lock: + return self._released + + def release(self) -> None: + with self._release_lock: + if self._released: + return + self._released = True + self._coordinator._release_request() + + def __enter__(self) -> "DatabaseLease": + return self + + def __exit__(self, exc_type, exc, traceback) -> None: + self.release() + + +class MaintenanceLease: + """Idempotent exclusive maintenance state transition lease.""" + + def __init__( + self, + coordinator: "DatabaseMaintenanceCoordinator", + *, + abort_mode: DatabaseAccessMode, + ): + self._coordinator = coordinator + self._abort_mode = abort_mode + self._released = False + self._release_lock = threading.Lock() + + def release(self) -> None: + with self._release_lock: + if self._released: + return + self._released = True + self._coordinator._finish_maintenance("normal") + + def mark_faulted(self, code: str) -> None: + with self._release_lock: + if self._released: + return + self._released = True + self._coordinator._finish_faulted(code) + + def __enter__(self) -> "MaintenanceLease": + return self + + def __exit__(self, exc_type, exc, traceback) -> None: + if exc_type is None: + self.release() + return + with self._release_lock: + if self._released: + return + self._released = True + self._coordinator._finish_maintenance(self._abort_mode) + + +class DatabaseMaintenanceCoordinator: + """Admit concurrent requests and exclusively drain them for DB swaps.""" + + def __init__( + self, + *, + fault_state_path: str | os.PathLike[str] | None = None, + clock=time.time, + monotonic=time.monotonic, + ): + self._condition = threading.Condition(threading.Lock()) + self._fault_persistence_lock = threading.Lock() + self._state_generation = 0 + self._clock = clock + self._monotonic = monotonic + self._fault_state_path = Path(fault_state_path) if fault_state_path else None + self._mode: DatabaseAccessMode = "normal" + self._active_requests = 0 + self._maintenance_reason: str | None = None + self._maintenance_started_monotonic: float | None = None + self._last_checkpoint_at: float | None = None + self._fault_code: str | None = None + self._fault_at: float | None = None + self._load_fault_state() + + def acquire_request(self, route_name: str) -> DatabaseLease: + with self._condition: + if self._mode != "normal": + raise DatabaseAccessUnavailable(self._mode) + self._active_requests += 1 + return DatabaseLease(self, route_name) + + def try_acquire_request(self, route_name: str) -> DatabaseLease | None: + """Non-blocking admission intended for optional periodic work.""" + + with self._condition: + if self._mode != "normal": + return None + self._active_requests += 1 + return DatabaseLease(self, route_name) + + def acquire_fault_recovery_read(self, route_name: str) -> DatabaseLease: + """Admit a read used solely to inspect recovery data while faulted. + + Fault recovery reads share the active-request counter with ordinary + requests. Consequently ``begin_fault_recovery`` drains them before a + live database replace and cannot race an open SQLite read handle. + """ + + with self._condition: + if self._mode != "faulted": + raise DatabaseAccessUnavailable(self._mode) + self._active_requests += 1 + return DatabaseLease(self, route_name) + + def begin_maintenance( + self, + reason: str, + drain_timeout_seconds: float = 5.0, + ) -> MaintenanceLease: + return self._begin_maintenance( + reason, + drain_timeout_seconds=drain_timeout_seconds, + required_mode="normal", + ) + + def begin_fault_recovery( + self, + reason: str, + drain_timeout_seconds: float = 5.0, + ) -> MaintenanceLease: + return self._begin_maintenance( + reason, + drain_timeout_seconds=drain_timeout_seconds, + required_mode="faulted", + ) + + def _begin_maintenance( + self, + reason: str, + *, + drain_timeout_seconds: float, + required_mode: DatabaseAccessMode, + ) -> MaintenanceLease: + timeout = max(0.0, float(drain_timeout_seconds)) + with self._condition: + if self._mode != required_mode: + raise DatabaseAccessUnavailable(self._mode) + return_mode = required_mode + self._mode = "draining" + self._maintenance_reason = reason + self._maintenance_started_monotonic = self._monotonic() + deadline = self._monotonic() + timeout + while self._active_requests: + remaining = deadline - self._monotonic() + if remaining <= 0: + active_requests = self._active_requests + self._mode = return_mode + self._clear_maintenance_metadata() + self._condition.notify_all() + raise DatabaseDrainTimeout(active_requests) + self._condition.wait(remaining) + + # Arm a durable write-ahead guard outside the condition lock. If the + # process exits during restore, or the detailed fault sentinel later + # cannot be committed, the next process still starts faulted. A guard + # failure occurs before the caller can mutate the live database. + try: + self._arm_maintenance_guard(reason, required_mode=required_mode) + except OSError as exc: + with self._condition: + if self._mode == "draining": + self._mode = return_mode + self._clear_maintenance_metadata() + self._condition.notify_all() + raise DatabaseFaultStatePersistenceError("guard_write", exc) from exc + + with self._condition: + if self._mode != "draining": + raise DatabaseAccessUnavailable(self._mode) + self._mode = "maintenance" + return MaintenanceLease(self, abort_mode=return_mode) + + def snapshot(self) -> DatabaseAccessSnapshot: + with self._condition: + elapsed_ms = None + if self._maintenance_started_monotonic is not None: + elapsed_ms = max( + 0.0, + (self._monotonic() - self._maintenance_started_monotonic) * 1000.0, + ) + return DatabaseAccessSnapshot( + mode=self._mode, + active_requests=self._active_requests, + maintenance_reason=self._maintenance_reason, + maintenance_elapsed_ms=elapsed_ms, + last_checkpoint_at=self._last_checkpoint_at, + fault_code=self._fault_code, + fault_at=self._fault_at, + ) + + def record_checkpoint(self, timestamp: float | None = None) -> None: + with self._condition: + self._last_checkpoint_at = self._clock() if timestamp is None else float(timestamp) + + def _release_request(self) -> None: + with self._condition: + if self._active_requests <= 0: + return + self._active_requests -= 1 + if self._active_requests == 0: + self._condition.notify_all() + + def _finish_maintenance(self, mode: DatabaseAccessMode) -> None: + with self._condition: + if self._mode != "maintenance": + return + + if mode == "normal": + try: + self._remove_fault_state() + except OSError as exc: + fault_at = self._clock() + with self._condition: + if self._mode == "maintenance": + self._mode = "faulted" + self._state_generation += 1 + self._fault_code = "database_fault_state_clear_failed" + self._fault_at = fault_at + self._clear_maintenance_metadata() + self._condition.notify_all() + raise DatabaseFaultStatePersistenceError("sentinel_clear", exc) from exc + + with self._condition: + if self._mode != "maintenance": + return + self._mode = mode + self._state_generation += 1 + self._clear_maintenance_metadata() + if mode == "normal": + self._fault_code = None + self._fault_at = None + self._condition.notify_all() + + def _finish_faulted(self, code: str) -> None: + fault_at = self._clock() + with self._condition: + self._mode = "faulted" + self._state_generation += 1 + transition_generation = self._state_generation + self._fault_code = str(code) + self._fault_at = fault_at + self._clear_maintenance_metadata() + self._condition.notify_all() + try: + self._write_fault_state( + transition_generation, + fault_code=str(code), + fault_at=fault_at, + ) + except OSError as exc: + # Do not downgrade or swallow this failure. The maintenance guard + # remains on disk so a new process still denies ordinary DB access. + raise DatabaseFaultStatePersistenceError("sentinel_write", exc) from exc + + def _clear_maintenance_metadata(self) -> None: + self._maintenance_reason = None + self._maintenance_started_monotonic = None + + def _load_fault_state(self) -> None: + path = self._fault_state_path + if path is None: + return + candidates = ( + path, + self._temporary_path(path), + self._maintenance_guard_path(path), + self._temporary_path(self._maintenance_guard_path(path)), + ) + existing = next((candidate for candidate in candidates if candidate.exists()), None) + if existing is None: + return + try: + payload = json.loads(existing.read_text(encoding="utf-8")) + self._fault_code = str(payload["code"]) + self._fault_at = float(payload["timestamp"]) + self._mode = "faulted" + self._state_generation += 1 + except (OSError, ValueError, TypeError, KeyError, json.JSONDecodeError): + self._fault_code = "database_fault_state_unreadable" + self._fault_at = self._clock() + self._mode = "faulted" + self._state_generation += 1 + + @staticmethod + def _temporary_path(path: Path) -> Path: + return path.with_name(path.name + ".tmp") + + @staticmethod + def _maintenance_guard_path(path: Path) -> Path: + return path.with_name(path.name + ".guard") + + @staticmethod + def _atomic_write_json(path: Path, payload: dict[str, object]) -> None: + """Atomically commit JSON and flush both content and directory metadata. + + The temporary file is intentionally retained on failure. Startup + treats any canonical, guard, or pending marker as faulted, including a + partially-written file whose JSON cannot be decoded. + """ + + path.parent.mkdir(parents=True, exist_ok=True) + temporary = DatabaseMaintenanceCoordinator._temporary_path(path) + encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True).encode("utf-8") + with temporary.open("wb") as output: + output.write(encoded) + output.flush() + os.fsync(output.fileno()) + os.replace(temporary, path) + # Windows rejects fsync on a read-only descriptor. Reopen the newly + # committed sentinel read/write so FlushFileBuffers reaches the file. + with path.open("rb+") as committed: + os.fsync(committed.fileno()) + if os.name != "nt": + directory_fd = os.open(path.parent, os.O_RDONLY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + + def _arm_maintenance_guard( + self, + _reason: str, + *, + required_mode: DatabaseAccessMode, + ) -> None: + path = self._fault_state_path + if path is None: + return + if required_mode == "faulted": + # The canonical sentinel or the guard that caused startup to enter + # faulted mode is already the durable write-ahead marker. + return + guard_path = self._maintenance_guard_path(path) + database_path = path.parent / "app_data.db" + database_sha256 = self._database_sha256(database_path) + self._atomic_write_json( + guard_path, + { + "code": "database_maintenance_interrupted", + "timestamp": self._clock(), + "database_path": str(database_path), + "database_sha256": database_sha256, + }, + ) + + @staticmethod + def _database_sha256(database_path: Path) -> str | None: + if not database_path.exists(): + return None + digest = hashlib.sha256() + try: + with database_path.open("rb") as database_file: + for chunk in iter(lambda: database_file.read(1024 * 1024), b""): + digest.update(chunk) + except OSError: + return None + return digest.hexdigest() + + def _write_fault_state( + self, + expected_generation: int, + *, + fault_code: str, + fault_at: float, + ) -> None: + path = self._fault_state_path + if path is None: + return + with self._fault_persistence_lock: + with self._condition: + if ( + self._state_generation != expected_generation + or self._mode != "faulted" + or self._fault_code != fault_code + or self._fault_at != fault_at + ): + return + database_path = path.parent / "app_data.db" + database_sha256 = self._database_sha256(database_path) + payload = { + "code": fault_code, + "timestamp": fault_at, + "database_path": str(database_path), + "database_sha256": database_sha256, + } + self._atomic_write_json(path, payload) + + def _remove_fault_state(self) -> None: + path = self._fault_state_path + if path is not None: + with self._fault_persistence_lock: + with self._condition: + if self._mode != "maintenance": + return + for candidate in ( + path, + self._temporary_path(path), + self._maintenance_guard_path(path), + self._temporary_path(self._maintenance_guard_path(path)), + ): + try: + candidate.unlink() + except FileNotFoundError: + pass + + +def create_database_coordinator(data_directory: str | os.PathLike[str]) -> DatabaseMaintenanceCoordinator: + return DatabaseMaintenanceCoordinator( + fault_state_path=Path(data_directory) / "database_fault_state.json", + ) diff --git a/src/data/models.py b/src/data/models.py index ab8e8d94..7e693f14 100644 --- a/src/data/models.py +++ b/src/data/models.py @@ -24,6 +24,8 @@ class Process(Base): last_played_timestamp = Column(Float, nullable=True) original_launch_path = Column(String, nullable=True) preferred_launch_type = Column(String, default="shortcut") # 실행 방식 선호도 + launch_args_enabled = Column(Boolean, nullable=False, default=False) # 직접 실행 인자 사용 여부 + launch_args = Column(String, nullable=False, default="") # 직접 실행 시 전달할 추가 인자 user_preset_id = Column(String, nullable=True) # 사용자 설정 프리셋 ID # HoYoLab 스태미나 연동 필드 diff --git a/src/data/schemas.py b/src/data/schemas.py index 49d0975c..cf460c73 100644 --- a/src/data/schemas.py +++ b/src/data/schemas.py @@ -13,6 +13,8 @@ class ProcessSchema(BaseModel): last_played_timestamp: Optional[float] = None original_launch_path: Optional[str] = None preferred_launch_type: str = "shortcut" + launch_args_enabled: bool = False + launch_args: str = "" user_preset_id: Optional[str] = None # HoYoLab 스태미나 필드 stamina_tracking_enabled: bool = False @@ -47,6 +49,8 @@ class ProcessCreateSchema(BaseModel): last_played_timestamp: Optional[float] = None original_launch_path: Optional[str] = None preferred_launch_type: str = "shortcut" + launch_args_enabled: bool = False + launch_args: str = "" user_preset_id: Optional[str] = None # HoYoLab 스태미나 필드 stamina_tracking_enabled: bool = False diff --git a/src/gui/beholder_dialog.py b/src/gui/beholder_dialog.py index ba7377b8..505e7a30 100644 --- a/src/gui/beholder_dialog.py +++ b/src/gui/beholder_dialog.py @@ -4,7 +4,7 @@ from typing import Any -from PyQt6.QtWidgets import QDialog, QDialogButtonBox, QLabel, QPushButton, QTextEdit, QVBoxLayout +from PySide6.QtWidgets import QDialog, QDialogButtonBox, QLabel, QPushButton, QTextEdit, QVBoxLayout class BeholderIncidentDialog(QDialog): @@ -21,23 +21,38 @@ def __init__(self, incident: dict[str, Any], parent=None): lead = QLabel(incident.get("user_summary") or "Beholder가 저장 전에 변경 내용을 확인했습니다.") lead.setWordWrap(True) - severity = incident.get("severity", "warning") - risk = incident.get("risk_score", 0) - summary = QTextEdit() - summary.setReadOnly(True) - summary.setMinimumHeight(250) + impact = QLabel(incident.get("user_impact") or "현재 데이터는 변경되지 않았습니다.") + impact.setWordWrap(True) + recommendation = QLabel( + f"권장 조치: {incident.get('safe_recommendation') or '차단을 유지하세요.'}" + ) + recommendation.setWordWrap(True) + + technical_toggle = QPushButton("기술 정보 보기") + technical_toggle.setCheckable(True) + technical = QTextEdit() + technical.setReadOnly(True) + technical.setMinimumHeight(220) + technical.setVisible(False) factors = incident.get("risk_factors") or [] factor_text = "\n".join(f"- {item}" for item in factors) or "- 없음" - summary.setPlainText( - f"사용자 영향\n{incident.get('user_impact') or '-'}\n\n" - f"권장 조치\n{incident.get('safe_recommendation') or '차단을 유지하세요.'}\n\n" - f"심각도: {severity} / 위험도: {risk}/100\n" - f"동작: {incident.get('operation_kind')} / {incident.get('actor')}\n\n" + technical.setPlainText( + f"사건 ID: {incident.get('id') or '-'}\n" + f"심각도: {incident.get('severity', 'warning')} / 위험도: {incident.get('risk_score', 0)}/100\n" + f"내부 동작: {incident.get('operation_kind') or '-'} / {incident.get('actor') or '-'}\n" + f"내부 대상: {incident.get('target_summary') or '-'}\n\n" f"현재 DB 상태\n{incident.get('current_state_summary') or '-'}\n\n" f"저장하려던 변경\n{incident.get('proposed_change_summary') or '-'}\n\n" f"위험 신호\n{factor_text}" ) + def toggle_technical(checked: bool) -> None: + technical.setVisible(checked) + technical_toggle.setText("기술 정보 접기" if checked else "기술 정보 보기") + self.adjustSize() + + technical_toggle.toggled.connect(toggle_technical) + buttons = QDialogButtonBox() actions = incident.get("available_actions") or [] if not actions: @@ -46,6 +61,7 @@ def __init__(self, incident: dict[str, Any], parent=None): {"id": "quarantine", "label": "격리"}, {"id": "allow_once", "label": "이번 한 번 허용"}, ] + action_explanations: list[str] = [] for action in actions: action_id = action.get("id") if not action_id: @@ -53,6 +69,9 @@ def __init__(self, incident: dict[str, Any], parent=None): label = action.get("label") or action_id if action.get("recommended"): label = f"★ {label}" + outcome = action.get("outcome") or action.get("description") + if outcome: + action_explanations.append(f"{label}: {outcome}") button = QPushButton(label) button.setToolTip(action.get("description") or "") role = QDialogButtonBox.ButtonRole.AcceptRole if action.get("recommended") else QDialogButtonBox.ButtonRole.ActionRole @@ -62,14 +81,16 @@ def __init__(self, incident: dict[str, Any], parent=None): role = QDialogButtonBox.ButtonRole.RejectRole buttons.addButton(button, role) button.clicked.connect(lambda _checked=False, selected=action_id: self._finish(selected)) - restore = QPushButton("백업에서 복구") - buttons.addButton(restore, QDialogButtonBox.ButtonRole.DestructiveRole) - restore.clicked.connect(lambda: self._finish("restore_backup")) - + choices = QLabel("\n".join(action_explanations)) + choices.setWordWrap(True) layout = QVBoxLayout(self) layout.addWidget(title) layout.addWidget(lead) - layout.addWidget(summary) + layout.addWidget(impact) + layout.addWidget(recommendation) + layout.addWidget(choices) + layout.addWidget(technical_toggle) + layout.addWidget(technical) layout.addWidget(buttons) def _finish(self, action: str) -> None: diff --git a/src/gui/countdown_overlay.py b/src/gui/countdown_overlay.py index 6184f3c5..38ab5f66 100644 --- a/src/gui/countdown_overlay.py +++ b/src/gui/countdown_overlay.py @@ -8,9 +8,9 @@ import logging from typing import Callable, Optional -from PyQt6.QtCore import QRect, Qt, QTimer -from PyQt6.QtGui import QColor, QFont, QPainter, QPen, QScreen -from PyQt6.QtWidgets import QApplication, QWidget +from PySide6.QtCore import QRect, Qt, QTimer +from PySide6.QtGui import QColor, QFont, QPainter, QPen, QScreen +from PySide6.QtWidgets import QApplication, QWidget logger = logging.getLogger(__name__) diff --git a/src/gui/dialogs.py b/src/gui/dialogs.py index b56bf271..cabf50fb 100644 --- a/src/gui/dialogs.py +++ b/src/gui/dialogs.py @@ -6,7 +6,7 @@ logger = logging.getLogger(__name__) -from PyQt6.QtWidgets import ( +from PySide6.QtWidgets import ( QTableWidgetItem, QDialog, QVBoxLayout, QLabel, QTableWidget, QDialogButtonBox, QHeaderView, QWidget, QFormLayout, QPushButton, QLineEdit, QHBoxLayout, QFileDialog, QMessageBox, QCheckBox, @@ -14,8 +14,8 @@ QRadioButton, QButtonGroup, QTextEdit, QGridLayout, QTabWidget, QAbstractItemView, QMenu, ) -from PyQt6.QtCore import Qt, QTime, QThread, QTimer, pyqtSignal -from PyQt6.QtGui import QIcon # QIcon might be needed if dialogs use icons directly +from PySide6.QtCore import Qt, QTime, QThread, QTimer, Signal +from PySide6.QtGui import QIcon # QIcon might be needed if dialogs use icons directly # Local imports from src.data.data_models import ManagedProcess, GlobalSettings @@ -30,8 +30,8 @@ class _RemoteSettingsWorker(QThread): """Run a remote-settings HTTP/probe task without blocking dialog creation.""" - succeeded = pyqtSignal(str, object) - failed = pyqtSignal(str, object) + succeeded = Signal(str, object) + failed = Signal(str, object) def __init__(self, task_name: str, task, parent: Optional[QWidget] = None): super().__init__(parent) @@ -646,6 +646,8 @@ def get_selected_process_info(self) -> Optional[Dict[str, Any]]: class ProcessDialog(QDialog): """ Dialog for adding a new process or editing an existing one. """ + MAX_LAUNCH_ARGS_LENGTH = 512 + def __init__(self, parent: Optional[QWidget] = None, existing_process: Optional[ManagedProcess] = None): super().__init__(parent) self.existing_process = existing_process @@ -693,6 +695,7 @@ def __init__(self, parent: Optional[QWidget] = None, existing_process: Optional[ # 실행 방식 선택 섹션 self._setup_launch_type_section() + self._setup_launch_args_section() # 스태미나 추적 섹션 (호요버스 게임 전용) self._setup_stamina_section() @@ -791,7 +794,7 @@ def on_presets_changed(): def _on_save_as_preset_clicked(self): """현재 설정을 신규 프리셋으로 바로 저장 (간단한 입력 다이얼로그)""" - from PyQt6.QtWidgets import QInputDialog, QLineEdit + from PySide6.QtWidgets import QInputDialog, QLineEdit from src.utils.game_preset_manager import GamePresetManager import re import os @@ -1053,6 +1056,27 @@ def _setup_launch_type_section(self): # 시그널 연결은 모든 위젯 초기화 후에 한 번만 하도록 __init__ 마지막에서 처리 self._update_launch_type_enabled() + def _setup_launch_args_section(self): + """직접 실행 시 전달할 추가 인자 opt-in 섹션 설정""" + launch_args_layout = QHBoxLayout() + self.launch_args_enabled_checkbox = QCheckBox("직접 실행 인자 사용") + self.launch_args_enabled_checkbox.setToolTip( + "프로세스 선호/직접 실행에만 적용됩니다.\n" + "바로가기(.lnk), .url, 런처 실행에는 적용되지 않습니다." + ) + self.launch_args_edit = QLineEdit() + self.launch_args_edit.setPlaceholderText("-use-d3d12") + self.launch_args_edit.setToolTip( + "직접 실행 파일에 전달할 추가 실행 인자를 입력합니다.\n" + "예: -use-d3d12" + ) + self.launch_args_edit.setEnabled(False) + self.launch_args_enabled_checkbox.toggled.connect(self.launch_args_edit.setEnabled) + + launch_args_layout.addWidget(self.launch_args_enabled_checkbox) + launch_args_layout.addWidget(self.launch_args_edit, 1) + self.form_layout.addRow(launch_args_layout) + def _update_launch_type_enabled(self, _=None): """모니터링 경로와 실행 경로가 다를 때만 실행 방식 선택 활성화""" # 콤보박스가 아직 생성되지 않은 경우 무시 @@ -1353,6 +1377,10 @@ def populate_fields_from_existing_process(self): if self.existing_process.mandatory_times_str: self.mandatory_times_edit.setText(",".join(self.existing_process.mandatory_times_str)) self.is_mandatory_time_enabled_checkbox.setChecked(self.existing_process.is_mandatory_time_enabled) + launch_args_enabled = bool(getattr(self.existing_process, 'launch_args_enabled', False)) + self.launch_args_enabled_checkbox.setChecked(launch_args_enabled) + self.launch_args_edit.setText(str(getattr(self.existing_process, 'launch_args', '') or '')) + self.launch_args_edit.setEnabled(launch_args_enabled) # 실행 방식 선택 로드 if hasattr(self.existing_process, 'preferred_launch_type'): @@ -1507,6 +1535,13 @@ def accept_data(self): if t_str and not self.validate_time_format(t_str): QMessageBox.warning(self, "입력 오류", f"특정 접속 시각 형식이 잘못되었습니다 (HH:MM): {t_str}") return + launch_args = self.launch_args_edit.text().strip() + if "\n" in launch_args or "\r" in launch_args or "\x00" in launch_args: + QMessageBox.warning(self, "입력 오류", "직접 실행 인자에는 줄바꿈 또는 NULL 문자를 사용할 수 없습니다.") + return + if len(launch_args) > self.MAX_LAUNCH_ARGS_LENGTH: + QMessageBox.warning(self, "입력 오류", f"직접 실행 인자는 {self.MAX_LAUNCH_ARGS_LENGTH}자 이하로 입력해야 합니다.") + return self.accept() def get_data(self) -> Optional[Dict[str, Any]]: @@ -1536,6 +1571,7 @@ def get_data(self) -> Optional[Dict[str, Any]]: # 실행 방식 선택 preferred_launch_type = self.launch_type_combo.currentData() or "shortcut" + launch_args = self.launch_args_edit.text().strip() # 프리셋 ID 추출 preset_data = self.preset_combo.currentData() @@ -1569,6 +1605,8 @@ def get_data(self) -> Optional[Dict[str, Any]]: "mandatory_times_str": mandatory_times_list if mandatory_times_list else None, "is_mandatory_time_enabled": is_mandatory_enabled, "preferred_launch_type": preferred_launch_type, + "launch_args_enabled": self.launch_args_enabled_checkbox.isChecked(), + "launch_args": launch_args, "user_preset_id": user_preset_id, "stamina_tracking_enabled": stamina_tracking_enabled, "hoyolab_game_id": hoyolab_game_id, diff --git a/src/gui/gui_notification_handler.py b/src/gui/gui_notification_handler.py index 07269dd0..2d0cd454 100644 --- a/src/gui/gui_notification_handler.py +++ b/src/gui/gui_notification_handler.py @@ -1,6 +1,6 @@ from typing import Optional -from PyQt6.QtCore import QObject, pyqtSlot +from PySide6.QtCore import QObject, Slot class GuiNotificationHandler(QObject): @@ -10,7 +10,7 @@ def __init__(self, main_window): super().__init__(main_window) self.main_window = main_window - @pyqtSlot(str, str) + @Slot(str, str) def process_system_notification_activation(self, task_id_obj: Optional[str], source: Optional[str] = None): """ 토스트 알림 클릭 처리. diff --git a/src/gui/host_ui_facade.py b/src/gui/host_ui_facade.py new file mode 100644 index 00000000..4e3ca778 --- /dev/null +++ b/src/gui/host_ui_facade.py @@ -0,0 +1,120 @@ +"""Binding-neutral presentation API shared by Widgets and Qt Quick surfaces.""" +from __future__ import annotations + +import datetime +import logging +from typing import Any + +from PySide6.QtCore import QObject, Property, QTimer, Signal, Slot + +from src.gui.qt_runtime import require_object_thread + +logger = logging.getLogger(__name__) + + +class HostUiFacade(QObject): + processesChanged = Signal() + themeChanged = Signal() + visibilityRequested = Signal(bool) + + def __init__(self, main_window, parent: QObject | None = None): + super().__init__(parent or main_window) + self._main_window = main_window + self._processes: list[dict[str, Any]] = [] + self._dark_theme = False + self._presentation_window = None + refresh_timer = getattr(main_window, "ui_refresh_timer", None) + if refresh_timer is not None: + refresh_timer.timeout.connect(self.refresh) + main_window.request_table_refresh_signal.connect(self.refresh) + self.refresh() + + @Property("QVariantList", notify=processesChanged) + def processes(self) -> list[dict[str, Any]]: + return list(self._processes) + + @Property(bool, notify=themeChanged) + def darkTheme(self) -> bool: + return self._dark_theme + + @Property(str, constant=True) + def title(self) -> str: + return "숙제 관리자" + + def set_presentation_window(self, window) -> None: + self._presentation_window = window + + @Slot() + def refresh(self) -> None: + require_object_thread(self, "HostUiFacade.refresh") + now = datetime.datetime.now() + settings = self._main_window.data_manager.global_settings + processes: list[dict[str, Any]] = [] + for process in sorted( + self._main_window.data_manager.managed_processes, + key=lambda item: ((item.name or "").casefold(), item.id or ""), + ): + try: + state = self._main_window.scheduler.determine_process_visual_status(process, now, settings) + percent, detail = self._main_window._calculate_progress_percentage(process, now) + except Exception: + logger.debug("QML process projection failed: %s", process.id, exc_info=True) + state, percent, detail = "확인 필요", 0.0, "" + processes.append( + { + "id": str(process.id), + "name": str(process.name or "이름 없음"), + "state": str(state or ""), + "progress": max(0.0, min(100.0, float(percent or 0.0))), + "detail": str(detail or ""), + } + ) + if processes != self._processes: + self._processes = processes + self.processesChanged.emit() + dark = bool(self._main_window._is_effective_dark_theme()) + if dark != self._dark_theme: + self._dark_theme = dark + self.themeChanged.emit() + + @Slot(str) + def launchProcess(self, process_id: str) -> None: + QTimer.singleShot(0, lambda: self._main_window.handle_launch_button_in_row(str(process_id))) + + @Slot() + def addProcess(self) -> None: + QTimer.singleShot(0, self._main_window.open_add_process_dialog) + + @Slot() + def openSettings(self) -> None: + QTimer.singleShot(0, self._main_window.open_global_settings_dialog) + + @Slot() + def openRemoteSettings(self) -> None: + QTimer.singleShot(0, self._main_window.open_remote_settings_dialog) + + @Slot() + def openDashboard(self) -> None: + QTimer.singleShot(0, self._main_window._open_dashboard) + + @Slot() + def hideWindow(self) -> None: + if self._presentation_window is not None: + self._presentation_window.hide() + + @Slot() + def activateAndShow(self) -> None: + if self._presentation_window is None: + return + self._presentation_window.show() + self._presentation_window.raise_() + self._presentation_window.requestActivate() + + @Slot() + def toggleVisibility(self) -> None: + if self._presentation_window is None: + return + if self._presentation_window.isVisible(): + self._presentation_window.hide() + else: + self.activateAndShow() diff --git a/src/gui/main_window.py b/src/gui/main_window.py index 3948ee20..6605b97b 100644 --- a/src/gui/main_window.py +++ b/src/gui/main_window.py @@ -6,54 +6,102 @@ import time import datetime import functools -import json import logging -from typing import Optional +import threading +from collections.abc import Mapping +from dataclasses import dataclass, replace +from typing import Any, Optional logger = logging.getLogger(__name__) -# PyQt6 임포트 -from PyQt6.QtWidgets import ( +# PySide6 임포트 +from PySide6.QtWidgets import ( QApplication, QMainWindow, QTableWidget, QTableWidgetItem, QVBoxLayout, QHBoxLayout, QWidget, QHeaderView, QPushButton, QSizePolicy, QFileIconProvider, QAbstractItemView, - QMessageBox, QMenu, QStyle, QStatusBar, QMenuBar, QAbstractScrollArea, QCheckBox, + QMessageBox, QMenu, QStyle, QMenuBar, QCheckBox, QLabel, QProgressBar, QSlider, QToolButton, QInputDialog, QDialog, QLineEdit, - QGraphicsDropShadowEffect, ) -from PyQt6.QtCore import Qt, QTimer, pyqtSignal, QUrl, QEvent, QThread, QSettings, QPoint, QRect, QSize -from PyQt6.QtGui import QAction, QIcon, QColor, QDesktopServices, QFontDatabase, QFont, QPixmap, QPalette, QScreen +from PySide6.QtCore import ( + Qt, QTimer, Signal, Slot, QUrl, QEvent, QThread, QSettings, QPoint, QSize, +) +from PySide6.QtGui import QAction, QIcon, QColor, QDesktopServices, QFontDatabase, QFont, QPixmap, QPalette, QCursor # --- 로컬 모듈 임포트 --- -from src.gui.dialogs import ProcessDialog, GlobalSettingsDialog, NumericTableWidgetItem, WebShortcutDialog, HoYoLabSettingsDialog, RemoteSettingsDialog +from src.gui.dialogs import ProcessDialog, GlobalSettingsDialog, WebShortcutDialog, HoYoLabSettingsDialog, RemoteSettingsDialog from src.gui.beholder_dialog import BeholderIncidentDialog from src.gui.tray_manager import TrayManager from src.gui.gui_notification_handler import GuiNotificationHandler from src.core.instance_manager import run_with_single_instance_check, SingleInstanceApplication from src.utils.common import get_bundle_resource_path -from src.api.runtime_config import dashboard_url, gui_health_url, resolve_local_api_base_url +from src.api.runtime_config import dashboard_url, resolve_local_api_base_url import requests # --- 기타 로컬 유틸리티/데이터 모듈 임포트 --- -from src.api.client import ApiClient +from src.api.client import ( + ApiClient, + BackgroundApiTransport, + BeholderIncidentRequired, + DatabaseFaultedResponse, +) from src.data.data_models import ManagedProcess, GlobalSettings, WebShortcut from src.utils.process import get_qicon_for_file from src.utils.windows import ( apply_windows_title_bar_color, + position_windows_window_bottom_right, + snap_windows_window_to_work_area, set_startup_shortcut, get_startup_shortcut_status, ) -from src.core.launcher import Launcher +from src.core.launcher import Launcher, launch_target_accepts_args from src.core.tailscale import tailscale_status from src.core.notifier import Notifier from src.core.hoyolab_reconcile import HoYoStaminaReconcileCoordinator from src.core.resource_reconcile import NikkeResourceReconcileCoordinator from src.core.daily_checkin_coordinator import DailyCheckInCoordinator +from src.core.process_monitor import ( + ProcessLifecycleEvent, + ProcessScanSnapshot, + detect_running_process_ids, + scan_running_processes, +) from src.core.scheduler import Scheduler, PROC_STATE_INCOMPLETE, PROC_STATE_COMPLETED, PROC_STATE_RUNNING from src.utils.admin import is_admin, run_as_admin, restart_as_normal from src.utils.game_preset_manager import GamePresetManager from src.utils import audio_control from src.gui.volume_panel import VolumePopoverPanel from src.gui.sidebar.sidebar_controller import SidebarController +from src.gui.power_events import DesiredTimerRegistry, PowerResumeEvent, WindowsPowerEventFilter +from src.gui.work_coordinator import GuiWorkCoordinator, WorkError, WorkResult +from src.gui.widgets_style import ( + CapsuleProgressBar, + apply_modern_widgets_style, + apply_widgets_palette, + tint_icon, + widgets_theme_tokens, +) + + +@dataclass(frozen=True, slots=True) +class _LifecycleCommand: + kind: str + event: ProcessLifecycleEvent + pid: int + process_create_time: float + runtime_token: str + + +@dataclass(frozen=True, slots=True) +class _LifecyclePersistenceResult: + command: _LifecycleCommand + session_id: int | None + succeeded: bool + attempts: int + error: str | None = None + blocked: bool = False + beholder_incident: Mapping[str, Any] | None = None + + +_GUI_CLEANUP_DEADLINE_SECONDS = 2.0 class IconDownloader(QThread): @@ -61,7 +109,7 @@ class IconDownloader(QThread): 별도 스레드에서 URL로부터 아이콘을 다운로드하는 클래스. 다운로드가 완료되면 icon_ready 시그널을 통해 QIcon 객체를 전달합니다. """ - icon_ready = pyqtSignal(QIcon) + icon_ready = Signal(QIcon) def __init__(self, url, parent=None): super().__init__(parent) @@ -86,39 +134,34 @@ def run(self): class MainWindow(QMainWindow): INSTANCE = None # 다른 모듈에서 메인 윈도우 인스턴스에 접근하기 위함 - request_table_refresh_signal = pyqtSignal() # 테이블 새로고침 요청 시그널 - _recording_state_sig = pyqtSignal(str) # OBS 상태 변경 (백그라운드→메인 스레드 릴레이) - _gamepad_countdown_sig = pyqtSignal() # 게임패드 롱프레스 → 메인 스레드 릴레이 - - # UI 색상 정의 - COLOR_INCOMPLETE = QColor("red") # 미완료 상태 색상 - COLOR_COMPLETED = QColor("green") # 완료 상태 색상 - COLOR_RUNNING = QColor("yellow") # 실행 중 상태 색상 - COLOR_WEB_BTN_RED = QColor("red") # 웹 버튼 (리셋 필요) 색상 - COLOR_WEB_BTN_GREEN = QColor("green") # 웹 버튼 (리셋 완료) 색상 - - # 테이블 컬럼 인덱스 정의 - COL_ICON = 0 - COL_NAME = 1 - COL_LAST_PLAYED = 2 - COL_LAUNCH_BTN = 3 - COL_STATUS = 4 - TOTAL_COLUMNS = 5 # 전체 컬럼 개수 + request_table_refresh_signal = Signal() # 기존 백그라운드 호출부가 사용하는 목록 새로고침 시그널 + _recording_state_sig = Signal(str) # OBS 상태 변경 (백그라운드→메인 스레드 릴레이) + _gamepad_countdown_sig = Signal() # 게임패드 롱프레스 → 메인 스레드 릴레이 + _PROGRESS_BAR_SCALE = 10 _PROGRESS_BAR_MAX = 100 * _PROGRESS_BAR_SCALE _UI_REFRESH_INTERVAL_MS = 1000 _WEB_BUTTON_REFRESH_INTERVAL_TICKS = 60 _MIN_WINDOW_WIDTH = 320 _MIN_WINDOW_HEIGHT = 120 - _SCREEN_SIZE_RATIO = 0.92 - _TABLE_ROW_HEIGHT = 30 - _TABLE_ICON_LOGICAL_SIZE = 24 - _TABLE_ICON_COLUMN_PADDING = 4 - _WINDOW_ANCHOR_SETTINGS_KEY = "window_anchor_v1" + COL_ICON = 0 + COL_NAME = 1 + COL_LAST_PLAYED = 2 + COL_LAUNCH_BTN = 3 + COL_STATUS = 4 + TOTAL_COLUMNS = 5 + _TABLE_ROW_HEIGHT = 40 + _TABLE_ICON_LOGICAL_SIZE = 32 + _TABLE_ICON_COLUMN_PADDING = 6 + _TABLE_LAUNCH_BUTTON_HEIGHT = 32 + _QWIDGETSIZE_MAX = 16_777_215 def __init__(self, data_manager: ApiClient, instance_manager: Optional[SingleInstanceApplication] = None): super().__init__() MainWindow.INSTANCE = self + self._pending_bottom_right_placement = False + self._presentation_window = self + self._host_ui_facade = None self.data_manager = data_manager self._instance_manager = instance_manager # 종료 시 정리를 위해 인스턴스 매니저 참조 저장 self.launcher = Launcher(run_as_admin=self.data_manager.global_settings.run_as_admin) @@ -126,17 +169,35 @@ def __init__(self, data_manager: ApiClient, instance_manager: Optional[SingleIns # Launcher 콜백 설정: 게임 런처 재시작 확인 self.launcher.launcher_restart_callback = self._on_launcher_restart_request - # statusBar, menuBar 명시적 생성 - self.setStatusBar(QStatusBar(self)) - self._remote_readiness_indicator_labels: dict[str, QLabel] = {} - self._setup_remote_readiness_indicators() + # 기존 readiness 데이터는 하단의 세 가지 간결한 상태 표시에만 사용합니다. + self._remote_readiness_states: dict[str, tuple[str, str]] = {} initial_api_error = getattr(self.data_manager, "last_connection_error", None) if initial_api_error: - self._set_remote_readiness_indicator("beholder", "red", f"API 초기 연결 실패: {initial_api_error}") + self._remote_readiness_states["beholder"] = ( + "red", + f"API 초기 연결 실패: {initial_api_error}", + ) self.setMenuBar(QMenuBar(self)) from src.core.process_monitor import ProcessMonitor # 순환 참조 방지를 위한 동적 임포트 self.process_monitor = ProcessMonitor(self.data_manager) + self._shutting_down = False + self._app_instance_id = str(self.data_manager.app_instance_id) + self._background_transport = BackgroundApiTransport(self._api_base_url()) + self._work_coordinator = GuiWorkCoordinator(self, max_threads=4) + self._work_coordinator.result_ready.connect(self._on_background_work_result) + self._work_coordinator.error_ready.connect(self._on_background_work_error) + self._lifecycle_shutdown_event = threading.Event() + self._lifecycle_session_lock = threading.Lock() + self._lifecycle_session_ids: dict[str, int] = {} + self._beholder_dialog_active = False + self._api_backoff_failures: dict[str, int] = {} + self._api_backoff_until: dict[str, float] = {} + self._timer_registry = DesiredTimerRegistry() + self._power_event_filter = WindowsPowerEventFilter(self._on_native_resume) + app_instance = QApplication.instance() + if app_instance is not None and sys.platform == "win32": + app_instance.installNativeEventFilter(self._power_event_filter) self.gui_notification_handler = GuiNotificationHandler(self) # GUI 알림 처리기 생성 self.system_notifier = Notifier( # 시스템 알림 객체 생성 (콜백을 생성자에 전달하여 시그널 연결 보장) QApplication.applicationName(), @@ -170,27 +231,18 @@ def __init__(self, data_manager: ApiClient, instance_manager: Optional[SingleIns self.preset_manager = GamePresetManager() self.setWindowTitle(QApplication.applicationName() or "숙제 관리자") # 창 제목 설정 + self.setWindowFlag(Qt.WindowType.WindowCloseButtonHint, True) self._ensure_background_survival_mode() - # 창 크기: 테이블/버튼 실제 sizeHint를 기반으로 동적으로 최적화합니다. - self.setMinimumSize(self._MIN_WINDOW_WIDTH, self._MIN_WINDOW_HEIGHT) - self.resize(470, 300) # 최초 표시 전 임시 크기 - - # QSettings 초기화 (창 위치/크기 저장용) + # QSettings 초기화 (Game Bar 복원 등 런타임 설정용) self._settings = QSettings(QSettings.Format.IniFormat, QSettings.Scope.UserScope, "HomeworkHelper", "display_settings") - # 절전 복귀 시 창 상태 복원을 위한 geometry 저장 변수 - self._saved_geometry = None - self._saved_size = None self._wake_recovery_in_progress = False - self._pending_window_anchor = self._load_window_anchor() self._mute_retry_tokens: dict[str, int] = {} self._volume_retry_tokens: dict[str, int] = {} self._beholder_seen_incidents: set[int] = set() self._beholder_restore_runtime_suspended = False - # 저장된 창 위치 복원 - self._restore_window_geometry() self._recover_gamebar_setting_if_needed() self._set_window_icon() # 창 아이콘 설정 @@ -253,7 +305,11 @@ def __init__(self, data_manager: ApiClient, instance_manager: Optional[SingleIns main_layout = QVBoxLayout(central_widget) # 메인 수직 레이아웃 생성 # 상단 버튼 영역 레이아웃 (게임 추가 버튼 + 동적 웹 버튼들 + 웹 바로가기 추가 버튼) - self.top_button_area_layout = QHBoxLayout() # 수평 레이아웃 + self.top_button_area = QWidget(central_widget) + self.top_button_area.setObjectName("topButtonArea") + self.top_button_area_layout = QHBoxLayout(self.top_button_area) # 수평 레이아웃 + self.top_button_area_layout.setContentsMargins(0, 0, 0, 0) + self.top_button_area_layout.setSpacing(4) self.add_game_button = QPushButton("새 게임 추가") # '새 게임 추가' 버튼 생성 self.add_game_button.clicked.connect(self.open_add_process_dialog) # 버튼 클릭 시그널 연결 self.top_button_area_layout.addWidget(self.add_game_button) # 레이아웃에 버튼 추가 @@ -267,20 +323,12 @@ def __init__(self, data_manager: ApiClient, instance_manager: Optional[SingleIns self.add_web_shortcut_button = QPushButton("+") # 웹 바로가기 추가 버튼 생성 self.add_web_shortcut_button.setToolTip("새로운 웹 바로 가기 버튼을 추가합니다.") # 툴팁 설정 - # '+' 버튼 크기를 텍스트에 맞게 조절 - font_metrics = self.add_web_shortcut_button.fontMetrics() - text_width = font_metrics.horizontalAdvance(" + ") # 텍스트 너비 계산 (양 옆 공백 포함) - icon_button_size = text_width + 8 # 아이콘 버튼 크기 (여유 공간 추가) - self.add_web_shortcut_button.setFixedSize(icon_button_size, icon_button_size) # 버튼 크기 고정 - self.add_web_shortcut_button.clicked.connect(self._open_add_web_shortcut_dialog) # 버튼 클릭 시그널 연결 self.top_button_area_layout.addWidget(self.add_web_shortcut_button) # 상단 버튼 영역에 웹 바로가기 추가 버튼 추가 # 대시보드 버튼 추가 - self.dashboard_button = QPushButton() + self.dashboard_button = QPushButton("📊") self.dashboard_button.setToolTip("통계 대시보드 열기") - self.dashboard_button.setText("📊") # 차트 이모지 - self.dashboard_button.setFixedSize(icon_button_size, icon_button_size) self.dashboard_button.clicked.connect(self._open_dashboard) self.top_button_area_layout.addWidget(self.dashboard_button) @@ -289,7 +337,6 @@ def __init__(self, data_manager: ApiClient, instance_manager: Optional[SingleIns self.github_button.setToolTip("GitHub 저장소 방문") self.github_button.setText("GH") # 아이콘 로딩 전 기본 텍스트 # 크기를 다른 아이콘 버튼과 맞춤 - self.github_button.setFixedSize(icon_button_size, icon_button_size) self.github_button.clicked.connect(lambda: self.open_webpage("https://github.com/lsh930309/HomeworkHelperServer")) self.top_button_area_layout.addWidget(self.github_button) @@ -308,42 +355,57 @@ def __init__(self, data_manager: ApiClient, instance_manager: Optional[SingleIns self.icon_downloader.icon_ready.connect(self.set_github_button_icon) # 아이콘 다운로더에 연결 self.icon_downloader.start() - main_layout.addLayout(self.top_button_area_layout) # 메인 레이아웃에 상단 버튼 영역 추가 - - # 프로세스 테이블 설정 - self.process_table = QTableWidget() # 테이블 위젯 생성 - self.process_table.setColumnCount(self.TOTAL_COLUMNS) # 컬럼 개수 설정 - self.process_table.setHorizontalHeaderLabels(["", "이름", "진행률", "실행", "상태"]) # 헤더 라벨 설정 - self._configure_table_header() # 테이블 헤더 상세 설정 - self.process_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) # 편집 불가 설정 - self.process_table.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection) # 선택 불가 설정 - self.process_table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) # 컨텍스트 메뉴 정책 설정 - self.process_table.customContextMenuRequested.connect(self.show_table_context_menu) # 컨텍스트 메뉴 요청 시그널 연결 + main_layout.addWidget(self.top_button_area, 0, Qt.AlignmentFlag.AlignHCenter) - # 테이블 크기 정책 설정 - 스크롤바 없이 내용에 맞게 조절 + self.process_table = QTableWidget(self) + self.process_table.setObjectName("processTable") + self.process_table.setColumnCount(self.TOTAL_COLUMNS) + self.process_table.setHorizontalHeaderLabels(["", "이름", "진행률", "실행", "상태"]) + self._configure_table_header() + self.process_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + self.process_table.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection) + self.process_table.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self.process_table.setShowGrid(False) + self.process_table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.process_table.customContextMenuRequested.connect(self.show_table_context_menu) self.process_table.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.process_table.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - - # 테이블 행 높이 및 아이콘 크기 설정 - vh = self.process_table.verticalHeader() - if vh: - vh.setDefaultSectionSize(self._TABLE_ROW_HEIGHT) - vh.setMinimumSectionSize(self._TABLE_ROW_HEIGHT) - - # 아이콘 크기: Image #1에 가까운 압축 비율을 유지하면서도 캐시 아이콘을 선명하게 배치합니다. - # DPI 배율은 get_qicon_for_file 내부에서 적용합니다. - self._table_icon_logical_size = self._TABLE_ICON_LOGICAL_SIZE - self.process_table.setIconSize(QSize(self._table_icon_logical_size, self._table_icon_logical_size)) - - main_layout.addWidget(self.process_table) # 메인 레이아웃에 테이블 추가 + self.process_table.verticalHeader().setDefaultSectionSize(self._TABLE_ROW_HEIGHT) + self.process_table.verticalHeader().setMinimumSectionSize(self._TABLE_ROW_HEIGHT) + self.process_table.setIconSize(QSize(self._TABLE_ICON_LOGICAL_SIZE, self._TABLE_ICON_LOGICAL_SIZE)) + main_layout.addWidget(self.process_table, 0, Qt.AlignmentFlag.AlignHCenter) + + self.readiness_strip = QWidget(central_widget) + self.readiness_strip.setObjectName("readinessStrip") + readiness_layout = QHBoxLayout(self.readiness_strip) + readiness_layout.setContentsMargins(6, 6, 6, 3) + readiness_layout.setSpacing(8) + self._readiness_status_widgets: dict[str, tuple[QLabel, QLabel]] = {} + for key in ("beholder", "remote", "admin"): + item = QWidget(self.readiness_strip) + item.setProperty("hhRole", "readinessItem") + item_layout = QHBoxLayout(item) + item_layout.setContentsMargins(0, 0, 0, 0) + item_layout.setSpacing(4) + dot = QLabel("●", item) + dot.setProperty("hhRole", "readinessDot") + text = QLabel(item) + text.setProperty("hhRole", "readinessText") + item_layout.addWidget(dot) + item_layout.addWidget(text) + item_layout.addStretch(1) + readiness_layout.addWidget(item, 1) + self._readiness_status_widgets[key] = (dot, text) + main_layout.addWidget(self.readiness_strip, 0, Qt.AlignmentFlag.AlignHCenter) + self._setup_remote_readiness_indicators() # 초기 데이터 로드 및 UI 업데이트 self.populate_process_list() # 프로세스 목록 채우기 self._load_and_display_web_buttons() # 웹 바로가기 버튼 로드 및 표시 - self._adjust_window_height_for_table_rows() # 테이블 내용에 맞게 창 높이 조절 + self._adjust_window_size_to_content() # 시그널 및 타이머 설정 - self.request_table_refresh_signal.connect(self.populate_process_list_slot) # 테이블 새로고침 시그널 연결 + self.request_table_refresh_signal.connect(self.populate_process_list_slot) self._last_timer_tick = time.time() # 절전 복귀 감지용 마지막 타이머 틱 시간 self._ui_refresh_tick_count = 0 self.monitor_timer = QTimer(self) @@ -367,6 +429,15 @@ def __init__(self, data_manager: ApiClient, instance_manager: Optional[SingleIns self.remote_readiness_timer = QTimer(self) self.remote_readiness_timer.timeout.connect(self._refresh_remote_readiness_indicators) self.remote_readiness_timer.start(5000) + for timer_name, timer, interval in ( + ("monitor", self.monitor_timer, 1000), + ("scheduler", self.scheduler_timer, 1000), + ("ui_refresh", self.ui_refresh_timer, self._UI_REFRESH_INTERVAL_MS), + ("beholder", self.beholder_timer, 1500), + ("heartbeat", self.runtime_heartbeat_timer, 30000), + ("readiness", self.remote_readiness_timer, 5000), + ): + self._timer_registry.register(timer_name, timer, interval_ms=interval) # Reconcile stale open sessions before the first fresh heartbeat so # crash-recovery decisions can use the pre-crash heartbeat. QTimer.singleShot(300, self._reconcile_open_sessions_after_startup) @@ -382,6 +453,8 @@ def __init__(self, data_manager: ApiClient, instance_manager: Optional[SingleIns self._record_status_event("준비 완료.") + self._apply_widgets_presentation() + self.apply_startup_setting() # 시작 프로그램 설정 적용 @@ -405,108 +478,284 @@ def _hide_main_window_to_tray(self, reason: str): def _record_status_event(self, message: str, *_args: object) -> None: - """Keep legacy transient UI messages out of the persistent indicator bar.""" + """Record transient UI events without creating a persistent status bar.""" logger.info("UI status event: %s", message) def _setup_remote_readiness_indicators(self) -> None: - """Add textless, compact readiness indicators to the persistent status bar.""" - status_bar = self.statusBar() - if status_bar is None: + """기존 readiness 상태를 하단의 세 가지 읽기 전용 표시에 연결합니다.""" + for key in ("beholder", "remote", "admin"): + self._remote_readiness_states.setdefault(key, ("gray", "상태를 확인 중입니다.")) + self._refresh_readiness_strip() + + def _submit_telemetry(self, key: str, function, *args, **kwargs) -> int | None: + """실패한 endpoint의 짧은 backoff를 지키며 최신 작업만 접수합니다.""" + if self._shutting_down or self._beholder_restore_runtime_suspended: + return None + if time.monotonic() < self._api_backoff_until.get(key, 0.0): + return None + return self._work_coordinator.submit_telemetry(key, function, *args, **kwargs) + + @Slot(object) + def _on_background_work_result(self, result: WorkResult) -> None: + self._api_backoff_failures.pop(result.key, None) + self._api_backoff_until.pop(result.key, None) + if result.lane == "lifecycle": + self._apply_lifecycle_persistence_result(result.value) + return + value = result.value + if result.key == "process_scan" and isinstance(value, ProcessScanSnapshot): + self._apply_process_scan_snapshot(value) + elif result.key == "remote_readiness" and isinstance(value, Mapping): + for key, state in value.items(): + if isinstance(state, tuple) and len(state) == 2: + self._set_remote_readiness_indicator(str(key), str(state[0]), str(state[1])) + elif result.key in {"beholder_incidents", "startup_reconcile"}: + incidents = tuple(item for item in value if isinstance(item, Mapping)) if isinstance(value, tuple) else () + if incidents: + self._apply_beholder_incidents(incidents) + + @Slot(object) + def _on_background_work_error(self, error: WorkError) -> None: + failures = self._api_backoff_failures.get(error.key, 0) + 1 + self._api_backoff_failures[error.key] = failures + delay = (1.0, 2.0, 5.0, 10.0, 30.0)[min(failures - 1, 4)] + self._api_backoff_until[error.key] = time.monotonic() + delay + logger.warning( + "GUI background work failed: lane=%s key=%s error=%s: %s retry_after=%.1fs", + error.lane, + error.key, + error.exception_type, + error.message, + delay, + ) + if error.key == "remote_readiness": + self._set_remote_readiness_indicator("beholder", "red", f"상태 확인 실패: {error.message}") + + def _persist_lifecycle_command(self, command: _LifecycleCommand) -> _LifecyclePersistenceResult: + retry_delays = (1.0, 2.0, 5.0, 10.0, 30.0) + last_error: str | None = None + session_id = command.event.session_id + if not str(command.event.process_id or "").strip() or not str(command.runtime_token or "").strip(): + logger.error( + "불완전한 lifecycle 명령 거부: kind=%s process_id=%r runtime_token=%r", + command.kind, + command.event.process_id, + command.runtime_token, + ) + return _LifecyclePersistenceResult(command, session_id, False, 0, "invalid lifecycle identity") + for attempt in range(1, len(retry_delays) + 2): + if self._lifecycle_shutdown_event.is_set(): + return _LifecyclePersistenceResult(command, session_id, False, attempt - 1, "shutdown") + try: + if command.kind == "start": + payload = self._background_transport.start_session( + app_instance_id=self._app_instance_id, + process_id=command.event.process_id, + process_name=command.event.process_name, + pid=command.pid, + process_create_time=command.process_create_time, + timeout=10.0, + ) + session_id = int(payload["id"]) + with self._lifecycle_session_lock: + self._lifecycle_session_ids[command.runtime_token] = session_id + return _LifecyclePersistenceResult(command, session_id, True, attempt) + + if session_id is None: + with self._lifecycle_session_lock: + session_id = self._lifecycle_session_ids.get(command.runtime_token) + if session_id is None: + active = self._background_transport.get_json( + f"/sessions/process/{command.event.process_id}/active", timeout=10.0 + ) + if isinstance(active.payload, Mapping) and active.payload.get("id") is not None: + session_id = int(active.payload["id"]) + if session_id is None: + raise RuntimeError("active lifecycle session not found") + self._background_transport.end_session( + session_id=session_id, + end_timestamp=command.event.timestamp, + stamina_at_end=command.event.stamina_at_end, + resource_percent_at_end=command.event.resource_percent_at_end, + timeout=10.0, + ) + self._background_transport.patch_json( + f"/processes/{command.event.process_id}/runtime-state", + {"last_played_timestamp": command.event.timestamp}, + timeout=10.0, + headers={ + "X-HH-Beholder-Actor": "process_monitor", + "X-HH-Beholder-Operation": "process_runtime_state_update", + }, + ) + with self._lifecycle_session_lock: + self._lifecycle_session_ids.pop(command.runtime_token, None) + return _LifecyclePersistenceResult(command, session_id, True, attempt) + except BeholderIncidentRequired as exc: + return _LifecyclePersistenceResult( + command, + session_id, + False, + attempt, + "beholder_blocked", + True, + exc.incident, + ) + except DatabaseFaultedResponse as exc: + last_error = str(exc) + break + except Exception as exc: + last_error = f"{type(exc).__name__}: {exc}" + if attempt > len(retry_delays): + break + if self._lifecycle_shutdown_event.wait(retry_delays[attempt - 1]): + return _LifecyclePersistenceResult(command, session_id, False, attempt, "shutdown") + + try: + self._background_transport.post_json( + "/api/beholder/runtime/lifecycle-failure", + { + "kind": command.kind, + "process_id": command.event.process_id, + "process_name": command.event.process_name, + "runtime_token": command.runtime_token, + "attempts": attempt, + "error_type": last_error or "unknown", + }, + timeout=5.0, + ) + except Exception: + logger.warning("lifecycle failure incident 기록 실패", exc_info=True) + return _LifecyclePersistenceResult(command, session_id, False, attempt, last_error) + + @Slot(object) + def _apply_lifecycle_persistence_result(self, value: object) -> None: + if not isinstance(value, _LifecyclePersistenceResult): + return + command = value.command + active = self.process_monitor.active_monitored_processes + if value.blocked: + logger.info( + "Beholder가 lifecycle 저장을 차단함: kind=%s process_id=%s incident_id=%s", + command.kind, + command.event.process_id, + (value.beholder_incident or {}).get("id"), + ) + if value.beholder_incident: + self._apply_beholder_incidents((dict(value.beholder_incident),)) return - status_bar.setStyleSheet("QStatusBar::item { border: 0px; }") - for key, glyph in [ - ("beholder", "●"), - ("remote", "●"), - ("admin", "●"), - ]: - widget = QLabel(glyph, self) - widget.setObjectName(f"remoteReadiness_{key}") - widget.setAlignment(Qt.AlignmentFlag.AlignCenter) - widget.setFixedWidth(24) - widget.setToolTip("원격 제어 준비 상태를 확인 중입니다.") - widget.setStyleSheet( - f"QLabel#{widget.objectName()} {{ color: #808080; background: transparent; border: 0px; padding: 0px; }}" + if not value.succeeded: + logger.warning( + "lifecycle persistence exhausted: kind=%s process_id=%s attempts=%s error=%s", + command.kind, + command.event.process_id, + value.attempts, + value.error, ) - status_bar.addPermanentWidget(widget) - self._remote_readiness_indicator_labels[key] = widget + self._poll_beholder_incidents() + return + event = replace(command.event, session_id=value.session_id) + if command.kind == "start": + entry = active.get(event.process_id) + if entry is not None and entry.get("runtime_token") == command.runtime_token: + entry["session_id"] = value.session_id + self._hoyolab_reconcile.handle_process_started(event) + self._nikke_resource_reconcile.handle_process_started(event) + else: + process = next((item for item in self.data_manager.managed_processes if item.id == event.process_id), None) + if process is not None: + process.last_played_timestamp = event.timestamp + self._hoyolab_reconcile.handle_process_stopped(event) + self._nikke_resource_reconcile.handle_process_stopped(event) + self.update_process_statuses_only() def _set_remote_readiness_indicator(self, key: str, color: str, message: str) -> None: - widget = self._remote_readiness_indicator_labels.get(key) - if widget is None: + if key not in self._remote_readiness_states: + return + self._remote_readiness_states[key] = (color, message) + self._refresh_readiness_strip() + + def _refresh_readiness_strip(self) -> None: + widgets = getattr(self, "_readiness_status_widgets", None) + if not widgets: return - palette = { - "green": ("#22c55e", "rgba(34, 197, 94, 45)", "rgba(34, 197, 94, 125)"), - "yellow": ("#eab308", "rgba(234, 179, 8, 42)", "rgba(234, 179, 8, 120)"), - "red": ("#ef4444", "rgba(239, 68, 68, 45)", "rgba(239, 68, 68, 125)"), - "gray": ("#808080", "rgba(128, 128, 128, 22)", "rgba(128, 128, 128, 55)"), + labels = { + "beholder": { + "green": "데이터 정상", "yellow": "데이터 확인", "red": "데이터 오류", "gray": "데이터 확인 중", + }, + "remote": { + "green": "원격 연결", "yellow": "원격 준비 중", "red": "원격 오류", "gray": "원격 미설정", + }, + "admin": { + "green": "관리자 권한", "yellow": "권한 확인", "red": "권한 오류", "gray": "일반 권한", + }, } - foreground, background, border = palette.get(color, palette["gray"]) - widget.setStyleSheet( - f""" - QLabel#{widget.objectName()} {{ - color: {foreground}; - background-color: {background}; - border: 1px solid {border}; - border-radius: 8px; - padding: 0px; - font-size: 13px; - font-weight: 900; - }} - """ - ) - glow = QGraphicsDropShadowEffect(widget) - glow.setBlurRadius(14 if color != "gray" else 6) - glow.setColor(QColor(foreground)) - glow.setOffset(0, 0) - widget.setGraphicsEffect(glow) - widget.setToolTip(message) + layout_changed = False + for key in ("beholder", "remote", "admin"): + color, message = self._remote_readiness_states[key] + state = color if color in {"green", "yellow", "red"} else "gray" + dot, text = widgets[key] + dot.setProperty("hhState", state) + new_text = labels[key][state] + if text.text() != new_text: + text.setText(new_text) + layout_changed = True + text.setToolTip(message) + dot.setToolTip(message) + for control in (dot, text): + control.style().unpolish(control) + control.style().polish(control) + if layout_changed and hasattr(self, "process_table"): + QTimer.singleShot(0, self._adjust_window_size_to_content) def _refresh_remote_readiness_indicators(self) -> None: - """Refresh bottom-dot readiness without touching transient status messages.""" - try: - incidents = self.data_manager.get_active_beholder_incidents() if hasattr(self.data_manager, "get_active_beholder_incidents") else [] - if incidents: - self._set_remote_readiness_indicator("beholder", "yellow", f"Beholder incident {len(incidents)}건 확인 필요") - else: - self._set_remote_readiness_indicator("beholder", "green", "Beholder 대기 중인 incident 없음") - except Exception as exc: - self._set_remote_readiness_indicator("beholder", "red", f"Beholder 상태 확인 실패: {exc}") + """Readiness I/O를 하나의 coalesced worker에서 조회합니다.""" + api_host = os.environ.get("HH_API_HOST", "127.0.0.1") + externally_bound = api_host not in {"127.0.0.1", "localhost", "::1"} + has_token = bool(os.environ.get("HH_REMOTE_TOKEN")) + remote_server_mode_enabled = bool(getattr(self.data_manager.global_settings, "remote_server_mode_enabled", False)) + self._submit_telemetry( + "remote_readiness", + self._collect_remote_readiness, + externally_bound or remote_server_mode_enabled or has_token, + ) - tailscale_message = "Tailscale 상태 미확인" - tailscale_ready = False - tailscale_installed = False + def _collect_remote_readiness(self, remote_exposed: bool) -> dict[str, tuple[str, str]]: + incidents_result = self._background_transport.get_json( + "/api/beholder/incidents/active", timeout=5.0 + ) + payload = incidents_result.payload if isinstance(incidents_result.payload, Mapping) else {} + incidents = payload.get("incidents") or [] + beholder = ( + ("yellow", f"Beholder incident {len(incidents)}건 확인 필요") + if incidents + else ("green", "Beholder 대기 중인 incident 없음") + ) try: snapshot = tailscale_status(timeout_seconds=0.8, cache_ttl_seconds=30.0) tailscale_ready = snapshot.ready tailscale_installed = snapshot.installed tailscale_message = f"Tailscale IP: {', '.join(snapshot.self_ips)}" if snapshot.ready else snapshot.message except Exception as exc: + tailscale_ready = False + tailscale_installed = False tailscale_message = f"Tailscale 상태 확인 실패: {exc}" - - api_host = os.environ.get("HH_API_HOST", "127.0.0.1") - externally_bound = api_host not in {"127.0.0.1", "localhost", "::1"} - has_token = bool(os.environ.get("HH_REMOTE_TOKEN")) - remote_server_mode_enabled = bool(getattr(self.data_manager.global_settings, "remote_server_mode_enabled", False)) - remote_exposed = externally_bound or remote_server_mode_enabled or has_token remote_ready = remote_exposed and tailscale_ready if remote_ready: - remote_color = "green" - remote_message = f"Remote ready · {tailscale_message}" + remote = ("green", f"Remote ready · {tailscale_message}") elif remote_exposed or tailscale_installed: - remote_color = "yellow" - remote_message = ( - f"Remote 준비 중 · exposed={remote_exposed} · tailscale={tailscale_message}" - ) + remote = ("yellow", f"Remote 준비 중 · exposed={remote_exposed} · tailscale={tailscale_message}") else: - remote_color = "gray" - remote_message = "Remote 설정 전입니다. 설정 > 원격 설정에서 최초 페어링을 진행하세요." - self._set_remote_readiness_indicator("remote", remote_color, remote_message) - - self._set_remote_readiness_indicator( - "admin", - "green" if is_admin() else "gray", - "관리자 권한으로 실행 중입니다." if is_admin() else "일반 사용자 권한으로 실행 중입니다.", - ) + remote = ("gray", "Remote 설정 전입니다. 설정 > 원격 설정에서 최초 페어링을 진행하세요.") + admin = is_admin() + return { + "beholder": beholder, + "remote": remote, + "admin": ( + "green" if admin else "gray", + "관리자 권한으로 실행 중입니다." if admin else "일반 사용자 권한으로 실행 중입니다.", + ), + } def _api_base_url(self) -> str: return resolve_local_api_base_url(getattr(self.data_manager, "base_url", None)) @@ -515,42 +764,54 @@ def _dashboard_url(self) -> str: return dashboard_url(self._api_base_url()) def _open_dashboard(self) -> None: - base_url = self._api_base_url() - try: - response = requests.get(gui_health_url(base_url), timeout=0.8) - if response.status_code == 200: - payload = response.json() - if not payload.get("dashboard_static_ready", True): - logger.warning("Dashboard static files are not ready: %s", payload) - else: - logger.warning("Dashboard health check failed before open: status=%s", response.status_code) - except Exception as exc: - logger.warning("Dashboard health check failed before open: %s", exc) + # 대시보드 자체가 API 오류와 재시도를 표시한다. 버튼 callback에서 + # 별도 health 요청을 기다리지 않아 브라우저 열기를 즉시 처리한다. self.open_webpage(self._dashboard_url()) def _poll_beholder_incidents(self): - """Show Beholder incidents promptly in the PyQt main GUI.""" + """로컬 pending 사건을 우선 소비하고 원격 조회는 worker에 맡깁니다.""" + if self._beholder_dialog_active: + return incident = self.data_manager.pop_latest_beholder_incident() - incidents = [incident] if incident else self.data_manager.get_active_beholder_incidents() + if incident: + self._apply_beholder_incidents((incident,)) + return + self._submit_telemetry("beholder_incidents", self._collect_beholder_incidents) + + def _collect_beholder_incidents(self) -> tuple[dict[str, Any], ...]: + result = self._background_transport.get_json("/api/beholder/incidents/active", timeout=5.0) + payload = result.payload if isinstance(result.payload, Mapping) else {} + return tuple(item for item in payload.get("incidents", ()) if isinstance(item, dict)) + + def _apply_beholder_incidents(self, incidents: tuple[dict[str, Any], ...]) -> None: + if self._beholder_dialog_active: + return for item in incidents: - if not item: + if not item or item.get("status") != "pending": continue incident_id = item.get("id") if incident_id in self._beholder_seen_incidents: continue + # 모달의 중첩 이벤트 루프에서도 polling timer가 실행될 수 있으므로 + # 창을 열기 전에 표시 완료와 active 상태를 먼저 확정합니다. 닫기/X는 + # DB 결정을 남기지 않지만 이번 앱 실행에서는 다시 표시하지 않습니다. + if incident_id is not None: + self._beholder_seen_incidents.add(incident_id) + self._beholder_dialog_active = True self.showNormal() self.raise_() self.activateWindow() dialog = BeholderIncidentDialog(item, self) - if not dialog.exec() or not dialog.action: + try: + accepted = bool(dialog.exec()) + finally: + self._beholder_dialog_active = False + if not accepted or not dialog.action: break if dialog.action == "restore_backup": - if self._handle_beholder_restore_request() and incident_id is not None: - self._beholder_seen_incidents.add(incident_id) + self._handle_beholder_restore_request() elif incident_id is not None: result = self.data_manager.resolve_beholder_incident(incident_id, dialog.action) - if result: - self._beholder_seen_incidents.add(incident_id) if result and hasattr(self, "process_monitor"): self.process_monitor.apply_beholder_resolution(result) if dialog.action == "allow_once" and result and result.get("override_token"): @@ -568,16 +829,32 @@ def _apply_sidebar_startup_mode(self) -> None: controller.apply_settings(self.data_manager.global_settings) def _send_runtime_heartbeat(self): - if hasattr(self.data_manager, "send_runtime_heartbeat"): - self.data_manager.send_runtime_heartbeat(runtime_kind="pyqt") + self._submit_telemetry("runtime_heartbeat", self._collect_runtime_heartbeat, False) + + def _collect_runtime_heartbeat(self, shutdown: bool) -> object: + return self._background_transport.post_json( + "/api/beholder/runtime/heartbeat", + { + "app_instance_id": self._app_instance_id, + "runtime_kind": "pyside", + "shutdown": bool(shutdown), + }, + timeout=5.0, + ).payload def _reconcile_open_sessions_after_startup(self): - if not hasattr(self.data_manager, "reconcile_open_sessions"): - return - running_ids = list(self.process_monitor.detect_running_process_ids()) - incidents = self.data_manager.reconcile_open_sessions(running_ids) - if incidents: - self._poll_beholder_incidents() + targets = self.process_monitor.process_scan_targets() + self._submit_telemetry("startup_reconcile", self._collect_startup_reconcile, targets) + + def _collect_startup_reconcile(self, targets: tuple[object, ...]) -> tuple[dict[str, Any], ...]: + running_ids = sorted(detect_running_process_ids(targets)) + result = self._background_transport.post_json( + "/api/beholder/open-sessions/reconcile", + {"running_process_ids": running_ids}, + timeout=10.0, + ) + payload = result.payload if isinstance(result.payload, Mapping) else {} + return tuple(item for item in payload.get("incidents", ()) if isinstance(item, dict)) def _handle_beholder_restore_request(self) -> bool: backups = self.data_manager.get_beholder_backups() @@ -622,10 +899,8 @@ def _suspend_runtime_after_beholder_restore(self) -> None: self._beholder_restore_runtime_suspended = True if hasattr(self, "process_monitor"): self.process_monitor.active_monitored_processes.clear() - for timer_name in ("monitor_timer", "scheduler_timer", "runtime_heartbeat_timer"): - timer = getattr(self, timer_name, None) - if timer is not None and timer.isActive(): - timer.stop() + self._timer_registry.suspend("database_restore", ("monitor", "scheduler", "heartbeat")) + self._work_coordinator.invalidate_telemetry() def set_github_button_icon(self, icon: QIcon): """IconDownloader로부터 받은 아이콘을 GitHub 버튼에 설정합니다.""" @@ -646,34 +921,38 @@ def changeEvent(self, event: QEvent): # 타이머 상태 확인 및 재시작 (절전 복귀 대응) self._ensure_timers_running() - if self._saved_size: - # 저장된 크기와 현재 크기 비교 - current_size = self.size() - if current_size != self._saved_size: - QTimer.singleShot(100, self._restore_window_state) - super().changeEvent(event) def showEvent(self, event): """창이 표시될 때 호출됩니다.""" super().showEvent(event) - # Qt6 자동 High DPI 스케일링에 의존하므로 수동 레이아웃 새로고침 불필요 - QTimer.singleShot(0, self._sync_windows_title_bar_color) + # 최종 콘텐츠 피팅이 끝난 뒤 네이티브 외곽 프레임을 우하단에 맞춥니다. + self._pending_bottom_right_placement = True + QTimer.singleShot(0, self._apply_widgets_presentation) # 트레이/복원/플랫폼별 native show 타이밍 이후에도 사이드바 모드를 # 한 번 더 반영해, always 모드 손잡이가 시작 시점 경합으로 누락되는 # 경로를 줄입니다. QTimer.singleShot(0, self._apply_sidebar_startup_mode) + def moveEvent(self, event): + """Windows에서 보이는 창 프레임을 가까운 작업 영역 경계에 붙입니다.""" + super().moveEvent(event) + try: + snap_windows_window_to_work_area( + int(self.winId()), + threshold_logical=15, + ) + except Exception: + logger.debug("Windows 창 이동 자석 적용 실패", exc_info=True) + def _on_monitor_timer_tick(self): - """프로세스 모니터 타이머 틱 처리 (절전 복귀 감지 포함)""" + """타이머 지연을 기록하고 다음 process snapshot을 요청합니다.""" start_time = time.time() current_time = time.time() elapsed = current_time - self._last_timer_tick - # 10초 이상 경과했으면 절전 복귀로 판단 (정상: 1초 간격, 이전 5초 → 10초로 증가하여 오탐 방지) if elapsed > 10: - logger.warning(f"절전 복귀 감지: 타이머 간격 {elapsed:.1f}초 (정상: 1초)") - self._on_sleep_wake() + logger.warning("monitor timer gap: %.1fs (resume으로 간주하지 않음)", elapsed) self._last_timer_tick = current_time self.run_process_monitor_check() @@ -683,64 +962,27 @@ def _on_monitor_timer_tick(self): if execution_time > 100: logger.warning(f"monitor_timer 실행 시간 초과: {execution_time:.1f}ms") - def _on_sleep_wake(self): - """절전 복귀 시 호출되는 메서드 - - 절전모드 복귀 후 UI 갱신이 멈추는 문제 해결: - - 타이머는 작동하지만 Qt 렌더링이 트리거되지 않는 문제 대응 - - 무거운 갱신을 한 이벤트 루프에 몰지 않고 단계화해 복귀 직후 버벅임을 줄임 - """ + def _on_native_resume(self, _event: PowerResumeEvent) -> None: + """Windows native 복귀 신호 한 건을 가벼운 비동기 복구로 변환합니다.""" if self._wake_recovery_in_progress: - logger.info("절전 복귀 UI 갱신이 이미 진행 중이므로 중복 요청을 건너뜁니다.") return - self._wake_recovery_in_progress = True - logger.info("절전 복귀 감지 - UI 단계적 갱신 시작") - - # 타이머 상태 확인 및 재시작 - self._ensure_timers_running() + logger.info("Windows native resume 감지") + self._work_coordinator.invalidate_telemetry() + self._timer_registry.restart_desired() + QTimer.singleShot(2000, lambda: self._submit_telemetry( + "resume_ping", self._background_transport.get_json, "/api/gui/ping", timeout=2.0 + )) + QTimer.singleShot(3000, self.run_process_monitor_check) + QTimer.singleShot(6000, self._refresh_remote_readiness_indicators) + QTimer.singleShot(15000, self._run_wake_provider_followups) + QTimer.singleShot(16000, self._finish_native_resume) + + def _run_wake_provider_followups(self) -> None: if hasattr(self, "_daily_checkin"): self._daily_checkin.handle_wake_recovery() - QTimer.singleShot(0, self._run_sleep_wake_refresh) - - def _run_sleep_wake_refresh(self): - """절전 복귀 후 UI를 단계적으로 갱신합니다.""" - refresh_start = time.time() - try: - # 테이블 전체 다시 채우기 (모든 위젯 강제 재생성) - # 이렇게 하면 Progress Bar, 상태 컬럼 등 모든 셀이 현재 시간에 맞게 다시 그려짐 - populate_start = time.time() - self.populate_process_list() - populate_ms = (time.time() - populate_start) * 1000 - - # 웹 버튼 상태 갱신 - web_start = time.time() - self._refresh_web_button_states() - web_ms = (time.time() - web_start) * 1000 - - # 동기 repaint()는 복귀 직후 GUI 스레드 정체를 키울 수 있으므로 update()만 요청합니다. - if self.process_table.viewport(): - self.process_table.viewport().update() - - # 창 크기 복원 (절전 복귀 시 창 렌더링 문제 대응) - if self._saved_size: - QTimer.singleShot(100, self._restore_window_state) - - total_ms = (time.time() - refresh_start) * 1000 - if total_ms > 100: - logger.warning( - "절전 복귀 UI 갱신 지연: total=%.1fms populate=%.1fms web=%.1fms", - total_ms, - populate_ms, - web_ms, - ) - else: - logger.info("절전 복귀 UI 갱신 완료: total=%.1fms", total_ms) - finally: - QTimer.singleShot(250, self._finish_sleep_wake_refresh) - - def _finish_sleep_wake_refresh(self): + def _finish_native_resume(self) -> None: self._wake_recovery_in_progress = False def _on_ui_refresh_tick(self) -> None: @@ -759,58 +1001,34 @@ def _on_ui_refresh_tick(self) -> None: logger.warning(f"ui_refresh_timer 실행 시간 초과: {execution_time:.1f}ms") def _ensure_timers_running(self): - """모든 주기적 타이머가 실행 중인지 확인하고, 중단된 경우 재시작합니다. - - Windows 절전 모드(슬립/최대 절전)에서 복귀할 때 QTimer가 중단될 수 있으므로, - 타이머 상태를 확인하고 필요시 재시작합니다. - """ - timers_restarted = [] - - runtime_suspended = getattr(self, "_beholder_restore_runtime_suspended", False) - - if not runtime_suspended and hasattr(self, 'monitor_timer') and not self.monitor_timer.isActive(): - self.monitor_timer.start(1000) - timers_restarted.append('monitor_timer') - - if not runtime_suspended and hasattr(self, 'scheduler_timer') and not self.scheduler_timer.isActive(): - self.scheduler_timer.start(1000) - timers_restarted.append('scheduler_timer') - - if hasattr(self, 'ui_refresh_timer') and not self.ui_refresh_timer.isActive(): - self.ui_refresh_timer.start(self._UI_REFRESH_INTERVAL_MS) - timers_restarted.append('ui_refresh_timer') + """등록된 정상 실행 의도만 복원합니다.""" + self._timer_registry.restart_desired() def _restore_window_state(self): - """절전 복귀 후 창 상태를 복원합니다. - - 핵심: 창 크기를 +1/-1 픽셀 조정하여 Qt 렌더링 파이프라인을 강제 초기화. - 이 방법이 Windows DWM과 Qt 간의 좌표 불일치를 해결하는 가장 확실한 방법입니다. - """ - # 1. 창 크기 +1 픽셀 조정 후 복구 (렌더링 파이프라인 강제 초기화) - # 이 트릭이 유령 렌더링(Ghost Window)을 제거하는 핵심입니다. - w, h = self.width(), self.height() - self.setFixedSize(w + 1, h + 1) - self.setFixedSize(w, h) - - # 2. 저장된 geometry가 있으면 위치도 복원 - if self._saved_geometry: - self.move(self._saved_geometry.x(), self._saved_geometry.y()) - - # 3. 레이아웃 강제 업데이트 + """절전 복귀 후 레이아웃과 고정 피팅 크기를 다시 적용합니다.""" central_widget = self.centralWidget() if central_widget and central_widget.layout(): central_widget.layout().invalidate() central_widget.layout().activate() - - # 4. 비동기 다시 그리기 요청 + self._adjust_window_size_to_content() self.update() def activate_and_show(self): """IPC 등을 통해 외부에서 창을 활성화하고 표시하도록 요청받았을 때 호출됩니다.""" + if self._presentation_window is not self and self._host_ui_facade is not None: + self._host_ui_facade.activateAndShow() + return self.showNormal() # 창을 보통 크기로 표시 (최소화/숨김 상태에서 복원) self.activateWindow() # 창 활성화 (포커스 가져오기) self.raise_() # 창을 최상단으로 올림 + def set_presentation_window(self, window, facade=None) -> None: + self._presentation_window = window or self + self._host_ui_facade = facade + + def presentation_window(self): + return self._presentation_window + def open_webpage(self, url: str): """주어진 URL을 기본 웹 브라우저에서 엽니다.""" if not QDesktopServices.openUrl(QUrl(url)): @@ -827,27 +1045,54 @@ def _set_window_icon(self): style = QApplication.style() self.setWindowIcon(style.standardIcon(QStyle.StandardPixmap.SP_ComputerIcon)) - def _configure_table_header(self): - h = self.process_table.horizontalHeader() - if h: - h.hide() - h.setSectionsClickable(False) - h.setHighlightSections(False) - for col in range(self.TOTAL_COLUMNS): - h.setSectionResizeMode(col, QHeaderView.ResizeMode.Fixed) - - vh = self.process_table.verticalHeader() - if vh: - vh.hide() - vh.setHidden(True) - vh.setVisible(False) - vh.setMinimumWidth(0) - vh.setMaximumWidth(0) - vh.setFixedWidth(0) - vh.setSectionsClickable(False) - vh.setHighlightSections(False) + def _configure_table_header(self) -> None: + """헤더 장식 없이 열 정렬만 유지하는 압축형 게임 표를 구성합니다.""" + horizontal = self.process_table.horizontalHeader() + horizontal.hide() + horizontal.setSectionsClickable(False) + horizontal.setHighlightSections(False) + for column in range(self.TOTAL_COLUMNS): + horizontal.setSectionResizeMode(column, QHeaderView.ResizeMode.Fixed) + + vertical = self.process_table.verticalHeader() + vertical.hide() + vertical.setMinimumWidth(0) + vertical.setMaximumWidth(0) + vertical.setSectionsClickable(False) + vertical.setHighlightSections(False) self.process_table.setCornerButtonEnabled(False) + def _table_default_colors(self) -> tuple[QColor, QColor]: + tokens = widgets_theme_tokens(self._is_effective_dark_theme()) + return QColor(tokens["surface_raised"]), QColor(tokens["text"]) + + def _apply_table_status(self, item: QTableWidgetItem, status: str) -> None: + """현재 공통 테마의 semantic 색상을 상태 셀 하나에 적용합니다.""" + tokens = widgets_theme_tokens(self._is_effective_dark_theme()) + background = tokens["surface_raised"] + foreground = tokens["muted"] + if status == PROC_STATE_RUNNING: + background, foreground = tokens["warning_soft"], tokens["warning"] + elif status == PROC_STATE_INCOMPLETE: + background, foreground = tokens["danger_soft"], tokens["danger"] + elif status == PROC_STATE_COMPLETED: + background, foreground = tokens["success_soft"], tokens["success"] + item.setBackground(QColor(background)) + item.setForeground(QColor(foreground)) + + def _refresh_table_theme_colors(self) -> None: + """테마 전환 시 기존 표 항목도 현재 토큰으로 즉시 다시 칠합니다.""" + background, foreground = self._table_default_colors() + for row in range(self.process_table.rowCount()): + for column in (self.COL_ICON, self.COL_NAME): + item = self.process_table.item(row, column) + if item is not None: + item.setBackground(background) + item.setForeground(foreground) + status_item = self.process_table.item(row, self.COL_STATUS) + if status_item is not None: + self._apply_table_status(status_item, status_item.text()) + def _remote_pairing_endpoint(self) -> str: return f"{self._api_base_url()}/remote/pair/start" @@ -968,44 +1213,53 @@ def _create_menu_bar(self): # 메뉴바 오른쪽 끝: [항상 위] 체크박스 + 볼륨 토글 버튼 self._volume_btn = QToolButton() - self._volume_btn.setText("🔊") + self._volume_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaVolume)) self._volume_btn.setToolTip("볼륨 조절 패널 열기/닫기") self._volume_btn.setCheckable(True) + self._volume_btn.setProperty("hhRole", "menuCornerAction") + self._volume_btn.setIconSize(QSize(16, 16)) self._volume_btn.clicked.connect(self._toggle_volume_panel) - self._volume_btn.setStyleSheet(""" - QToolButton { - border: 1px solid transparent; - border-radius: 4px; - background: transparent; - padding: 2px 6px; - } - QToolButton:hover { - background: palette(midlight); - border-color: palette(mid); - } - QToolButton:checked { - background: palette(highlight); - color: palette(highlighted-text); - border-color: palette(highlight); - } - QToolButton:pressed { - background: palette(dark); - } - """) self._always_on_top_cb = QCheckBox("항상 위") + self._always_on_top_cb.setProperty("hhRole", "menuCornerToggle") self._always_on_top_cb.setToolTip("창을 항상 위에 표시") self._always_on_top_cb.setChecked(self.data_manager.global_settings.always_on_top) self._always_on_top_cb.toggled.connect(self._on_always_on_top_toggled) - corner_container = QWidget() - corner_layout = QHBoxLayout(corner_container) - corner_layout.setContentsMargins(0, 0, 4, 0) + # PySide6에서는 setCornerWidget()에 넘긴 지역 wrapper가 사라지면 + # 자식 위젯 wrapper도 무효화될 수 있으므로 명시적으로 보유합니다. + self._menu_corner_container = QWidget() + self._menu_corner_layout = QHBoxLayout(self._menu_corner_container) + corner_container = self._menu_corner_container + corner_layout = self._menu_corner_layout + corner_layout.setContentsMargins(0, 0, 0, 0) corner_layout.setSpacing(6) - corner_layout.addWidget(self._always_on_top_cb) - corner_layout.addWidget(self._volume_btn) + corner_layout.addWidget( + self._always_on_top_cb, + 0, + Qt.AlignmentFlag.AlignVCenter, + ) + corner_layout.addWidget(self._volume_btn, 0, Qt.AlignmentFlag.AlignVCenter) mb.setCornerWidget(corner_container, Qt.Corner.TopRightCorner) + def _sync_menu_corner_metrics(self) -> None: + """플랫폼별 메뉴 액션 높이에 코너 컨트롤의 실제 중심선을 맞춥니다.""" + if not hasattr(self, "_menu_corner_layout"): + return + actions = self.menuBar().actions() + if not actions: + return + action_height = self.menuBar().actionGeometry(actions[0]).height() + if action_height <= 0: + return + self._always_on_top_cb.setFixedHeight(action_height) + self._volume_btn.setFixedSize(action_height, action_height) + self._menu_corner_layout.invalidate() + self._menu_corner_layout.activate() + self._menu_corner_container.adjustSize() + self._menu_corner_container.updateGeometry() + self.menuBar().updateGeometry() + def _restart_app(self) -> None: """앱을 재시작합니다.""" import sys, os @@ -1064,60 +1318,31 @@ def _apply_theme(self, theme: str = "system"): return # setStyle() 호출 시 앱 폰트가 스타일 기본값으로 초기화되는 문제 방지 saved_font = app.font() - if theme == "dark": - app.setStyle("Fusion") - palette = QPalette() - dark_base = QColor(42, 42, 42) - dark_window = QColor(53, 53, 53) - dark_text = QColor(220, 220, 220) - highlight = QColor(42, 130, 218) - palette.setColor(QPalette.ColorRole.Window, dark_window) - palette.setColor(QPalette.ColorRole.WindowText, dark_text) - palette.setColor(QPalette.ColorRole.Base, dark_base) - palette.setColor(QPalette.ColorRole.AlternateBase, QColor(66, 66, 66)) - palette.setColor(QPalette.ColorRole.ToolTipBase, dark_base) - palette.setColor(QPalette.ColorRole.ToolTipText, dark_text) - palette.setColor(QPalette.ColorRole.Text, dark_text) - palette.setColor(QPalette.ColorRole.Button, dark_window) - palette.setColor(QPalette.ColorRole.ButtonText, dark_text) - palette.setColor(QPalette.ColorRole.BrightText, QColor(255, 80, 80)) - palette.setColor(QPalette.ColorRole.Link, highlight) - palette.setColor(QPalette.ColorRole.Highlight, highlight) - palette.setColor(QPalette.ColorRole.HighlightedText, QColor(255, 255, 255)) - palette.setColor(QPalette.ColorRole.Mid, QColor(80, 80, 80)) - palette.setColor(QPalette.ColorRole.Shadow, QColor(20, 20, 20)) - palette.setColor(QPalette.ColorRole.Light, QColor(90, 90, 90)) - palette.setColor(QPalette.ColorRole.Midlight, QColor(65, 65, 65)) - app.setPalette(palette) - elif theme == "light": + if theme in ("dark", "light"): app.setStyle("Fusion") - # standardPalette() 대신 모든 색상 명시적 정의: - # 시스템 다크 모드가 활성화된 환경에서도 라이트 팔레트 강제 적용 - palette = QPalette() - palette.setColor(QPalette.ColorRole.Window, QColor(240, 240, 240)) - palette.setColor(QPalette.ColorRole.WindowText, QColor(0, 0, 0)) - palette.setColor(QPalette.ColorRole.Base, QColor(255, 255, 255)) - palette.setColor(QPalette.ColorRole.AlternateBase, QColor(233, 231, 227)) - palette.setColor(QPalette.ColorRole.ToolTipBase, QColor(255, 255, 220)) - palette.setColor(QPalette.ColorRole.ToolTipText, QColor(0, 0, 0)) - palette.setColor(QPalette.ColorRole.Text, QColor(0, 0, 0)) - palette.setColor(QPalette.ColorRole.Button, QColor(240, 240, 240)) - palette.setColor(QPalette.ColorRole.ButtonText, QColor(0, 0, 0)) - palette.setColor(QPalette.ColorRole.BrightText, QColor(255, 0, 0)) - palette.setColor(QPalette.ColorRole.Link, QColor(0, 0, 255)) - palette.setColor(QPalette.ColorRole.Highlight, QColor(42, 130, 218)) - palette.setColor(QPalette.ColorRole.HighlightedText, QColor(255, 255, 255)) - palette.setColor(QPalette.ColorRole.Mid, QColor(160, 160, 160)) - palette.setColor(QPalette.ColorRole.Shadow, QColor(105, 105, 105)) - palette.setColor(QPalette.ColorRole.Light, QColor(255, 255, 255)) - palette.setColor(QPalette.ColorRole.Midlight, QColor(227, 227, 227)) - app.setPalette(palette) else: # system app.setStyle(self._original_style_name or "") app.setPalette(QPalette()) # 스타일 변경 후 폰트 복원 app.setFont(saved_font) + self._apply_widgets_presentation() + + def _apply_widgets_presentation(self) -> None: + """팔레트, QSS, DWM 제목 표시줄을 같은 색상 원천으로 동기화합니다.""" + dark = self._is_effective_dark_theme() + apply_widgets_palette(dark=dark) + if self.centralWidget() is not None: + apply_modern_widgets_style(self, dark=dark) + if hasattr(self, "process_table"): + self._refresh_table_theme_colors() + if hasattr(self, "_volume_btn"): + self._sync_menu_corner_metrics() + tokens = widgets_theme_tokens(dark) + volume_icon = self.style().standardIcon(QStyle.StandardPixmap.SP_MediaVolume) + self._volume_btn.setIcon(tint_icon(volume_icon, QColor(tokens["text"]))) self._sync_windows_title_bar_color() + if hasattr(self, "process_table"): + QTimer.singleShot(0, self._adjust_window_size_to_content) def _is_effective_dark_theme(self) -> bool: """현재 팔레트가 실질적으로 다크 테마인지 반환합니다.""" @@ -1131,9 +1356,9 @@ def _is_effective_dark_theme(self) -> bool: def _sync_windows_title_bar_color(self) -> bool: """가능한 Windows 환경에서 표준 제목 표시줄 색상을 앱 GUI 팔레트와 맞춥니다.""" - palette = self.palette() - window = palette.color(QPalette.ColorRole.Window) - text = palette.color(QPalette.ColorRole.WindowText) + tokens = widgets_theme_tokens(self._is_effective_dark_theme()) + window = QColor(tokens["surface"]) + text = QColor(tokens["text"]) return apply_windows_title_bar_color( int(self.winId()), caption_color=(window.red(), window.green(), window.blue()), @@ -1141,18 +1366,6 @@ def _sync_windows_title_bar_color(self) -> bool: dark_mode=self._is_effective_dark_theme(), ) - def _table_default_colors(self) -> tuple[QColor, QColor]: - """테이블 기본 배경/전경색을 반환합니다. - - 일부 플랫폼 스타일은 앱 팔레트가 다크여도 QTableWidgetItem에 저장한 - palette(base) 브러시를 밝은 기본색으로 해석할 수 있어, 다크 테마에서는 - 명시 색상을 사용해 셀 배경이 창 배경과 어긋나지 않게 합니다. - """ - if self._is_effective_dark_theme(): - return QColor(42, 42, 42), QColor(220, 220, 220) - palette = self.process_table.palette() - return palette.color(QPalette.ColorRole.Base), palette.color(QPalette.ColorRole.Text) - def open_global_settings_dialog(self): """전역 설정 대화 상자를 엽니다.""" # 중요: 대화상자를 열 때마다 data_manager로부터 최신 설정 객체를 가져와야 합니다. @@ -1200,9 +1413,7 @@ def _log_admin_debug(msg): _log_admin_debug("재시작 실패, 설정 롤백") upd_gs.run_as_admin = False self.data_manager.save_global_settings(upd_gs, actor="global_settings_dialog") - status_bar = self.statusBar() - if status_bar: - self._record_status_event("관리자 권한으로 재시작 실패. 설정이 롤백되었습니다.", 5000) + self._record_status_event("관리자 권한으로 재시작 실패. 설정이 롤백되었습니다.", 5000) return elif not upd_gs.run_as_admin and is_admin(): # 관리자 → 일반: 일반 권한으로 재시작 @@ -1217,9 +1428,7 @@ def _log_admin_debug(msg): QApplication.quit() return else: - status_bar = self.statusBar() - if status_bar: - self._record_status_event("일반 권한으로 재시작 실패. 앱을 수동으로 재시작해주세요.", 5000) + self._record_status_event("일반 권한으로 재시작 실패. 앱을 수동으로 재시작해주세요.", 5000) else: _log_admin_debug("권한 설정 변경 없음 - 조건문 통과하지 않음") @@ -1232,22 +1441,18 @@ def _log_admin_debug(msg): self._apply_theme(getattr(upd_gs, 'theme', 'system')) self.show() # 창 플래그 변경을 적용하기 위해 show() 호출 - status_bar = self.statusBar() - if status_bar: - self._record_status_event("전역 설정 저장됨.", 3000) # 상태 표시줄 메시지 + self._record_status_event("전역 설정 저장됨.", 3000) self.apply_startup_setting() # 시작 프로그램 설정 적용 - self.populate_process_list() # 전체 테이블 새로고침 (전역 설정 변경) + self.populate_process_list() # 전역 설정 변경을 카드 목록에 반영 self._refresh_web_button_states() # 웹 버튼 상태 새로고침 (전역 설정 변경이 웹 버튼에 영향을 줄 수 있는 경우) - self._adjust_window_height_for_table_rows() # 창 높이 조절 + self._adjust_window_size_to_content() # 시작 프로그램 상태 확인 및 메시지 표시 current_status = get_startup_shortcut_status() - status_bar = self.statusBar() - if status_bar: - if current_status: - self._record_status_event("시작 프로그램에 등록되어 있습니다.", 3000) - else: - self._record_status_event("시작 프로그램에 등록되어 있지 않습니다.", 3000) + if current_status: + self._record_status_event("시작 프로그램에 등록되어 있습니다.", 3000) + else: + self._record_status_event("시작 프로그램에 등록되어 있지 않습니다.", 3000) def open_remote_settings_dialog(self): """원격 설정 대화 상자를 엽니다.""" @@ -1264,36 +1469,105 @@ def apply_startup_setting(self): """시작 프로그램 자동 실행 설정을 적용합니다.""" run = self.data_manager.global_settings.run_on_startup # 자동 실행 여부 가져오기 - status_bar = self.statusBar() if set_startup_shortcut(run): # 바로가기 설정 시도 - if status_bar: - self._record_status_event(f"시작 시 자동 실행: {'활성' if run else '비활성'}", 3000) + self._record_status_event(f"시작 시 자동 실행: {'활성' if run else '비활성'}", 3000) else: - if status_bar: - self._record_status_event("자동 실행 설정 중 문제 발생 가능.", 3000) + self._record_status_event("자동 실행 설정 중 문제 발생 가능.", 3000) def run_process_monitor_check(self): - """실행 중인 프로세스를 확인하고 상태 변경 시 테이블을 새로고침합니다.""" - monitor_result = self.process_monitor.check_and_update_statuses() # 상태 변경 감지 + """GUI 소유 입력을 확정하고 느린 psutil 스캔을 coalesce합니다.""" + self._check_and_toggle_game_mode() + self._submit_telemetry( + "process_scan", + scan_running_processes, + self.process_monitor.process_scan_targets(), + ) - for event in monitor_result.started: - self._hoyolab_reconcile.handle_process_started(event) - self._nikke_resource_reconcile.handle_process_started(event) - for event in monitor_result.stopped: - self._hoyolab_reconcile.handle_process_stopped(event) - self._nikke_resource_reconcile.handle_process_stopped(event) + def _apply_process_scan_snapshot(self, snapshot: ProcessScanSnapshot) -> None: + """OS snapshot을 GUI cache에 적용하고 lifecycle DB 작업만 FIFO로 보냅니다.""" + detected = {item.process_id: item for item in snapshot.detected} + active = self.process_monitor.active_monitored_processes + processes = {item.id: item for item in self.data_manager.managed_processes} + changed = False + + for process_id, entry in tuple(active.items()): + observed = detected.get(process_id) + same_instance = bool( + observed is not None + and int(entry.get("pid") or 0) == observed.pid + and abs(float(entry.get("start_time_approx") or 0.0) - observed.create_time) <= 0.001 + ) + if same_instance: + continue + process = processes.get(process_id) + if process is not None: + event = ProcessLifecycleEvent( + process_id=process.id, + process_name=process.name, + session_id=int(entry["session_id"]) if entry.get("session_id") is not None else None, + timestamp=float(snapshot.observed_at), + stamina_tracking_enabled=process.stamina_tracking_enabled, + hoyolab_game_id=process.hoyolab_game_id, + pid=int(entry.get("pid") or 0), + stamina_at_end=process.stamina_current, + stamina_max=process.stamina_max, + resource_tracking_enabled=getattr(process, "resource_tracking_enabled", False), + resource_provider=getattr(process, "resource_provider", None), + resource_key=getattr(process, "resource_key", None), + resource_percent_at_end=getattr(process, "resource_percent", None), + ) + command = _LifecycleCommand( + "stop", + event, + int(entry.get("pid") or 0), + float(entry.get("start_time_approx") or 0.0), + str(entry.get("runtime_token") or ""), + ) + self._work_coordinator.submit_lifecycle( + process.id, self._persist_lifecycle_command, command + ) + active.pop(process_id, None) + changed = True - if monitor_result.changed: - status_bar = self.statusBar() - if status_bar: - self._record_status_event("프로세스 상태 변경 감지됨.", 2000) - self.update_process_statuses_only() # 상태 컬럼만 업데이트 - - # 사이드바/게임 모드는 ProcessMonitor의 시작·종료 이벤트 외에도 - # Beholder 복구, startup reconcile, 외부 캐시 재결합처럼 이미 실행 중인 - # 상태가 캐시에 들어온 뒤 steady-state tick만 발생하는 경로가 있습니다. - # changed=True에만 묶으면 앱 기동 후 서랍 손잡이 트리거가 시작되지 않을 수 - # 있으므로 매 tick 실제 active cache와 UI 모드를 재동기화합니다. + for process_id, observed in detected.items(): + if process_id in active: + continue + process = processes.get(process_id) + if process is None: + continue + runtime_token = ( + f"{self._app_instance_id}:{process.id}:" + f"{observed.pid}:{observed.create_time:.6f}" + ) + active[process.id] = { + "pid": observed.pid, + "exe": observed.executable, + "start_time_approx": observed.create_time, + "session_id": None, + "runtime_token": runtime_token, + } + event = ProcessLifecycleEvent( + process_id=process.id, + process_name=process.name, + session_id=None, + timestamp=observed.create_time, + stamina_tracking_enabled=process.stamina_tracking_enabled, + hoyolab_game_id=process.hoyolab_game_id, + pid=observed.pid, + resource_tracking_enabled=getattr(process, "resource_tracking_enabled", False), + resource_provider=getattr(process, "resource_provider", None), + resource_key=getattr(process, "resource_key", None), + ) + self._work_coordinator.submit_lifecycle( + process.id, + self._persist_lifecycle_command, + _LifecycleCommand("start", event, observed.pid, observed.create_time, runtime_token), + ) + changed = True + + if changed: + self._record_status_event("프로세스 상태 변경 감지됨.", 2000) + self.update_process_statuses_only() self._check_and_toggle_game_mode() def _check_and_toggle_game_mode(self): @@ -1321,9 +1595,7 @@ def _check_and_toggle_game_mode(self): self._is_game_mode_active = True if hide_enabled: self._hide_main_window_to_tray("game_started") - status_bar = self.statusBar() - if status_bar: - self._record_status_event("게임 실행 중: 창이 트레이로 숨겨졌습니다.", 3000) + self._record_status_event("게임 실행 중: 창이 트레이로 숨겨졌습니다.", 3000) if hasattr(self, '_sidebar_controller') and running_process is not None: self._sidebar_controller.activate_for_game( running_process, @@ -1344,12 +1616,10 @@ def _check_and_toggle_game_mode(self): self._sidebar_controller.deactivate() if hide_enabled: self.activate_and_show() # 창을 다시 표시 - status_bar = self.statusBar() - if status_bar: - self._record_status_event("모든 게임 종료: 창이 다시 표시되었습니다.", 3000) + self._record_status_event("모든 게임 종료: 창이 다시 표시되었습니다.", 3000) def run_scheduler_check(self): - """스케줄러 검사를 실행하고 상태 변경이 있을 때만 테이블을 업데이트합니다.""" + """스케줄러 검사를 실행하고 상태 변경이 있을 때만 카드 목록을 업데이트합니다.""" start_time = time.time() # 스케줄러 검사 실행 (알림 발송 등) @@ -1368,81 +1638,57 @@ def run_scheduler_check(self): logger.warning(f"scheduler_timer 실행 시간 초과: {execution_time:.1f}ms") def populate_process_list_slot(self): - """테이블 새로고침 시그널에 연결된 슬롯입니다.""" + """게임 표 새로고침 시그널에 연결된 슬롯입니다.""" self.populate_process_list() def update_process_statuses_only(self): - """프로세스 상태 컬럼만 업데이트합니다. 버튼은 유지하여 포커스 문제를 방지합니다.""" - if not hasattr(self, 'process_table') or not self.process_table: + """표의 상태만 업데이트하여 실행 버튼 포커스를 유지합니다.""" + if not hasattr(self, "process_table"): return - processes_by_id = { process.id: process for process in self.data_manager.managed_processes } - now_dt = datetime.datetime.now() - gs = self.data_manager.global_settings - df_bg, df_fg = self._table_default_colors() - - # 현재 테이블의 행 수와 프로세스 수가 다르면 전체 새로고침 필요 if self.process_table.rowCount() != len(processes_by_id): self.populate_process_list() return + now_dt = datetime.datetime.now() + gs = self.data_manager.global_settings has_changes = False - for r in range(self.process_table.rowCount()): - name_item = self.process_table.item(r, self.COL_NAME) - if not name_item: + for row in range(self.process_table.rowCount()): + name_item = self.process_table.item(row, self.COL_NAME) + if name_item is None: self.populate_process_list() return - process_id = name_item.data(Qt.ItemDataRole.UserRole) p = processes_by_id.get(process_id) if p is None: self.populate_process_list() return - # 상태 컬럼만 업데이트 st_str = self.scheduler.determine_process_visual_status(p, now_dt, gs) - st_item = self.process_table.item(r, self.COL_STATUS) - if st_item and st_item.text() != st_str: - st_item.setText(st_str) - st_item.setForeground(df_fg) # 기본 글자색 설정 - - # 상태에 따른 배경색 설정 - if st_str == PROC_STATE_RUNNING: - st_item.setBackground(self.COLOR_RUNNING) - st_item.setForeground(QColor("black")) - elif st_str == PROC_STATE_INCOMPLETE: - st_item.setBackground(self.COLOR_INCOMPLETE) - elif st_str == PROC_STATE_COMPLETED: - st_item.setBackground(self.COLOR_COMPLETED) - else: - st_item.setBackground(df_bg) + status_item = self.process_table.item(row, self.COL_STATUS) + if status_item is not None and status_item.text() != st_str: + status_item.setText(st_str) + self._apply_table_status(status_item, st_str) has_changes = True - - # 새로 실행된 프로세스에 기본 볼륨 자동 적용 pid = self._get_active_pid(p.id) self._sync_default_volume_state(p, pid) - # 상태 변경과 별개로 진행률 컬럼은 전용 refresh 루프에서 갱신한다. self._refresh_progress_bars() - - # 실제 변경사항이 있을 때만 상태바 메시지 표시 if has_changes: - status_bar = self.statusBar() - if status_bar: - self._record_status_event("프로세스 상태 업데이트됨.", 2000) + self._record_status_event("프로세스 상태 업데이트됨.", 2000) def _create_centered_app_icon_cell(self, icon: QIcon) -> QLabel: - """앱 아이콘을 아이콘 전용 셀 중앙에 배치하는 라벨을 생성합니다.""" - icon_size = getattr(self, '_table_icon_logical_size', self._TABLE_ICON_LOGICAL_SIZE) + """아이콘 전용 셀 안에 앱 아이콘을 중앙 정렬합니다.""" + icon_size = self._TABLE_ICON_LOGICAL_SIZE label = QLabel() + label.setObjectName("gameAppIcon") label.setAlignment(Qt.AlignmentFlag.AlignCenter) label.setContentsMargins(0, 0, 0, 0) label.setMinimumSize(icon_size, icon_size) label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) - label.setStyleSheet("QLabel { background: transparent; border: none; }") if icon and not icon.isNull(): label.setPixmap(icon.pixmap(QSize(icon_size, icon_size))) return label @@ -1464,71 +1710,70 @@ def _create_centered_resource_icon_label(self, icon_path: Optional[str], *, size ) return icon_label + def _create_launch_button_cell(self, process: ManagedProcess) -> QWidget: + """고정 높이 실행 버튼을 표 셀 정중앙에 배치합니다.""" + container = QWidget() + layout = QHBoxLayout(container) + layout.setContentsMargins(2, 0, 2, 0) + button = QPushButton("실행", container) + button.setProperty("hhRole", "primaryAction") + button.setFixedHeight(self._TABLE_LAUNCH_BUTTON_HEIGHT) + button.clicked.connect(functools.partial(self.handle_launch_button_in_row, process.id)) + if process.monitoring_path != process.launch_path and process.launch_path: + button.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + button.customContextMenuRequested.connect( + functools.partial(self._show_launch_context_menu, process.id, button) + ) + current_pref = getattr(process, "preferred_launch_type", "shortcut") + if current_pref == "auto": + current_pref = "shortcut" + pref_label = "바로가기 선호" if current_pref == "shortcut" else "프로세스 선호" + button.setToolTip(f"좌클릭: 실행 / 우클릭: 기본 실행 방식 설정 (현재: {pref_label})") + layout.addWidget(button, 1, Qt.AlignmentFlag.AlignVCenter) + return container + def populate_process_list(self): - """관리 대상 프로세스 목록을 테이블에 채웁니다.""" - self.process_table.setSortingEnabled(False) # 사용자가 바꿀 수 없는 고정 정렬 + """관리 대상 프로세스를 기존 행·열 구조의 게임 표로 표시합니다.""" + self.process_table.setSortingEnabled(False) processes = sorted( self.data_manager.managed_processes, key=lambda process: ((process.name or "").casefold(), process.id or ""), ) - self.process_table.setRowCount(len(processes)) # 행 개수 설정 - - now_dt = datetime.datetime.now() # 현재 시각 - gs = self.data_manager.global_settings # 전역 설정 - df_bg, df_fg = self._table_default_colors() # 기본 배경색 및 글자색 + self.process_table.setRowCount(len(processes)) + now_dt = datetime.datetime.now() + gs = self.data_manager.global_settings + default_background, default_foreground = self._table_default_colors() - for r, p in enumerate(processes): # 각 프로세스에 대해 반복 - # 아이콘 컬럼 - icon_item = QTableWidgetItem() + for row, p in enumerate(processes): qi = get_qicon_for_file( p.monitoring_path, - icon_size=getattr(self, '_table_icon_logical_size', self._TABLE_ICON_LOGICAL_SIZE), + icon_size=self._TABLE_ICON_LOGICAL_SIZE, process_id=p.id, ) - icon_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) - self.process_table.setItem(r, self.COL_ICON, icon_item); icon_item.setBackground(df_bg); icon_item.setForeground(df_fg) - self.process_table.setCellWidget(r, self.COL_ICON, self._create_centered_app_icon_cell(qi)) + icon_item = QTableWidgetItem() + icon_item.setBackground(default_background) + icon_item.setForeground(default_foreground) + self.process_table.setItem(row, self.COL_ICON, icon_item) + self.process_table.setCellWidget(row, self.COL_ICON, self._create_centered_app_icon_cell(qi)) - # 이름 컬럼 (UserRole에 ID 저장) name_item = QTableWidgetItem(p.name) - name_item.setData(Qt.ItemDataRole.UserRole, p.id) # UserRole에 프로세스 ID 저장 - self.process_table.setItem(r, self.COL_NAME, name_item); name_item.setBackground(df_bg); name_item.setForeground(df_fg) + name_item.setData(Qt.ItemDataRole.UserRole, p.id) + name_item.setBackground(default_background) + name_item.setForeground(default_foreground) + name_item.setTextAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft) + self.process_table.setItem(row, self.COL_NAME, name_item) - # 마지막 플레이 컬럼 (진행률 표시) percentage, time_str = self._calculate_progress_percentage(p, now_dt) progress_widget = self._create_progress_bar_widget(p, percentage, time_str) - self.process_table.setCellWidget(r, self.COL_LAST_PLAYED, progress_widget) + self.process_table.setCellWidget(row, self.COL_LAST_PLAYED, progress_widget) - # 실행 버튼 컬럼 - btn = QPushButton("실행") - btn.clicked.connect(functools.partial(self.handle_launch_button_in_row, p.id)) # 버튼 클릭 시그널 연결 + self.process_table.setCellWidget(row, self.COL_LAUNCH_BTN, self._create_launch_button_cell(p)) - # 모니터링 경로와 실행 경로가 다른 경우 우클릭 메뉴 활성화 - if p.monitoring_path != p.launch_path and p.launch_path: - btn.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) - btn.customContextMenuRequested.connect( - functools.partial(self._show_launch_context_menu, p.id, btn) - ) - current_pref = getattr(p, "preferred_launch_type", "shortcut") - if current_pref == "auto": - current_pref = "shortcut" - pref_label = "바로가기 선호" if current_pref == "shortcut" else "프로세스 선호" - btn.setToolTip(f"좌클릭: 실행 / 우클릭: 기본 실행 방식 설정 (현재: {pref_label})") - - self.process_table.setCellWidget(r, self.COL_LAUNCH_BTN, btn) # 셀에 버튼 위젯 설정 - - # 상태 컬럼 - st_str = self.scheduler.determine_process_visual_status(p, now_dt, gs) # 시각적 상태 결정 - st_item = QTableWidgetItem(st_str) - st_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) # 텍스트 가운데 정렬 - self.process_table.setItem(r, self.COL_STATUS, st_item) - st_item.setForeground(df_fg) # 기본 글자색 설정 - - # 상태에 따른 배경색 설정 - if st_str == PROC_STATE_RUNNING: st_item.setBackground(self.COLOR_RUNNING); st_item.setForeground(QColor("black")) # 실행 중: 노란색 배경, 검은색 글자 - elif st_str == PROC_STATE_INCOMPLETE: st_item.setBackground(self.COLOR_INCOMPLETE) # 미완료: 빨간색 배경 - elif st_str == PROC_STATE_COMPLETED: st_item.setBackground(self.COLOR_COMPLETED) # 완료: 초록색 배경 - else: st_item.setBackground(df_bg) # 그 외: 기본 배경색 + status = self.scheduler.determine_process_visual_status(p, now_dt, gs) + status_item = QTableWidgetItem(status) + status_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) + self._apply_table_status(status_item, status) + self.process_table.setItem(row, self.COL_STATUS, status_item) pid = self._get_active_pid(p.id) self._sync_default_volume_state(p, pid) @@ -1536,26 +1781,23 @@ def populate_process_list(self): self.scheduler.invalidate_visual_status_snapshot() QTimer.singleShot(0, self._adjust_window_size_to_content) - def show_table_context_menu(self, pos): # 게임 테이블용 컨텍스트 메뉴 - """게임 테이블의 항목에 대한 컨텍스트 메뉴를 표시합니다.""" - item = self.process_table.itemAt(pos) # 클릭 위치의 아이템 가져오기 - if not item: return # 아이템 없으면 반환 - + def show_table_context_menu(self, pos: QPoint) -> None: + item = self.process_table.itemAt(pos) + if item is None: + return name_item = self.process_table.item(item.row(), self.COL_NAME) - if not name_item: + if name_item is None: return - pid = name_item.data(Qt.ItemDataRole.UserRole) # 선택된 행의 프로세스 ID 가져오기 - if not pid: return # ID 없으면 반환 - - menu = QMenu(self) # 컨텍스트 메뉴 생성 - edit_act = QAction("편집", self) # 편집 액션 - del_act = QAction("삭제", self) # 삭제 액션 - - edit_act.triggered.connect(functools.partial(self.handle_edit_action_for_row, pid)) # 편집 액션 시그널 연결 - del_act.triggered.connect(functools.partial(self.handle_delete_action_for_row, pid)) # 삭제 액션 시그널 연결 - - menu.addActions([edit_act, del_act]) # 메뉴에 액션 추가 - menu.exec(self.process_table.mapToGlobal(pos)) # 컨텍스트 메뉴 표시 + pid = name_item.data(Qt.ItemDataRole.UserRole) + if not pid: + return + menu = QMenu(self) + edit_act = QAction("편집", self) + del_act = QAction("삭제", self) + edit_act.triggered.connect(functools.partial(self.handle_edit_action_for_row, pid)) + del_act.triggered.connect(functools.partial(self.handle_delete_action_for_row, pid)) + menu.addActions([edit_act, del_act]) + menu.exec(self.process_table.viewport().mapToGlobal(pos)) def handle_edit_action_for_row(self, pid:str): # 게임 수정 """선택된 게임 프로세스의 정보를 수정하는 대화 상자를 엽니다.""" @@ -1575,6 +1817,8 @@ def handle_edit_action_for_row(self, pid:str): # 게임 수정 last_played_timestamp=p_edit.last_played_timestamp, # 마지막 플레이 시간은 유지 original_launch_path=getattr(p_edit, 'original_launch_path', data["launch_path"]), # 원본 경로 보존 preferred_launch_type=data.get("preferred_launch_type", "shortcut"), # 실행 방식 선택 + launch_args_enabled=data.get("launch_args_enabled", False), + launch_args=data.get("launch_args", ""), user_preset_id=data.get("user_preset_id"), # 사용자 프리셋 ID stamina_tracking_enabled=data.get("stamina_tracking_enabled", False), # 스태미나 추적 hoyolab_game_id=data.get("hoyolab_game_id"), # 호요랩 게임 ID @@ -1591,12 +1835,9 @@ def handle_edit_action_for_row(self, pid:str): # 게임 수정 default_volume=getattr(p_edit, 'default_volume', None)) # 기존 볼륨 설정 보존 if self.data_manager.update_process(upd_p): # 프로세스 정보 업데이트 - self.populate_process_list() # 전체 테이블 새로고침 (프로세스 정보 변경) - # 테이블이 완전히 렌더링된 후 창 높이 조절 (다음 이벤트 루프에서 실행) - QTimer.singleShot(0, self._adjust_window_height_for_table_rows) - status_bar = self.statusBar() - if status_bar: - self._record_status_event(f"'{upd_p.name}' 수정 완료.", 3000) + self.populate_process_list() + QTimer.singleShot(0, self._adjust_window_size_to_content) + self._record_status_event(f"'{upd_p.name}' 수정 완료.", 3000) else: QMessageBox.warning(self, "오류", "프로세스 수정 실패.") def handle_delete_action_for_row(self, pid:str): # 게임 삭제 @@ -1610,14 +1851,20 @@ def handle_delete_action_for_row(self, pid:str): # 게임 삭제 QMessageBox.StandardButton.No) # 기본 선택은 'No' if reply == QMessageBox.StandardButton.Yes: # 'Yes' 클릭 시 if self.data_manager.remove_process(pid): # 프로세스 삭제 - self.populate_process_list() # 전체 테이블 새로고침 (프로세스 삭제) - # 테이블이 완전히 렌더링된 후 창 높이 조절 (다음 이벤트 루프에서 실행) - QTimer.singleShot(0, self._adjust_window_height_for_table_rows) - status_bar = self.statusBar() - if status_bar: - self._record_status_event(f"'{p_del.name}' 삭제 완료.", 3000) + self.populate_process_list() + QTimer.singleShot(0, self._adjust_window_size_to_content) + self._record_status_event(f"'{p_del.name}' 삭제 완료.", 3000) else: QMessageBox.warning(self, "오류", "프로세스 삭제 실패.") + def _launch_args_for_process(self, process: ManagedProcess, launch_mode: str, launch_target: str | None) -> str | None: + """직접 실행 대상에만 저장된 추가 인자를 적용합니다.""" + if launch_mode == "launcher" or not launch_target_accepts_args(launch_target): + return None + if not getattr(process, "launch_args_enabled", False): + return None + launch_args = str(getattr(process, "launch_args", "") or "").strip() + return launch_args or None + def handle_launch_button_in_row(self, pid:str): # 게임 실행 """선택된 게임 프로세스를 실행합니다.""" p_launch = self.data_manager.get_process_by_id(pid) # ID로 프로세스 정보 가져오기 @@ -1652,16 +1899,14 @@ def handle_launch_button_in_row(self, pid:str): # 게임 실행 if not launch_target: QMessageBox.warning(self, "오류", f"'{p_launch.name}' 실행 경로 없음."); return - if self.launcher.launch_process(launch_target): # 프로세스 실행 시도 - status_bar = self.statusBar() - if status_bar: - self._record_status_event(f"'{p_launch.name}' 실행 시도.", 3000) + launch_args = self._launch_args_for_process(p_launch, launch_type, launch_target) + + if self.launcher.launch_process(launch_target, args=launch_args): # 프로세스 실행 시도 + self._record_status_event(f"'{p_launch.name}' 실행 시도.", 3000) # 실행 성공 시 즉시 상태 업데이트 self.update_process_statuses_only() else: # 실행 실패 시 - status_bar = self.statusBar() - if status_bar: - self._record_status_event(f"'{p_launch.name}' 실행 실패.", 3000) + self._record_status_event(f"'{p_launch.name}' 실행 실패.", 3000) def _launch_with_specific_path(self, pid: str, use_shortcut: bool): """특정 경로로 프로세스 실행 (우클릭 메뉴용)""" @@ -1673,16 +1918,15 @@ def _launch_with_specific_path(self, pid: str, use_shortcut: bool): QMessageBox.warning(self, "오류", f"해당 경로가 없습니다.") return - if self.launcher.launch_process(launch_target): - status_bar = self.statusBar() - if status_bar: - path_type = "바로가기" if use_shortcut else "직접 실행" - self._record_status_event(f"'{p_launch.name}' {path_type}으로 실행 시도.", 3000) + launch_mode = "shortcut" if use_shortcut else "direct" + launch_args = self._launch_args_for_process(p_launch, launch_mode, launch_target) + + if self.launcher.launch_process(launch_target, args=launch_args): + path_type = "바로가기" if use_shortcut else "직접 실행" + self._record_status_event(f"'{p_launch.name}' {path_type}으로 실행 시도.", 3000) self.update_process_statuses_only() else: - status_bar = self.statusBar() - if status_bar: - self._record_status_event(f"'{p_launch.name}' 실행 실패.", 3000) + self._record_status_event(f"'{p_launch.name}' 실행 실패.", 3000) def _set_launch_preference(self, pid: str, preference: str): """기본 실행 방식을 영구 저장""" @@ -1695,9 +1939,7 @@ def _set_launch_preference(self, pid: str, preference: str): current_pref = "shortcut" if current_pref == preference: - status_bar = self.statusBar() - if status_bar: - self._record_status_event(f"이미 '{('바로가기' if preference == 'shortcut' else '프로세스')}' 선호로 설정되어 있습니다.", 3000) + self._record_status_event(f"이미 '{('바로가기' if preference == 'shortcut' else '프로세스')}' 선호로 설정되어 있습니다.", 3000) return updated_data = p.to_dict() if hasattr(p, "to_dict") else p.__dict__.copy() @@ -1706,18 +1948,16 @@ def _set_launch_preference(self, pid: str, preference: str): if self.data_manager.update_process(updated_process): self.populate_process_list() - status_bar = self.statusBar() - if status_bar: - self._record_status_event( - f"기본 실행 방식이 '{('바로가기 선호' if preference == 'shortcut' else '프로세스 선호')}'로 저장되었습니다.", - 4000 - ) + self._record_status_event( + f"기본 실행 방식이 '{('바로가기 선호' if preference == 'shortcut' else '프로세스 선호')}'로 저장되었습니다.", + 4000 + ) else: QMessageBox.warning(self, "저장 실패", "기본 실행 방식을 저장하지 못했습니다.") def _show_launch_context_menu(self, pid: str, button: QPushButton, pos): """실행 버튼 우클릭 시 컨텍스트 메뉴 표시""" - from PyQt6.QtWidgets import QMenu + from PySide6.QtWidgets import QMenu p = self.data_manager.get_process_by_id(pid) if not p: return @@ -1765,6 +2005,8 @@ def open_add_process_dialog(self): # "새 게임 추가" 버튼에 연결 is_mandatory_time_enabled=data["is_mandatory_time_enabled"], original_launch_path=data["launch_path"], # 원본 경로 보존 preferred_launch_type=data.get("preferred_launch_type", "shortcut"), # 실행 방식 선택 + launch_args_enabled=data.get("launch_args_enabled", False), + launch_args=data.get("launch_args", ""), user_preset_id=data.get("user_preset_id"), # 사용자 프리셋 ID stamina_tracking_enabled=data.get("stamina_tracking_enabled", False), # 스태미나 추적 hoyolab_game_id=data.get("hoyolab_game_id"), # 호요랩 게임 ID @@ -1773,12 +2015,9 @@ def open_add_process_dialog(self): # "새 게임 추가" 버튼에 연결 resource_key=data.get("resource_key"), resource_label=data.get("resource_label")) self.data_manager.add_process(new_p) # 데이터 매니저에 프로세스 추가 - self.populate_process_list() # 전체 테이블 새로고침 (프로세스 추가) - # 테이블이 완전히 렌더링된 후 창 높이 조절 (다음 이벤트 루프에서 실행) - QTimer.singleShot(0, self._adjust_window_height_for_table_rows) - status_bar = self.statusBar() - if status_bar: - self._record_status_event(f"'{new_p.name}' 추가 완료.", 3000) + self.populate_process_list() + QTimer.singleShot(0, self._adjust_window_size_to_content) + self._record_status_event(f"'{new_p.name}' 추가 완료.", 3000) # --- 웹 바로 가기 버튼 관련 메소드들 --- def _clear_layout(self, layout: QHBoxLayout): @@ -1821,12 +2060,11 @@ def _determine_web_button_state(self, shortcut: WebShortcut, current_dt: datetim return "GREEN" if last_reset_dt >= yesterdays_refresh_event_dt else "DEFAULT" def _apply_button_style(self, button: QPushButton, state: str): - """버튼 상태에 따라 스타일시트를 적용합니다.""" - button.setStyleSheet("") # 기존 스타일 초기화 - if state == "RED": - button.setStyleSheet(f"background-color: {self.COLOR_WEB_BTN_RED.name()};") # 빨간색 배경 - elif state == "GREEN": - button.setStyleSheet(f"background-color: {self.COLOR_WEB_BTN_GREEN.name()};") # 초록색 배경 + """웹 바로가기 상태를 공통 테마의 semantic 속성으로 적용합니다.""" + semantic = {"RED": "danger", "GREEN": "success"}.get(state, "default") + button.setProperty("hhState", semantic) + button.style().unpolish(button) + button.style().polish(button) def _refresh_web_button_states(self): """동적으로 생성된 모든 웹 바로가기 버튼의 상태를 새로고침합니다.""" @@ -1847,58 +2085,9 @@ def _refresh_web_button_states(self): self._apply_button_style(button, state) # 스타일 적용 def _refresh_status_columns(self): - """테이블의 상태 컬럼만 새로고침합니다.""" + """게임 카드의 상태 칩만 새로고침합니다.""" start_time = time.time() - current_dt = datetime.datetime.now() - gs = self.data_manager.global_settings - status_changes = 0 - - for r in range(self.process_table.rowCount()): - # 이름 컬럼에서 프로세스 ID 가져오기 - name_item = self.process_table.item(r, self.COL_NAME) - if not name_item: - continue - process_id = name_item.data(Qt.ItemDataRole.UserRole) - if not process_id: - continue - - # 프로세스 정보 가져오기 - process = self.data_manager.get_process_by_id(process_id) - if not process: - continue - - # 새로운 상태 결정 - new_status = self.scheduler.determine_process_visual_status(process, current_dt, gs) - - # 상태 컬럼 아이템 가져오기 - status_item = self.process_table.item(r, self.COL_STATUS) - if not status_item: - continue - - # 상태가 변경된 경우에만 업데이트 - if status_item.text() != new_status: - old_status = status_item.text() - status_item.setText(new_status) - status_changes += 1 - - # 상태에 따른 배경색 설정 - df_bg, df_fg = self._table_default_colors() - - status_item.setBackground(df_bg) # 기본 배경색으로 초기화 - status_item.setForeground(df_fg) # 기본 글자색으로 초기화 - - if new_status == PROC_STATE_RUNNING: - status_item.setBackground(self.COLOR_RUNNING) - status_item.setForeground(QColor("black")) - elif new_status == PROC_STATE_INCOMPLETE: - status_item.setBackground(self.COLOR_INCOMPLETE) - elif new_status == PROC_STATE_COMPLETED: - status_item.setBackground(self.COLOR_COMPLETED) - - # 상태 변경이 있었으면 viewport 강제 갱신 (절전 복귀 후 화면 그리기 문제 대응) - if status_changes > 0: - if self.process_table.viewport(): - self.process_table.viewport().update() + self.update_process_statuses_only() # 타이머 실행 시간 로깅 (100ms 이상 걸리면 경고) execution_time = (time.time() - start_time) * 1000 @@ -1939,7 +2128,8 @@ def _load_and_display_web_buttons(self): self.dynamic_web_buttons_layout.addWidget(button) # 레이아웃에 버튼 추가 # 웹 버튼 로드 완료 후 창 너비 조절 - self._adjust_window_width_for_web_buttons() + self._adjust_window_size_to_content() + QTimer.singleShot(0, self._adjust_window_size_to_content) def _handle_web_button_clicked(self, shortcut_id: str, url: str): """웹 바로가기 버튼 클릭 시 호출됩니다. URL을 열고, 필요한 경우 상태를 업데이트합니다.""" @@ -1972,10 +2162,8 @@ def _open_add_web_shortcut_dialog(self): refresh_time_str=data.get("refresh_time_str")) # refresh_time_str은 선택 사항 if self.data_manager.add_web_shortcut(new_shortcut): # 데이터 매니저에 추가 self._load_and_display_web_buttons() # 버튼 목록 새로고침 - self._adjust_window_width_for_web_buttons() # 창 너비 조절 - status_bar = self.statusBar() - if status_bar: - self._record_status_event(f"웹 바로 가기 '{new_shortcut.name}' 추가됨.", 3000) + self._adjust_window_size_to_content() + self._record_status_event(f"웹 바로 가기 '{new_shortcut.name}' 추가됨.", 3000) else: QMessageBox.warning(self, "추가 실패", "웹 바로 가기 추가에 실패했습니다.") @@ -2017,10 +2205,8 @@ def _edit_web_shortcut(self, shortcut_id: str): if self.data_manager.update_web_shortcut(updated_shortcut): # 데이터 매니저 통해 정보 업데이트 self._load_and_display_web_buttons() # 버튼 목록 새로고침 - self._adjust_window_width_for_web_buttons() # 창 너비 조절 - status_bar = self.statusBar() - if status_bar: - self._record_status_event(f"웹 바로 가기 '{updated_shortcut.name}' 수정됨.", 3000) + self._adjust_window_size_to_content() + self._record_status_event(f"웹 바로 가기 '{updated_shortcut.name}' 수정됨.", 3000) else: QMessageBox.warning(self, "수정 실패", "웹 바로 가기 수정에 실패했습니다.") @@ -2039,229 +2225,61 @@ def _delete_web_shortcut(self, shortcut_id: str): if reply == QMessageBox.StandardButton.Yes: # 'Yes' 클릭 시 if self.data_manager.remove_web_shortcut(shortcut_id): # 데이터 매니저 통해 삭제 self._load_and_display_web_buttons() # 버튼 목록 새로고침 - self._adjust_window_width_for_web_buttons() # 창 너비 조절 - status_bar = self.statusBar() - if status_bar: - self._record_status_event(f"웹 바로 가기 '{shortcut_to_delete.name}' 삭제됨.", 3000) + self._adjust_window_size_to_content() + self._record_status_event(f"웹 바로 가기 '{shortcut_to_delete.name}' 삭제됨.", 3000) else: QMessageBox.warning(self, "삭제 실패", "웹 바로 가기 삭제에 실패했습니다.") - def _save_window_geometry(self): - """현재 창 위치를 QSettings에 저장합니다. - - 새 저장값은 해상도 변화에도 위치 의도를 유지하도록 화면 유효 영역 기준 - 상대 앵커를 사용합니다. 기존 geometry/position 값은 이전 버전 fallback 용도로만 - 함께 유지합니다. - """ + def _position_on_cursor_screen_bottom_right(self) -> None: + """현재 커서가 있는 모니터의 작업 영역 우하단에 프레임을 맞춥니다.""" try: - anchor = self._build_current_window_anchor() - if anchor: - self._settings.setValue(self._WINDOW_ANCHOR_SETTINGS_KEY, json.dumps(anchor, ensure_ascii=False)) - self._settings.setValue("window_geometry", self.saveGeometry()) - self._settings.setValue("window_position", self.pos()) - self._settings.sync() - logger.debug("창 위치 저장: pos=%s size=%s anchor=%s", self.pos(), self.size(), anchor) - except Exception as e: - logger.error(f"창 위치 저장 실패: {e}", exc_info=True) - - def _restore_window_geometry(self): - """저장된 창 위치와 크기를 복원합니다.""" - try: - # 상대 앵커가 있으면 최종 content-size 계산 뒤 적용합니다. - # 여기서 saveGeometry()를 먼저 복원하면 예전 해상도의 크기까지 되살아날 수 - # 있으므로, 새 포맷은 위치 복원만 지연합니다. - if self._pending_window_anchor: - logger.debug("저장된 창 상대 앵커 복원 대기: %s", self._pending_window_anchor) + cursor = QCursor.pos() + if position_windows_window_bottom_right( + int(self.winId()), + cursor.x(), + cursor.y(), + ): + logger.debug("Win32 외곽 창을 커서 모니터 우하단에 배치") return - - # 저장된 geometry가 있으면 복원 - geometry = self._settings.value("window_geometry") - if geometry: - self.restoreGeometry(geometry) - logger.debug("저장된 창 geometry 복원 완료") - self._clamp_window_to_available_screen() + screen = QApplication.screenAt(QCursor.pos()) or QApplication.primaryScreen() + if not screen: return - - # geometry가 없으면 position만 복원 - position = self._settings.value("window_position") - if position: - self.move(position) - logger.debug(f"저장된 창 위치 복원: {position}") - self._clamp_window_to_available_screen() - except Exception as e: - logger.error(f"창 위치 복원 실패: {e}", exc_info=True) - - def _load_window_anchor(self) -> Optional[dict]: - """QSettings에서 상대 창 위치 앵커를 읽습니다.""" - raw = self._settings.value(self._WINDOW_ANCHOR_SETTINGS_KEY) - if not raw: - return None - try: - if isinstance(raw, dict): - anchor = raw - else: - anchor = json.loads(str(raw)) - if anchor.get("version") != 1: - return None - if anchor.get("horizontal") not in {"left", "right"}: - return None - if anchor.get("vertical") not in {"top", "bottom"}: - return None - return anchor + frame = self.frameGeometry() + available = screen.availableGeometry() + frame_left = available.right() - frame.width() + 1 + frame_top = available.bottom() - frame.height() + 1 + client_offset = self.geometry().topLeft() - frame.topLeft() + self.move(QPoint(frame_left, frame_top) + client_offset) + logger.debug( + "커서 모니터 우하단에 창 배치: screen=%s frame=(%s, %s)", + screen.name(), + frame_left, + frame_top, + ) except Exception as e: - logger.warning("저장된 창 상대 앵커를 읽을 수 없습니다: %s", e) - return None - - def _build_current_window_anchor(self) -> Optional[dict]: - """현재 창 위치를 화면 유효 영역 기준 상대 앵커로 변환합니다.""" - screen = QApplication.screenAt(self.geometry().center()) - if not screen: - screen = self.screen() or QApplication.primaryScreen() - if not screen: - return None - screen_name = screen.name() if hasattr(screen, "name") else "" - return self._window_anchor_from_rect(self.geometry(), screen.availableGeometry(), screen_name) - - @staticmethod - def _window_anchor_from_rect(window_rect: QRect, available_geometry: QRect, screen_name: str = "") -> dict: - """창 rect를 availableGeometry 기준 상대 앵커로 직렬화 가능한 dict로 변환합니다.""" - left_gap = max(0, window_rect.left() - available_geometry.left()) - right_gap = max(0, available_geometry.right() - window_rect.right()) - top_gap = max(0, window_rect.top() - available_geometry.top()) - bottom_gap = max(0, available_geometry.bottom() - window_rect.bottom()) - horizontal = "right" if right_gap <= left_gap else "left" - vertical = "bottom" if bottom_gap <= top_gap else "top" - return { - "version": 1, - "screen_name": screen_name, - "horizontal": horizontal, - "vertical": vertical, - "left_gap": left_gap, - "right_gap": right_gap, - "top_gap": top_gap, - "bottom_gap": bottom_gap, - } - - @staticmethod - def _position_from_window_anchor(anchor: dict, available_geometry: QRect, size: QSize) -> QPoint: - """저장된 상대 앵커와 현재 화면 크기로 창 좌상단 좌표를 계산합니다.""" - if anchor.get("horizontal") == "right": - x = available_geometry.right() - size.width() + 1 - int(anchor.get("right_gap", 0)) - else: - x = available_geometry.left() + int(anchor.get("left_gap", 0)) - - if anchor.get("vertical") == "bottom": - y = available_geometry.bottom() - size.height() + 1 - int(anchor.get("bottom_gap", 0)) - else: - y = available_geometry.top() + int(anchor.get("top_gap", 0)) - - max_x = available_geometry.right() - size.width() + 1 - max_y = available_geometry.bottom() - size.height() + 1 - return QPoint( - max(available_geometry.left(), min(x, max_x)), - max(available_geometry.top(), min(y, max_y)), - ) - - def _screen_for_window_anchor(self, anchor: dict) -> Optional[QScreen]: - """저장된 화면 이름을 우선 사용하고, 없으면 현재/기본 화면으로 fallback합니다.""" - screen_name = anchor.get("screen_name") - if screen_name: - for screen in QApplication.screens(): - if screen.name() == screen_name: - return screen - return QApplication.screenAt(self.geometry().center()) or self.screen() or QApplication.primaryScreen() - - def _restore_pending_window_anchor(self) -> bool: - """지연된 상대 앵커 복원을 적용합니다.""" - anchor = self._pending_window_anchor - if not anchor: - return False - screen = self._screen_for_window_anchor(anchor) - if not screen: - return False - position = self._position_from_window_anchor(anchor, screen.availableGeometry(), self.size()) - self.move(position) - self._pending_window_anchor = None - logger.debug("저장된 창 상대 앵커 복원: %s -> %s", anchor, position) - return True + logger.error(f"창 우하단 배치 실패: {e}", exc_info=True) def _clamp_window_to_available_screen(self): - """창이 상태 표시줄/Dock 등을 제외한 화면 유효 영역 안에 위치하도록 보정합니다.""" + """네이티브 프레임 전체를 현재 화면의 유효 영역 안으로 보정합니다.""" try: - screen = QApplication.screenAt(self.pos()) + frame = self.frameGeometry() + screen = QApplication.screenAt(frame.center()) if not screen: - screen = QApplication.primaryScreen() + screen = QApplication.screenAt(QCursor.pos()) or QApplication.primaryScreen() if not screen: return avail = screen.availableGeometry() - # 창 크기가 가용 영역을 초과하면 먼저 크기를 줄임 (위치 계산 전) - if self.height() > avail.height(): - self.resize(self.width(), avail.height()) - if self.width() > avail.width(): - self.resize(avail.width(), self.height()) - pos = self.pos() - size = self.size() - new_x = max(avail.left(), min(pos.x(), avail.right() - size.width() + 1)) - new_y = max(avail.top(), min(pos.y(), avail.bottom() - size.height() + 1)) - if new_x != pos.x() or new_y != pos.y(): - self.move(new_x, new_y) - logger.debug(f"창 위치 화면 영역으로 보정: ({new_x}, {new_y})") + new_left = max(avail.left(), min(frame.left(), avail.right() - frame.width() + 1)) + new_top = max(avail.top(), min(frame.top(), avail.bottom() - frame.height() + 1)) + if new_left != frame.left() or new_top != frame.top(): + client_offset = self.geometry().topLeft() - frame.topLeft() + self.move(QPoint(new_left, new_top) + client_offset) + logger.debug("창 프레임을 화면 영역으로 보정: (%s, %s)", new_left, new_top) except Exception as e: logger.error(f"창 위치 보정 실패: {e}", exc_info=True) - def moveEvent(self, event): - """창 이동 이벤트 - 마그넷 스냅 기능 구현""" - super().moveEvent(event) - - # 마그넷 스냅 활성화 (화면 가장자리에 자동 정렬) - try: - # 현재 창 위치와 크기 - window_rect = self.frameGeometry() - window_pos = window_rect.topLeft() - - # 현재 창이 있는 스크린 찾기 - screen = QApplication.screenAt(window_pos) - if not screen: - screen = QApplication.primaryScreen() - - if screen: - # 사용 가능한 화면 영역 (작업 표시줄 제외) - available_geometry = screen.availableGeometry() - - # 마그넷 감도 (픽셀 단위) - snap_threshold = 15 - - new_x = window_pos.x() - new_y = window_pos.y() - - # 왼쪽 가장자리 스냅 - if abs(window_rect.left() - available_geometry.left()) < snap_threshold: - new_x = available_geometry.left() - - # 오른쪽 가장자리 스냅 - if abs(window_rect.right() - available_geometry.right()) < snap_threshold: - new_x = available_geometry.right() - window_rect.width() - - # 위쪽 가장자리 스냅 - if abs(window_rect.top() - available_geometry.top()) < snap_threshold: - new_y = available_geometry.top() - - # 아래쪽 가장자리 스냅 (덜 자주 사용되므로 선택적) - if abs(window_rect.bottom() - available_geometry.bottom()) < snap_threshold: - new_y = available_geometry.bottom() - window_rect.height() - - # 위치가 변경되었으면 이동 - if new_x != window_pos.x() or new_y != window_pos.y(): - self.move(new_x, new_y) - - except Exception as e: - logger.error(f"마그넷 스냅 처리 중 오류: {e}", exc_info=True) - def closeEvent(self, event: QEvent): """창 닫기 이벤트를 처리합니다. 트레이 관리자가 있으면 트레이로 숨깁니다.""" - # 창 위치 저장 (트레이로 숨기기 전에 저장) - self._save_window_geometry() - if hasattr(self, 'tray_manager') and self.tray_manager: self.tray_manager.handle_window_close_event(event) # 트레이 관리자에게 이벤트 처리 위임 else: # 트레이 관리자 없으면 기본 동작 (숨기기) @@ -2270,23 +2288,34 @@ def closeEvent(self, event: QEvent): def initiate_quit_sequence(self): """애플리케이션 종료 절차를 시작합니다 (타이머 중지, 아이콘 숨기기, 리소스 정리 등).""" + if self._shutting_down: + return + self._shutting_down = True + deadline = time.monotonic() + _GUI_CLEANUP_DEADLINE_SECONDS + + def remaining_ms() -> int: + return max(0, int((deadline - time.monotonic()) * 1000)) - # 0. 창 위치 저장 (앱 종료 시) - self._save_window_geometry() - - # 1. 활성화된 타이머들 중지 - if hasattr(self, 'monitor_timer') and self.monitor_timer.isActive(): - self.monitor_timer.stop() - if hasattr(self, 'scheduler_timer') and self.scheduler_timer.isActive(): - self.scheduler_timer.stop() - if hasattr(self, 'ui_refresh_timer') and self.ui_refresh_timer.isActive(): - self.ui_refresh_timer.stop() - if hasattr(self, 'runtime_heartbeat_timer') and self.runtime_heartbeat_timer.isActive(): - self.runtime_heartbeat_timer.stop() - if hasattr(self, 'remote_readiness_timer') and self.remote_readiness_timer.isActive(): - self.remote_readiness_timer.stop() - if hasattr(self.data_manager, "send_runtime_heartbeat"): - self.data_manager.send_runtime_heartbeat(shutdown=True, runtime_kind="pyqt") + self._timer_registry.shutdown() + self._lifecycle_shutdown_event.set() + app_instance = QApplication.instance() + if app_instance is not None and sys.platform == "win32": + try: + app_instance.removeNativeEventFilter(self._power_event_filter) + except RuntimeError: + pass + try: + self._background_transport.post_json( + "/api/beholder/runtime/heartbeat", + { + "app_instance_id": self._app_instance_id, + "runtime_kind": "pyside", + "shutdown": True, + }, + timeout=max(0.05, min(1.0, remaining_ms() / 1000.0)), + ) + except Exception: + logger.debug("종료 heartbeat 전송 실패", exc_info=True) # 2. 트레이 아이콘 숨기기 if hasattr(self, 'tray_manager') and self.tray_manager: @@ -2298,22 +2327,27 @@ def initiate_quit_sequence(self): # 3-1. 볼륨 패널 정리 (대기 중인 볼륨 저장 타이머 플러시) if hasattr(self, '_volume_panel') and self._volume_panel: - self._volume_panel.cleanup() + self._volume_panel.cleanup(remaining_ms()) # 3-2. 사이드바 컨트롤러 정리 if hasattr(self, '_sidebar_controller'): - self._sidebar_controller.cleanup() + self._sidebar_controller.cleanup(remaining_ms()) # 3-2. 녹화 매니저 종료 if hasattr(self, '_recording_manager'): self._recording_manager.shutdown() if hasattr(self, '_hoyolab_reconcile'): - self._hoyolab_reconcile.shutdown() + self._hoyolab_reconcile.shutdown(remaining_ms()) if hasattr(self, '_nikke_resource_reconcile'): - self._nikke_resource_reconcile.shutdown() + self._nikke_resource_reconcile.shutdown(remaining_ms()) if hasattr(self, '_daily_checkin'): - self._daily_checkin.shutdown() + self._daily_checkin.shutdown(remaining_ms()) + drained = self._work_coordinator.shutdown( + deadline_seconds=remaining_ms() / 1000.0 + ) + if not drained: + logger.warning("GUI background work가 종료 기한 뒤에도 drain 중입니다.") # 3-3. Game Bar 설정 복원 self._restore_gamebar_setting() @@ -2323,167 +2357,96 @@ def initiate_quit_sequence(self): if app_instance: app_instance.quit() - def _visible_table_columns(self) -> list[int]: - return [ - column - for column in range(self.process_table.columnCount()) - if not self.process_table.isColumnHidden(column) - ] - def _cell_content_width(self, row: int, column: int) -> int: - """아이템/셀 위젯의 실제 sizeHint를 함께 반영한 컬럼 최소 폭.""" if column == self.COL_ICON: - # 아이콘 전용 컬럼은 QTableWidgetItem.sizeHint()의 플랫폼별 기본 여백을 - # 신뢰하지 않고, 표시 아이콘 + 최소 여백만으로 고정해 불필요한 빈 폭을 막습니다. - return self.process_table.iconSize().width() + self._TABLE_ICON_COLUMN_PADDING - - style = self.process_table.style() or self.style() - focus_margin = style.pixelMetric(QStyle.PixelMetric.PM_FocusFrameHMargin) if style else 2 - metrics = self.process_table.fontMetrics() - padding = max(metrics.horizontalAdvance(" "), focus_margin * 2 + self.process_table.frameWidth() * 2) - + return self._TABLE_ICON_LOGICAL_SIZE + self._TABLE_ICON_COLUMN_PADDING widget = self.process_table.cellWidget(row, column) widget_width = widget.sizeHint().width() if widget is not None else 0 - item = self.process_table.item(row, column) item_width = 0 if item is not None: - item_width = max( - item.sizeHint().width(), - metrics.horizontalAdvance(f" {item.text()} ") + padding, - ) - if not item.icon().isNull(): - item_width += self.process_table.iconSize().width() + padding - + item_width = self.process_table.fontMetrics().horizontalAdvance(f" {item.text()} ") return max(widget_width, item_width) - def _resize_table_to_contents(self, max_table_size: Optional[QSize] = None) -> QSize: - """헤더 없이도 각 셀 내용에 맞는 테이블 고정 크기를 계산합니다.""" + def _resize_table_to_contents(self, target_width: Optional[int] = None) -> QSize: + """모든 행을 표시하고 선택 폭의 여유는 진행률 열에만 배분합니다.""" table = self.process_table self._configure_table_header() - table.resizeRowsToContents() - - default_row_height = max(self._TABLE_ROW_HEIGHT, table.verticalHeader().defaultSectionSize()) + table.setMinimumSize(0, 0) + table.setMaximumSize(self._QWIDGETSIZE_MAX, self._QWIDGETSIZE_MAX) for row in range(table.rowCount()): - table.setRowHeight(row, max(default_row_height, table.rowHeight(row))) - - visible_columns = self._visible_table_columns() - style = table.style() or self.style() - column_gap = style.pixelMetric(QStyle.PixelMetric.PM_LayoutHorizontalSpacing) if style else 6 - column_gap = max(4, column_gap) - - for column in visible_columns: - max_width = 0 - for row in range(table.rowCount()): - max_width = max(max_width, self._cell_content_width(row, column)) - if table.rowCount() == 0: - max_width = max(max_width, table.fontMetrics().horizontalAdvance("빈 목록") + column_gap * 2) + table.setRowHeight(row, self._TABLE_ROW_HEIGHT) + + spacing = 4 + for column in range(table.columnCount()): + width = max( + (self._cell_content_width(row, column) for row in range(table.rowCount())), + default=0, + ) if column == self.COL_ICON: - table.setColumnWidth(column, max_width) + table.setColumnWidth(column, width) else: - table.setColumnWidth(column, max_width + column_gap) + table.setColumnWidth(column, width + spacing) frame = table.frameWidth() * 2 - content_width = sum(table.columnWidth(column) for column in visible_columns) + frame - content_height = frame - if table.rowCount() > 0: - content_height += sum(table.rowHeight(row) for row in range(table.rowCount())) + natural_width = frame + sum(table.columnWidth(column) for column in range(table.columnCount())) + width = max(natural_width, target_width or 0) + if width > natural_width: + table.setColumnWidth( + self.COL_LAST_PLAYED, + table.columnWidth(self.COL_LAST_PLAYED) + width - natural_width, + ) + if table.rowCount(): + height = frame + sum(table.rowHeight(row) for row in range(table.rowCount())) else: - content_height += max(default_row_height, table.fontMetrics().height() + column_gap * 2) - - max_width = max_table_size.width() if max_table_size and max_table_size.width() > 0 else None - max_height = max_table_size.height() if max_table_size and max_table_size.height() > 0 else None - - horizontal_overflow = max_width is not None and content_width > max_width - vertical_overflow = max_height is not None and content_height > max_height - table.setHorizontalScrollBarPolicy( - Qt.ScrollBarPolicy.ScrollBarAsNeeded if horizontal_overflow else Qt.ScrollBarPolicy.ScrollBarAlwaysOff - ) - table.setVerticalScrollBarPolicy( - Qt.ScrollBarPolicy.ScrollBarAsNeeded if vertical_overflow else Qt.ScrollBarPolicy.ScrollBarAlwaysOff - ) - - target_width = content_width - target_height = content_height - if vertical_overflow and not horizontal_overflow: - target_width += table.verticalScrollBar().sizeHint().width() - if horizontal_overflow: - target_height += table.horizontalScrollBar().sizeHint().height() - - if max_width is not None: - target_width = min(target_width, max_width) - if max_height is not None: - target_height = min(target_height, max_height) - - target = QSize(max(target_width, 1), max(target_height, 1)) + height = frame + self._TABLE_ROW_HEIGHT + target = QSize(max(1, width), max(1, height)) table.setFixedSize(target) table.updateGeometry() return target def _adjust_window_size_to_content(self): - """현재 테이블/웹 버튼/상태바 sizeHint에 맞춰 창 크기를 동적으로 최적화합니다.""" - table_size = self._resize_table_to_contents() - - central_widget = self.centralWidget() - if central_widget and central_widget.layout(): - central_widget.layout().invalidate() - central_widget.layout().activate() - - self.setMinimumSize(self._MIN_WINDOW_WIDTH, self._MIN_WINDOW_HEIGHT) - target = self.sizeHint().expandedTo(self.minimumSizeHint()) - - screen = self.screen() or QApplication.primaryScreen() - max_width: Optional[int] = None - max_height: Optional[int] = None - if screen is not None: - available = screen.availableGeometry() - max_width = max(self._MIN_WINDOW_WIDTH, int(available.width() * self._SCREEN_SIZE_RATIO)) - max_height = max(self._MIN_WINDOW_HEIGHT, int(available.height() * self._SCREEN_SIZE_RATIO)) + """상단·표·상태 표시 폭을 통일한 뒤 전체 콘텐츠에 창을 정확히 맞춥니다.""" + self.setMinimumSize(0, 0) + self.setMaximumSize(self._QWIDGETSIZE_MAX, self._QWIDGETSIZE_MAX) + central = self.centralWidget() + if central is None or central.layout() is None: + return - if ( - (max_width is not None and target.width() > max_width) - or (max_height is not None and target.height() > max_height) - ): - extra_width = max(0, target.width() - table_size.width()) - extra_height = max(0, target.height() - table_size.height()) - capped_table_size = QSize( - max(1, (max_width or target.width()) - extra_width), - max(1, (max_height or target.height()) - extra_height), - ) - self._resize_table_to_contents(capped_table_size) - if central_widget and central_widget.layout(): - central_widget.layout().invalidate() - central_widget.layout().activate() - target = self.sizeHint().expandedTo(self.minimumSizeHint()) - - target.setWidth(max(target.width(), self.minimumSizeHint().width(), self._MIN_WINDOW_WIDTH)) - target.setHeight(max(target.height(), self.minimumSizeHint().height(), self._MIN_WINDOW_HEIGHT)) - if max_width is not None: - target.setWidth(min(target.width(), max_width)) - if max_height is not None: - target.setHeight(min(target.height(), max_height)) + for widget in (self.top_button_area, self.readiness_strip): + widget.setMinimumWidth(0) + widget.setMaximumWidth(self._QWIDGETSIZE_MAX) + if widget.layout() is not None: + widget.layout().invalidate() + widget.layout().activate() + + natural_table = self._resize_table_to_contents() + margins = central.layout().contentsMargins() + horizontal_margins = margins.left() + margins.right() + menu_width = max(0, self.menuBar().sizeHint().width() - horizontal_margins) + minimum_content_width = max(1, self._MIN_WINDOW_WIDTH - horizontal_margins) + shared_width = max( + natural_table.width(), + self.top_button_area.sizeHint().width(), + self.readiness_strip.sizeHint().width(), + menu_width, + minimum_content_width, + ) + self.top_button_area.setFixedWidth(shared_width) + self.readiness_strip.setFixedWidth(shared_width) + self._resize_table_to_contents(shared_width) + central.layout().invalidate() + central.layout().activate() + target = self.sizeHint().expandedTo(QSize(self._MIN_WINDOW_WIDTH, self._MIN_WINDOW_HEIGHT)) self.setFixedSize(target) self.updateGeometry() self.update() - - if not self._restore_pending_window_anchor(): - self._clamp_window_to_available_screen() - - self._saved_size = self.size() - self._saved_geometry = self.geometry() - - def _adjust_window_height_to_table(self): - """기존 메서드명 호환성을 위한 별칭""" - self._adjust_window_size_to_content() - - def _adjust_window_width_for_web_buttons(self): - """웹 바로가기 변경 후 전체 content 기반 크기 계산을 다시 수행합니다.""" - self._adjust_window_size_to_content() - - def _adjust_window_height_for_table_rows(self): - """기존 호출부 호환: 전체 content 기반 크기 계산을 수행합니다.""" - self._adjust_window_size_to_content() + if self._pending_bottom_right_placement and self.isVisible(): + self._pending_bottom_right_placement = False + QTimer.singleShot(0, self._position_on_cursor_screen_bottom_right) + else: + QTimer.singleShot(0, self._clamp_window_to_available_screen) # ───────── 볼륨 패널 ───────── @@ -2492,7 +2455,6 @@ def _toggle_volume_panel(self): if self._volume_panel.isVisible(): self._volume_panel.hide() self._volume_btn.setChecked(False) - self._volume_btn.setText("🔊") else: all_entries = [] for p in self.data_manager.managed_processes: @@ -2505,7 +2467,6 @@ def _toggle_volume_panel(self): def _on_volume_panel_hidden(self): """볼륨 패널이 숨겨질 때 (외부 클릭 포함) 토글 버튼 상태를 초기화합니다.""" self._volume_btn.setChecked(False) - self._volume_btn.setText("🔊") def _get_active_pid(self, process_id: str) -> Optional[int]: """process_id에 대해 현재 활성 PID를 반환합니다. 실행 중이 아니면 None.""" @@ -2688,7 +2649,7 @@ def _apply_recording_settings(self) -> None: def _on_recording_state_changed(self, state: str) -> None: """RecordingManager 상태 변경 콜백 — 백그라운드 스레드에서 호출될 수 있음. - pyqtSignal을 통해 메인 스레드로 안전하게 릴레이.""" + Signal을 통해 메인 스레드로 안전하게 릴레이.""" self._recording_state_sig.emit(state) def _dispatch_recording_state_to_sidebar(self, state: str) -> None: @@ -2719,7 +2680,7 @@ def _show_countdown_then_record(self) -> None: if not hasattr(self, '_recording_manager'): return from src.gui.countdown_overlay import CountdownOverlay - from PyQt6.QtWidgets import QApplication + from PySide6.QtWidgets import QApplication screen = QApplication.primaryScreen() self._countdown_overlay = CountdownOverlay( on_complete=self._recording_manager.start_recording, @@ -2930,85 +2891,32 @@ def _calculate_progress_percentage(self, process: ManagedProcess, current_dt: da def _create_progress_bar_widget(self, process, percentage: float, time_str: str) -> QWidget: - """진행률을 표시하는 QProgressBar 위젯을 생성합니다.""" - if percentage == 0.0 and not time_str.startswith(("STAMINA:", "RESOURCE:")): - # 기록이 없는 경우 - 동일한 레이아웃 구조 유지 - container = QWidget() - layout = QHBoxLayout(container) - layout.setContentsMargins(2, 0, 2, 0) - layout.setSpacing(4) - - # 프리셋 아이콘 표시 - icon_label = self._create_centered_resource_icon_label(self._get_stamina_icon_path(process)) - layout.addWidget(icon_label) - - # 텍스트 라벨 - text_label = QLabel(time_str) - text_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - layout.addWidget(text_label, 1) # stretch factor 1로 남은 공간 채움 - - return container - - # 범용 리소스 형식 감지: "RESOURCE:key:label:percent" - if time_str.startswith("RESOURCE:"): - try: - parts = time_str.split(":", 3) - if len(parts) >= 4: - resource_text = parts[3] - - container = QWidget() - layout = QHBoxLayout(container) - layout.setContentsMargins(2, 0, 2, 0) - layout.setSpacing(4) - - icon_label = self._create_centered_resource_icon_label(self._get_stamina_icon_path(process)) - layout.addWidget(icon_label) - - progress_bar = self._create_styled_progress_bar(percentage, resource_text) - layout.addWidget(progress_bar, 1) - return container - except Exception as e: - logger.error(f"리소스 위젯 생성 오류: {e}", exc_info=True) - - # 스태미나 형식 감지: "STAMINA:game_id:current/max" - if time_str.startswith("STAMINA:"): - try: - parts = time_str.split(":") - if len(parts) >= 3: - game_id = parts[1] - stamina_text = parts[2] - - # 아이콘 + Progress Bar를 포함하는 컨테이너 위젯 생성 - container = QWidget() - layout = QHBoxLayout(container) - layout.setContentsMargins(2, 0, 2, 0) - layout.setSpacing(4) - - # 아이콘 라벨 - icon_label = self._create_centered_resource_icon_label(self._get_stamina_icon_path(process)) - layout.addWidget(icon_label) - - # Progress Bar - progress_bar = self._create_styled_progress_bar(percentage, stamina_text) - layout.addWidget(progress_bar, 1) - - return container - except Exception as e: - logger.error(f"스태미나 위젯 생성 오류: {e}", exc_info=True) - - # 일반 시간 기반 Progress Bar (프리셋 아이콘 포함) + """우측 값 행과 셀 전체 폭의 얇은 트랙을 가진 진행률 위젯을 생성합니다.""" container = QWidget() - layout = QHBoxLayout(container) - layout.setContentsMargins(2, 0, 2, 0) - layout.setSpacing(4) + layout = QVBoxLayout(container) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(3) + + value_row = QWidget(container) + value_layout = QHBoxLayout(value_row) + value_layout.setContentsMargins(0, 0, 0, 0) + value_layout.setSpacing(4) + icon_path = self._get_stamina_icon_path(process) + if icon_path and os.path.exists(icon_path): + value_layout.addWidget(self._create_centered_resource_icon_label(icon_path)) + value_layout.addStretch(1) - # 프리셋 아이콘 표시 - icon_label = self._create_centered_resource_icon_label(self._get_stamina_icon_path(process)) - layout.addWidget(icon_label) + expects_bar = not (percentage == 0.0 and not time_str.startswith(("STAMINA:", "RESOURCE:"))) + display_text = self._get_progress_bar_format(percentage, time_str) if expects_bar else time_str + text_label = QLabel(display_text, value_row) + text_label.setObjectName("progressText") + text_label.setProperty("hhRole", "muted") + text_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + value_layout.addWidget(text_label) + layout.addWidget(value_row) - # Progress Bar - progress_bar = self._create_styled_progress_bar(percentage, f"{percentage:.1f}%") - layout.addWidget(progress_bar, 1) # stretch factor 1로 남은 공간 채움 + if expects_bar: + layout.addWidget(self._create_styled_progress_bar(percentage, display_text)) return container @@ -3033,17 +2941,13 @@ def _get_stamina_icon_path(self, process) -> Optional[str]: return resolve_preset_icon_path(icon_path, icon_type) def _create_styled_progress_bar(self, percentage: float, format_text: str) -> QProgressBar: - """스타일이 적용된 QProgressBar 생성""" - progress_bar = QProgressBar() + """공통 테마가 그리는 얇은 QProgressBar를 생성합니다.""" + progress_bar = CapsuleProgressBar() progress_bar.setValue(self._progress_bar_value(percentage)) progress_bar.setMaximum(self._PROGRESS_BAR_MAX) progress_bar.setMinimum(0) - # 높이 설정 (행 높이에 맞게 자동 조절) - progress_bar.setMinimumHeight(20) - - # 텍스트 표시 설정 - progress_bar.setTextVisible(True) + progress_bar.setTextVisible(False) progress_bar.setFormat(format_text) progress_bar.setProperty("color_bucket", self._progress_color_bucket(percentage)) self._apply_progress_bar_style(progress_bar, percentage) @@ -3065,102 +2969,48 @@ def _progress_color_bucket(self, percentage: float) -> int: return 1 return 0 - def _progress_bar_stylesheet(self, chunk_color: str) -> str: - """공통 ProgressBar 스타일시트를 생성합니다.""" - return f""" - QProgressBar {{ - border: 1px solid #404040; - border-radius: 2px; - text-align: center; - background-color: #2d2d2d; - color: white; - font-weight: bold; - }} - QProgressBar::chunk {{ - background-color: {chunk_color}; - border-radius: 1px; - }} - """ - def _apply_progress_bar_style(self, progress_bar: QProgressBar, percentage: float) -> None: - """진행률 구간에 맞는 스타일을 ProgressBar에 적용합니다.""" - if percentage >= 100: - chunk_color = "#ff4444" - elif percentage >= 80: - chunk_color = "#ff8800" - elif percentage >= 50: - chunk_color = "#ffcc00" - else: - chunk_color = "#44cc44" - progress_bar.setStyleSheet(self._progress_bar_stylesheet(chunk_color)) + """진행률 구간을 공통 QSS가 소비하는 semantic 속성으로 적용합니다.""" + bucket = ("low", "medium", "high", "full")[self._progress_color_bucket(percentage)] + progress_bar.setProperty("hhBucket", bucket) + progress_bar.style().unpolish(progress_bar) + progress_bar.style().polish(progress_bar) def _refresh_progress_bars(self): - """프로그레스 바들을 실시간으로 갱신합니다. - - 최적화: - - 값이 실제로 변경되었을 때만 업데이트 - - 스타일시트는 색상 구간 변경 시에만 적용 - - 컨테이너 내부의 Progress Bar 처리 - """ + """표의 진행률 셀을 값이 바뀐 경우에만 갱신합니다.""" start_time = time.time() now_dt = datetime.datetime.now() - processes = self.data_manager.managed_processes + processes = {process.id: process for process in self.data_manager.managed_processes} updated_count = 0 - # 테이블의 각 행을 순회하면서 해당 행의 프로세스 ID를 찾아서 갱신 for row in range(self.process_table.rowCount()): - # 해당 행의 이름 컬럼에서 프로세스 ID 가져오기 name_item = self.process_table.item(row, self.COL_NAME) - if not name_item: + if name_item is None: continue - process_id = name_item.data(Qt.ItemDataRole.UserRole) - if not process_id: - continue - - # 프로세스 ID로 해당 프로세스 찾기 - process = None - for p in processes: - if p.id == process_id: - process = p - break - + process = processes.get(process_id) if not process: continue - - # 현재 셀의 위젯 가져오기 current_widget = self.process_table.cellWidget(row, self.COL_LAST_PLAYED) if not current_widget: continue - - # 새로운 진행률 계산 percentage, time_str = self._calculate_progress_percentage(process, now_dt) - - # 컨테이너 위젯인 경우 내부 Progress Bar 찾기 (Task #2에서 변경된 구조) - progress_bar = None - if isinstance(current_widget, QWidget): - # 컨테이너 내부에서 QProgressBar 찾기 - for child in current_widget.findChildren(QProgressBar): - progress_bar = child - break - elif isinstance(current_widget, QProgressBar): - # 직접 QProgressBar인 경우 (하위 호환) - progress_bar = current_widget - - # Progress Bar 업데이트 + progress_bar = current_widget.findChild(QProgressBar) expects_progress_widget = not (percentage == 0.0 and not time_str.startswith(("STAMINA:", "RESOURCE:"))) if expects_progress_widget != (progress_bar is not None): - self.process_table.setCellWidget( - row, - self.COL_LAST_PLAYED, - self._create_progress_bar_widget(process, percentage, time_str), - ) + replacement = self._create_progress_bar_widget(process, percentage, time_str) + self.process_table.setCellWidget(row, self.COL_LAST_PLAYED, replacement) updated_count += 1 continue + new_format = self._get_progress_bar_format(percentage, time_str) if expects_progress_widget else time_str + text_label = current_widget.findChild(QLabel, "progressText") + if text_label is not None and text_label.text() != new_format: + text_label.setText(new_format) + updated_count += 1 + if progress_bar: new_value = self._progress_bar_value(percentage) - new_format = self._get_progress_bar_format(percentage, time_str) new_bucket = self._progress_color_bucket(percentage) if progress_bar.value() != new_value: @@ -3176,18 +3026,9 @@ def _refresh_progress_bars(self): self._apply_progress_bar_style(progress_bar, percentage) updated_count += 1 - # QLabel 업데이트 (컨테이너 내부의 라벨 - "기록 없음" 표시) - else: - for child in current_widget.findChildren(QLabel): - if child.text() != time_str: - child.setText(time_str) - updated_count += 1 - break - - # 업데이트가 있었으면 viewport 강제 갱신 (절전 복귀 후 화면 그리기 문제 대응) if updated_count > 0: - if self.process_table.viewport(): - self.process_table.viewport().update() + self.process_table.viewport().update() + QTimer.singleShot(0, self._adjust_window_size_to_content) # 타이머 실행 시간 로깅 (100ms 이상 걸리면 경고) execution_time = (time.time() - start_time) * 1000 @@ -3233,7 +3074,7 @@ def _on_launcher_restart_request(self, launcher_name: str) -> bool: True: 사용자가 재시작에 동의 False: 사용자가 재시작 거부 """ - from PyQt6.QtWidgets import QMessageBox + from PySide6.QtWidgets import QMessageBox # 런처명을 사용자 친화적으로 변환 friendly_name = launcher_name.replace('.exe', '').replace('Launcher', ' Launcher') diff --git a/src/gui/power_events.py b/src/gui/power_events.py new file mode 100644 index 00000000..abbf40a1 --- /dev/null +++ b/src/gui/power_events.py @@ -0,0 +1,131 @@ +"""Windows native power-resume decoding and desired Qt timer state.""" +from __future__ import annotations + +from dataclasses import dataclass +import logging +import sys +import threading +import time +from typing import Callable, Iterable, Protocol + +from PySide6.QtCore import QAbstractNativeEventFilter + +logger = logging.getLogger(__name__) + +WM_POWERBROADCAST = 0x0218 +PBT_APMRESUMEAUTOMATIC = 0x0012 + + +@dataclass(frozen=True, slots=True) +class PowerResumeEvent: + occurred_at: float + + +class WindowsPowerEventParser: + def __init__(self, *, debounce_seconds: float = 5.0, clock: Callable[[], float] = time.monotonic): + self._debounce_seconds = max(0.0, float(debounce_seconds)) + self._clock = clock + self._last_resume_at: float | None = None + self._lock = threading.Lock() + + def parse(self, message: int, event_code: int) -> PowerResumeEvent | None: + if int(message) != WM_POWERBROADCAST or int(event_code) != PBT_APMRESUMEAUTOMATIC: + return None + now = float(self._clock()) + with self._lock: + if self._last_resume_at is not None and now - self._last_resume_at < self._debounce_seconds: + return None + self._last_resume_at = now + return PowerResumeEvent(now) + + +def decode_windows_message(message: object) -> tuple[int, int] | None: + if sys.platform != "win32": + return None + try: + import ctypes + from ctypes import wintypes + + address = int(message) + native = ctypes.cast(address, ctypes.POINTER(wintypes.MSG)).contents + return int(native.message), int(native.wParam) + except (TypeError, ValueError, OverflowError, OSError): + logger.debug("Windows native message decode failed", exc_info=True) + return None + + +class WindowsPowerEventFilter(QAbstractNativeEventFilter): + def __init__( + self, + callback: Callable[[PowerResumeEvent], None], + *, + parser: WindowsPowerEventParser | None = None, + decoder: Callable[[object], tuple[int, int] | None] = decode_windows_message, + ) -> None: + super().__init__() + self._callback = callback + self._parser = parser or WindowsPowerEventParser() + self._decoder = decoder + + def nativeEventFilter(self, event_type: bytes | bytearray | str, message: object): + value = bytes(event_type).lower() if isinstance(event_type, (bytes, bytearray)) else str(event_type).encode().lower() + if value not in {b"windows_generic_msg", b"windows_dispatcher_msg"}: + return False, 0 + decoded = self._decoder(message) + event = self._parser.parse(*decoded) if decoded is not None else None + if event is not None: + try: + self._callback(event) + except Exception: + logger.exception("Windows resume callback failed") + return False, 0 + + +class TimerLike(Protocol): + def start(self, msec: int) -> None: ... + def stop(self) -> None: ... + def isActive(self) -> bool: ... + + +@dataclass(slots=True) +class _TimerEntry: + timer: TimerLike + interval_ms: int + enabled: bool + suspension_owners: set[str] + + +class DesiredTimerRegistry: + """정상 실행 의도와 일시 중단 상태를 분리해 보존합니다.""" + + def __init__(self) -> None: + self._entries: dict[str, _TimerEntry] = {} + self._shutdown = False + + def register(self, name: str, timer: TimerLike, *, interval_ms: int, enabled: bool = True) -> None: + if name in self._entries or interval_ms <= 0: + raise ValueError(f"invalid timer registration: {name}") + self._entries[name] = _TimerEntry(timer, int(interval_ms), bool(enabled), set()) + + def suspend(self, owner: str, names: Iterable[str] | None = None) -> None: + for entry in self._selected(names): + entry.suspension_owners.add(owner) + entry.timer.stop() + + def restart_desired(self) -> None: + for entry in self._entries.values(): + if entry.enabled and not entry.suspension_owners and not self._shutdown: + entry.timer.start(entry.interval_ms) + elif entry.timer.isActive(): + entry.timer.stop() + + def shutdown(self) -> None: + self._shutdown = True + for entry in self._entries.values(): + if entry.timer.isActive(): + entry.timer.stop() + + def _selected(self, names: Iterable[str] | None) -> list[_TimerEntry]: + if names is None: + return list(self._entries.values()) + return [self._entries[name] for name in names] diff --git a/src/gui/presentation.py b/src/gui/presentation.py new file mode 100644 index 00000000..6098ac38 --- /dev/null +++ b/src/gui/presentation.py @@ -0,0 +1,86 @@ +"""Select and own the Windows host presentation surface.""" +from __future__ import annotations + +import logging +import os +from importlib import import_module +from pathlib import Path +from typing import Sequence + +from PySide6.QtCore import QCoreApplication, QEvent, QObject, QUrl, Slot + +from src.gui.host_ui_facade import HostUiFacade +from src.gui.qt_runtime import binding_diagnostics + +logger = logging.getLogger(__name__) + +VALID_RENDERERS = frozenset({"widgets", "qml"}) + + +def resolve_ui_renderer(argv: Sequence[str] | None = None) -> str: + renderer = os.environ.get("HH_UI_RENDERER", "").strip().lower() + for argument in list(argv or ()): + if argument.startswith("--ui-renderer="): + renderer = argument.split("=", 1)[1].strip().lower() + if not renderer: + renderer = "widgets" + if renderer not in VALID_RENDERERS: + raise ValueError(f"지원하지 않는 UI renderer입니다: {renderer}") + return renderer + + +class PresentationController(QObject): + def __init__(self, main_window, renderer: str): + super().__init__(main_window) + self.main_window = main_window + self.renderer = renderer + self.facade = HostUiFacade(main_window, self) + self.engine: QObject | None = None + self.window = main_window + self._shutdown = False + + if renderer == "qml": + # Qt Quick is an opt-in candidate in packaged builds. Keeping these + # imports dynamic prevents the default Widgets onedir from carrying + # the complete QML/Quick runtime. Set HH_INCLUDE_QML=1 while running + # PyInstaller for the separately measured QML candidate. + QQmlApplicationEngine = import_module("PySide6.QtQml").QQmlApplicationEngine + QQuickStyle = import_module("PySide6.QtQuickControls2").QQuickStyle + QQuickStyle.setStyle("Basic") + self.engine = QQmlApplicationEngine(self) + self.engine.rootContext().setContextProperty("hostUi", self.facade) + qml_path = Path(__file__).with_name("qml") / "HostWindow.qml" + self.engine.load(QUrl.fromLocalFile(str(qml_path))) + roots = self.engine.rootObjects() + if not roots: + raise RuntimeError(f"QML presentation을 로드하지 못했습니다: {qml_path}") + self.window = roots[0] + self.facade.set_presentation_window(self.window) + self.main_window.set_presentation_window(self.window, self.facade) + else: + self.main_window.set_presentation_window(self.main_window, self.facade) + + logger.info("UI runtime: %s", binding_diagnostics(renderer)) + app = QCoreApplication.instance() + if app is not None: + app.aboutToQuit.connect(self.shutdown) + + def show(self) -> None: + if self.renderer == "qml": + self.main_window.hide() + self.facade.activateAndShow() + return + self.main_window.show() + + @Slot() + def shutdown(self) -> None: + if self._shutdown: + return + self._shutdown = True + if self.engine is not None: + for root in self.engine.rootObjects(): + root.hide() + root.deleteLater() + QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete) + self.engine.deleteLater() + self.engine = None diff --git a/src/gui/preset_editor_dialog.py b/src/gui/preset_editor_dialog.py index c344c85a..a2f936a1 100644 --- a/src/gui/preset_editor_dialog.py +++ b/src/gui/preset_editor_dialog.py @@ -1,14 +1,14 @@ import logging from typing import Optional, Dict, Any -from PyQt6.QtWidgets import ( +from PySide6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QWidget, QFormLayout, QLineEdit, QTextEdit, QPushButton, QCheckBox, QMessageBox, QSplitter, QGroupBox, QSpinBox, QTimeEdit, QComboBox, QFileDialog ) -from PyQt6.QtCore import Qt, QSize, pyqtSignal -from PyQt6.QtGui import QColor, QBrush, QPixmap +from PySide6.QtCore import Qt, QSize, Signal +from PySide6.QtGui import QColor, QBrush, QPixmap from src.utils.game_preset_manager import GamePresetManager @@ -18,7 +18,7 @@ class PresetEditorDialog(QDialog): """게임 프리셋 관리/편집 다이얼로그""" # 프리셋 변경 시그널 (저장/삭제 시 발생) - presets_changed = pyqtSignal() + presets_changed = Signal() def __init__(self, parent: Optional[QWidget] = None): super().__init__(parent) @@ -237,7 +237,7 @@ def _on_preset_selected(self, current: QListWidgetItem, _previous: QListWidgetIt # Reset Time reset_time = preset.get("server_reset_time") if reset_time: - from PyQt6.QtCore import QTime + from PySide6.QtCore import QTime t = QTime.fromString(reset_time, "HH:mm") if t.isValid(): self.reset_time_edit.setTime(t) diff --git a/src/gui/qml/HostWindow.qml b/src/gui/qml/HostWindow.qml new file mode 100644 index 00000000..15ba97fa --- /dev/null +++ b/src/gui/qml/HostWindow.qml @@ -0,0 +1,136 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts + +ApplicationWindow { + id: root + width: 520 + height: Math.min(760, Math.max(320, 176 + processList.contentHeight)) + minimumWidth: 420 + minimumHeight: 280 + visible: false + title: hostUi.title + color: hostUi.darkTheme ? "#202124" : "#f5f6f8" + + property color card: hostUi.darkTheme ? "#292b2f" : "#ffffff" + property color border: hostUi.darkTheme ? "#3c4047" : "#d8dce3" + property color textColor: hostUi.darkTheme ? "#f2f3f5" : "#202124" + property color mutedColor: hostUi.darkTheme ? "#aeb4bf" : "#667085" + property color accent: hostUi.darkTheme ? "#6ea8fe" : "#2563eb" + + onClosing: function(close) { + close.accepted = false + root.visible = false + } + + component SurfaceButton: Button { + id: control + implicitHeight: 34 + leftPadding: 13 + rightPadding: 13 + contentItem: Text { + text: control.text + color: root.textColor + font.pixelSize: 13 + font.weight: Font.Medium + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + radius: 7 + color: control.down ? Qt.darker(root.card, 1.12) : control.hovered ? Qt.lighter(root.card, 1.08) : root.card + border.color: control.hovered ? root.accent : root.border + } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 16 + spacing: 12 + + RowLayout { + Layout.fillWidth: true + spacing: 8 + Text { + text: "숙제 관리자" + color: root.textColor + font.pixelSize: 20 + font.weight: Font.DemiBold + Layout.fillWidth: true + } + SurfaceButton { text: "대시보드"; onClicked: hostUi.openDashboard() } + SurfaceButton { text: "설정"; onClicked: hostUi.openSettings() } + SurfaceButton { text: "+ 게임"; onClicked: hostUi.addProcess() } + } + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + radius: 12 + color: root.card + border.color: root.border + + ListView { + id: processList + anchors.fill: parent + anchors.margins: 6 + spacing: 4 + clip: true + model: hostUi.processes + + delegate: Rectangle { + required property var modelData + width: processList.width + height: 72 + radius: 8 + color: rowMouse.containsMouse ? (hostUi.darkTheme ? "#32353a" : "#f3f6fb") : "transparent" + + MouseArea { id: rowMouse; anchors.fill: parent; hoverEnabled: true } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 10 + spacing: 12 + + ColumnLayout { + Layout.fillWidth: true + spacing: 5 + Text { + text: modelData.name + color: root.textColor + font.pixelSize: 14 + font.weight: Font.Medium + elide: Text.ElideRight + Layout.fillWidth: true + } + RowLayout { + Layout.fillWidth: true + spacing: 8 + ProgressBar { + from: 0; to: 100; value: modelData.progress + Layout.fillWidth: true + background: Rectangle { implicitHeight: 5; radius: 3; color: root.border } + contentItem: Item { + implicitHeight: 5 + Rectangle { width: parent.width * Math.max(0, Math.min(1, modelData.progress / 100)); height: 5; radius: 3; color: root.accent } + } + } + Text { text: modelData.state; color: root.mutedColor; font.pixelSize: 12 } + } + } + SurfaceButton { text: "실행"; onClicked: hostUi.launchProcess(modelData.id) } + } + } + + ScrollBar.vertical: ScrollBar {} + } + } + + RowLayout { + Layout.fillWidth: true + Text { text: "PySide6 · Qt Quick 후보"; color: root.mutedColor; font.pixelSize: 11; Layout.fillWidth: true } + SurfaceButton { text: "원격 설정"; onClicked: hostUi.openRemoteSettings() } + } + } +} diff --git a/src/gui/qt_runtime.py b/src/gui/qt_runtime.py new file mode 100644 index 00000000..f55dca1f --- /dev/null +++ b/src/gui/qt_runtime.py @@ -0,0 +1,34 @@ +"""PySide6 runtime diagnostics and ownership guards.""" +from __future__ import annotations + +from typing import Any + +import PySide6 +from PySide6.QtCore import QObject, QThread, qVersion +from shiboken6 import Shiboken + + +UI_BINDING = "pyside6" +UI_VARIANT = "newgui-2nd" + + +def binding_diagnostics(renderer: str = "widgets") -> dict[str, str]: + return { + "ui_binding": UI_BINDING, + "binding_version": str(PySide6.__version__), + "qt_version": str(qVersion()), + "ui_renderer": str(renderer or "widgets").strip().lower(), + "ui_variant": UI_VARIANT, + } + + +def is_qobject_valid(value: Any) -> bool: + return isinstance(value, QObject) and Shiboken.isValid(value) + + +def require_object_thread(owner: QObject, operation: str) -> None: + """Fail fast when a GUI receiver runs outside its QObject affinity thread.""" + if not is_qobject_valid(owner): + raise RuntimeError(f"{operation}: QObject wrapper is no longer valid") + if QThread.currentThread() != owner.thread(): + raise RuntimeError(f"{operation}: receiver executed outside its QObject thread") diff --git a/src/gui/runtime_logging.py b/src/gui/runtime_logging.py new file mode 100644 index 00000000..4a77b04f --- /dev/null +++ b/src/gui/runtime_logging.py @@ -0,0 +1,83 @@ +"""Bounded, redacted file logging for the GUI process.""" +from __future__ import annotations + +import logging +from logging.handlers import RotatingFileHandler +import os +from pathlib import Path +import re + + +_AUTH_HEADER_PATTERN = re.compile( + r"(?im)\b(authorization|proxy-authorization|cookie|set-cookie)\s*[:=]\s*[^\r\n]+" +) +_BEARER_PATTERN = re.compile(r"(?i)\bbearer\s+[A-Za-z0-9._~+/=-]+") +_QUERY_SECRET_PATTERN = re.compile( + r"(?i)([?&](?:access[_-]?token|token|api[_-]?key|password|passwd|secret)=)[^&#\s]+" +) +_SECRET_ASSIGNMENT_PATTERN = re.compile( + r"(?i)\b(authorization|password|passwd|token|cookie|secret|api[_-]?key|ltoken_v2)\b" + r"\s*[:=]\s*(?!\[REDACTED\])(?:\"[^\"]*\"|'[^']*'|[^\s,;}\]]+)" +) +_HANDLER_MARKER = "_homework_helper_gui_rotating_handler" + + +def redact_sensitive_text(value: object) -> str: + """Return diagnostic text with common credential forms removed.""" + text = str(value) + text = _AUTH_HEADER_PATTERN.sub(lambda match: f"{match.group(1)}: [REDACTED]", text) + text = _BEARER_PATTERN.sub("Bearer [REDACTED]", text) + text = _QUERY_SECRET_PATTERN.sub(lambda match: f"{match.group(1)}[REDACTED]", text) + return _SECRET_ASSIGNMENT_PATTERN.sub( + lambda match: f"{match.group(1)}=[REDACTED]", + text, + ) + + +class RedactingFormatter(logging.Formatter): + """Redact the fully formatted record, including exception text.""" + + def format(self, record: logging.LogRecord) -> str: + return redact_sensitive_text(super().format(record)) + + +def configure_gui_logging( + app_data_dir: str | os.PathLike[str] | None = None, + *, + max_bytes: int = 10 * 1024 * 1024, + backup_count: int = 5, +) -> Path: + """Install one idempotent 10 MiB x 5 GUI log handler on the root logger.""" + if app_data_dir is None: + from src.utils.app_paths import get_app_data_dir + + app_data_dir = get_app_data_dir() + + data_dir = Path(app_data_dir) / "homework_helper_data" + data_dir.mkdir(parents=True, exist_ok=True) + log_path = data_dir / "gui.log" + + root_logger = logging.getLogger() + for handler in root_logger.handlers: + if getattr(handler, _HANDLER_MARKER, False): + return Path(getattr(handler, "baseFilename", log_path)) + + handler = RotatingFileHandler( + log_path, + maxBytes=max(1, int(max_bytes)), + backupCount=max(1, int(backup_count)), + encoding="utf-8", + delay=True, + ) + setattr(handler, _HANDLER_MARKER, True) + handler.setLevel(logging.INFO) + handler.setFormatter( + RedactingFormatter( + "%(asctime)s - %(name)s - %(levelname)s - %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + ) + root_logger.addHandler(handler) + if root_logger.level == logging.NOTSET or root_logger.level > logging.INFO: + root_logger.setLevel(logging.INFO) + return log_path diff --git a/src/gui/sidebar/edge_trigger_window.py b/src/gui/sidebar/edge_trigger_window.py index 086f98b4..b172b6f4 100644 --- a/src/gui/sidebar/edge_trigger_window.py +++ b/src/gui/sidebar/edge_trigger_window.py @@ -7,9 +7,9 @@ import logging from typing import Callable, Optional -from PyQt6.QtCore import Qt, QTimer, QRect, QRectF -from PyQt6.QtGui import QColor, QCursor, QPainter, QPen, QScreen -from PyQt6.QtWidgets import QApplication, QWidget +from PySide6.QtCore import Qt, QTimer, QRect, QRectF +from PySide6.QtGui import QColor, QCursor, QPainter, QPen, QScreen +from PySide6.QtWidgets import QApplication, QWidget logger = logging.getLogger(__name__) diff --git a/src/gui/sidebar/sidebar_controller.py b/src/gui/sidebar/sidebar_controller.py index 8fb99006..ca54c2a4 100644 --- a/src/gui/sidebar/sidebar_controller.py +++ b/src/gui/sidebar/sidebar_controller.py @@ -7,8 +7,8 @@ import logging from typing import Callable, Optional -from PyQt6.QtWidgets import QApplication, QWidget -from PyQt6.QtGui import QScreen +from PySide6.QtWidgets import QApplication, QWidget +from PySide6.QtGui import QScreen from src.data.data_models import ( ManagedProcess, @@ -146,8 +146,9 @@ def deactivate(self) -> None: logger.debug("SidebarController 비활성화") - def cleanup(self) -> None: + def cleanup(self, deadline_ms: int = 2000) -> bool: """앱 종료 시 모든 리소스를 정리합니다.""" + drained = True if self._trigger is not None: self._trigger.stop() try: @@ -157,7 +158,7 @@ def cleanup(self) -> None: self._trigger = None if self._sidebar is not None: - self._sidebar.cleanup() + drained = self._sidebar.cleanup(deadline_ms) try: self._sidebar.close() except RuntimeError: @@ -165,6 +166,7 @@ def cleanup(self) -> None: self._sidebar = None logger.debug("SidebarController cleanup 완료") + return drained def set_recording_callbacks( self, diff --git a/src/gui/sidebar/sidebar_widget.py b/src/gui/sidebar/sidebar_widget.py index 68ffec91..fe5b2024 100644 --- a/src/gui/sidebar/sidebar_widget.py +++ b/src/gui/sidebar/sidebar_widget.py @@ -10,19 +10,21 @@ from pathlib import Path from typing import Callable, Optional -from PyQt6.QtCore import ( +from PySide6.QtCore import ( Qt, QObject, QPropertyAnimation, QEasingCurve, - QRect, QTimer, QRunnable, QThreadPool, pyqtSignal, pyqtSlot, + QRect, QTimer, QRunnable, QThreadPool, Signal, Slot, ) -from PyQt6.QtGui import QScreen, QColor, QIcon, QImage, QPixmap -from PyQt6.QtWidgets import ( +from PySide6.QtGui import QScreen, QColor, QIcon, QImage, QPixmap +from PySide6.QtWidgets import ( QApplication, QFrame, QGridLayout, QHBoxLayout, QLabel, - QPushButton, QScrollArea, QSizePolicy, QSlider, QVBoxLayout, QWidget, + QPushButton, QScrollArea, QSizePolicy, QSlider, QStyle, QVBoxLayout, QWidget, ) from src.data.data_models import ManagedProcess from src.utils import audio_control +from src.gui.work_coordinator import retain_detached_qthreadpool from src.utils.clipboard import copy_file_to_clipboard +from src.gui.widgets_style import apply_sidebar_widgets_style logger = logging.getLogger(__name__) @@ -45,7 +47,7 @@ class _ThumbnailLoadSignals(QObject): - loaded = pyqtSignal(int, str, object) + loaded = Signal(int, str, object) class _ThumbnailLoadTask(QRunnable): @@ -94,6 +96,16 @@ def __init__(self, request_id: int, path: str, signals: _ThumbnailLoadSignals) - @staticmethod def _extract_thumbnail(path: str, w: int, h: int) -> Optional[QImage]: """Windows IShellItemImageFactory로 비디오 썸네일을 추출합니다.""" + co_initialized = False + psi = None + psiif = None + hbm = None + hdc = None + release_si = None + release_siif = None + ole32 = None + gdi32 = None + user32 = None try: import ctypes import ctypes.wintypes as wintypes @@ -104,7 +116,12 @@ def _extract_thumbnail(path: str, w: int, h: int) -> Optional[QImage]: gdi32 = ctypes.windll.gdi32 user32 = ctypes.windll.user32 - ole32.CoInitializeEx(None, 0) # COINIT_APARTMENTTHREADED + # S_OK(0)와 S_FALSE(1)는 모두 이 스레드가 대응하는 + # CoUninitialize()를 호출해야 하는 성공 결과다. + co_hr = int(ole32.CoInitializeEx(None, 0)) # COINIT_APARTMENTTHREADED + if co_hr not in (0, 1): + return None + co_initialized = True def _make_guid(s: str): import uuid @@ -124,11 +141,10 @@ def _make_guid(s: str): vtbl = ctypes.cast(psi, POINTER(POINTER(c_void_p))) QI_fn = ctypes.WINFUNCTYPE(c_int, c_void_p, POINTER(ctypes.c_byte * 16), POINTER(c_void_p))(vtbl[0][0]) - Release_si = ctypes.WINFUNCTYPE(c_uint, c_void_p)(vtbl[0][2]) + release_si = ctypes.WINFUNCTYPE(c_uint, c_void_p)(vtbl[0][2]) psiif = c_void_p() hr = QI_fn(psi, byref(IID_ISIIF), byref(psiif)) - Release_si(psi) if hr != 0 or not psiif: return None @@ -138,12 +154,11 @@ class _SIZE(ctypes.Structure): vtbl2 = ctypes.cast(psiif, POINTER(POINTER(c_void_p))) GetImage_fn = ctypes.WINFUNCTYPE(c_int, c_void_p, _SIZE, c_uint, POINTER(wintypes.HBITMAP))(vtbl2[0][3]) - Release_siif = ctypes.WINFUNCTYPE(c_uint, c_void_p)(vtbl2[0][2]) + release_siif = ctypes.WINFUNCTYPE(c_uint, c_void_p)(vtbl2[0][2]) SIIGBF_BIGGERSIZEOK = 0x1 hbm = wintypes.HBITMAP() hr = GetImage_fn(psiif, _SIZE(w, h), SIIGBF_BIGGERSIZEOK, byref(hbm)) - Release_siif(psiif) if hr != 0 or not hbm: return None @@ -159,6 +174,8 @@ class _BITMAPINFOHEADER(ctypes.Structure): ] hdc = user32.GetDC(0) + if not hdc: + return None bih = _BITMAPINFOHEADER() bih.biSize = ctypes.sizeof(_BITMAPINFOHEADER) bih.biWidth = w @@ -168,9 +185,9 @@ class _BITMAPINFOHEADER(ctypes.Structure): bih.biCompression = 0 # BI_RGB buf = ctypes.create_string_buffer(w * h * 4) - gdi32.GetDIBits(hdc, hbm, 0, h, buf, byref(bih), 0) - user32.ReleaseDC(0, hdc) - gdi32.DeleteObject(hbm) + copied_rows = gdi32.GetDIBits(hdc, hbm, 0, h, buf, byref(bih), 0) + if copied_rows != h: + return None img = QImage(buf, w, h, w * 4, QImage.Format.Format_ARGB32) return img.copy() # buf GC 전에 복사 @@ -178,11 +195,27 @@ class _BITMAPINFOHEADER(ctypes.Structure): except Exception as exc: logger.debug("비디오 썸네일 추출 실패 (%s): %s", path, exc) return None + finally: + cleanup_calls = ( + ("DC", user32.ReleaseDC, (0, hdc)) if hdc and user32 is not None else None, + ("HBITMAP", gdi32.DeleteObject, (hbm,)) if hbm and gdi32 is not None else None, + ("IShellItemImageFactory", release_siif, (psiif,)) if psiif and release_siif is not None else None, + ("IShellItem", release_si, (psi,)) if psi and release_si is not None else None, + ("COM", ole32.CoUninitialize, ()) if co_initialized and ole32 is not None else None, + ) + for cleanup in cleanup_calls: + if cleanup is None: + continue + label, function, args = cleanup + try: + function(*args) + except Exception: + logger.debug("비디오 썸네일 %s 해제 실패", label, exc_info=True) @staticmethod def _make_placeholder(w: int, h: int) -> QImage: """썸네일 추출 실패 시 회색 플레이 아이콘 플레이스홀더.""" - from PyQt6.QtGui import QPainter, QPainterPath + from PySide6.QtGui import QPainter, QPainterPath img = QImage(w, h, QImage.Format.Format_ARGB32) img.fill(QColor(40, 40, 50, 255)) painter = QPainter(img) @@ -210,7 +243,7 @@ class _HoverThumbCell(QLabel): _STYLE_NORMAL = ( "QLabel { background: rgba(255,255,255,6);" - " border: 1px solid rgba(255,255,255,15); border-radius: 3px; }" + " border: none; border-radius: 3px; }" ) _STYLE_HOVER = ( "QLabel { background: rgba(255,255,255,10);" @@ -274,8 +307,8 @@ def _tint_icon_white(icon) -> "QIcon": devicePixelRatio 를 원본에서 그대로 복사해야 HiDPI 환경에서 논리 픽셀 크기가 보존됩니다. """ - from PyQt6.QtGui import QPainter, QColor, QPixmap - from PyQt6.QtCore import Qt as _Qt + from PySide6.QtGui import QPainter, QColor, QPixmap + from PySide6.QtCore import Qt as _Qt pixmap = icon.pixmap(16, 16) if pixmap.isNull(): return icon @@ -287,7 +320,7 @@ def _tint_icon_white(icon) -> "QIcon": painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceIn) painter.fillRect(result.rect(), QColor("white")) painter.end() - from PyQt6.QtGui import QIcon + from PySide6.QtGui import QIcon return QIcon(result) @@ -316,25 +349,6 @@ def _tint_icon_white(icon) -> "QIcon": } """ -_MUTE_BTN_STYLE = """ -QPushButton { - border: 1px solid rgba(255,255,255,22); - border-radius: 3px; - background: rgba(255,255,255,10); - color: white; - font-size: 10px; -} -QPushButton:checked { - background: rgba(80,130,220,160); - border-color: rgba(100,160,255,180); - color: white; -} -QPushButton:hover:!checked { - background: rgba(255,255,255,22); -} -""" - - class SidebarWidget(QWidget): """게임 오버레이 사이드바 위젯. @@ -404,6 +418,7 @@ def __init__( frame_layout.setSpacing(8) self._build_ui(frame_layout) + apply_sidebar_widgets_style(self, dark=True) # 슬라이드 애니메이션 self._anim = QPropertyAnimation(self, b"geometry") @@ -437,19 +452,19 @@ def __init__( # 볼륨 저장 전용 직렬 스레드풀 self._volume_save_timers: dict = {} - self._save_pool = QThreadPool(self) + self._save_pool = QThreadPool() self._save_pool.setMaxThreadCount(1) # 스크린샷 썸네일 디코딩용 스레드풀 - self._thumb_pool = QThreadPool(self) + self._thumb_pool = QThreadPool() self._thumb_pool.setMaxThreadCount(2) - self._thumb_signals = _ThumbnailLoadSignals(self) + self._thumb_signals = _ThumbnailLoadSignals() self._thumb_signals.loaded.connect(self._apply_thumbnail_result) # 녹화 썸네일 디코딩용 스레드풀 (별도) - self._rec_thumb_pool = QThreadPool(self) + self._rec_thumb_pool = QThreadPool() self._rec_thumb_pool.setMaxThreadCount(2) - self._rec_thumb_signals = _ThumbnailLoadSignals(self) + self._rec_thumb_signals = _ThumbnailLoadSignals() self._rec_thumb_signals.loaded.connect(self._apply_rec_thumbnail_result) # Win32 외부 클릭 감지 상태 @@ -505,7 +520,6 @@ def _build_ui(self, layout: QVBoxLayout) -> None: # 볼륨 섹션 (항상 하단에 고정) self._vol_section = QWidget() - self._vol_section.setStyleSheet("background: transparent;") vol_section_layout = QVBoxLayout(self._vol_section) vol_section_layout.setContentsMargins(0, 10, 0, 0) vol_section_layout.setSpacing(4) @@ -515,7 +529,6 @@ def _build_ui(self, layout: QVBoxLayout) -> None: vol_section_layout.addWidget(vol_title) self._vol_list_container = QWidget() - self._vol_list_container.setStyleSheet("background: transparent;") self._vol_list_layout = QVBoxLayout(self._vol_list_container) self._vol_list_layout.setContentsMargins(0, 0, 0, 0) self._vol_list_layout.setSpacing(4) @@ -538,19 +551,6 @@ def _build_ui(self, layout: QVBoxLayout) -> None: # 닫기 버튼 (스크롤 영역 밖, 항상 하단 고정) close_btn = QPushButton("닫기") close_btn.setFixedHeight(28) - close_btn.setStyleSheet(""" - QPushButton { - background: rgba(255,255,255,10); - color: rgba(255,255,255,160); - border: 1px solid rgba(255,255,255,18); - border-radius: 4px; - font-size: 11px; - } - QPushButton:hover { - background: rgba(255,255,255,22); - color: white; - } - """) close_btn.clicked.connect(self.slide_out) layout.addWidget(close_btn) @@ -636,20 +636,36 @@ def slide_out(self) -> None: self._is_shown = False logger.debug("SidebarWidget 슬라이드아웃") - def cleanup(self) -> None: + def cleanup(self, deadline_ms: int = 2000) -> bool: """타이머와 애니메이션을 정리합니다.""" for timer in self._volume_save_timers.values(): if timer.isActive(): timer.stop() timer.timeout.emit() self._volume_save_timers.clear() - self._save_pool.waitForDone(2000) self._auto_hide_timer.stop() self._playtime_timer.stop() self._clock_timer.stop() self._cursor_poll_timer.stop() + self._rec_timer.stop() self._anim.stop() + for signals, receiver in ( + (self._thumb_signals, self._apply_thumbnail_result), + (self._rec_thumb_signals, self._apply_rec_thumbnail_result), + ): + try: + signals.loaded.disconnect(receiver) + except (TypeError, RuntimeError): + pass + deadline = time.monotonic() + max(0, int(deadline_ms)) / 1000.0 + drained = True + for pool in (self._save_pool, self._thumb_pool, self._rec_thumb_pool): + pool_drained = pool.waitForDone(max(0, int((deadline - time.monotonic()) * 1000))) + if not pool_drained: + retain_detached_qthreadpool(pool) + drained = drained and pool_drained self.hide() + return drained # ------------------------------------------------------------------ # 이벤트 오버라이드 @@ -712,10 +728,8 @@ def _make_game_cluster( 구성: [아이콘 + 이름] / [오늘 플레이타임] / [게임 종료 버튼] """ - cluster = QWidget() - cluster.setStyleSheet( - "QWidget { background: rgba(255,255,255,5); border: 1px solid rgba(255,255,255,10); border-radius: 8px; }" - ) + cluster = QFrame() + cluster.setProperty("hhRole", "sidebarGroup") layout = QVBoxLayout(cluster) layout.setContentsMargins(10, 10, 10, 10) layout.setSpacing(7) @@ -726,9 +740,7 @@ def _make_game_cluster( icon_label = QLabel() icon_label.setFixedSize(40, 40) - icon_label.setStyleSheet( - "background: rgba(255,255,255,8); border: 1px solid rgba(255,255,255,12); border-radius: 10px;" - ) + icon_label.setStyleSheet("background: rgba(255,255,255,8); border: none; border-radius: 10px;") icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter) icon_label.setScaledContents(True) header.addWidget(icon_label) @@ -760,17 +772,7 @@ def _make_game_cluster( # ── 게임 종료 버튼 ── kill_btn = QPushButton("게임 종료") kill_btn.setFixedHeight(28) - kill_btn.setStyleSheet(""" - QPushButton { - background: rgba(160, 30, 30, 160); - color: rgba(255,200,200,220); - border: 1px solid rgba(200, 60, 60, 120); - border-radius: 5px; - font-size: 11px; - } - QPushButton:hover { background: rgba(200, 40, 40, 200); color: white; } - QPushButton:pressed { background: rgba(130, 20, 20, 220); } - """) + kill_btn.setProperty("hhRole", "danger") kill_btn.clicked.connect(lambda _=False, p=pid: self._kill_process(p)) layout.addWidget(kill_btn) @@ -835,7 +837,7 @@ def _update_clock(self) -> None: def _load_icon_async(self, process: ManagedProcess, icon_label: QLabel) -> None: """게임 아이콘을 백그라운드 스레드에서 추출해 icon_label 에 반영합니다.""" - from PyQt6.QtCore import QThread, pyqtSignal as Signal + from PySide6.QtCore import QThread, Signal as Signal class _IconLoader(QThread): icon_loaded = Signal(object) @@ -907,7 +909,6 @@ def _make_vol_row(self, process: ManagedProcess, pid: Optional[int]) -> QWidget: """다크 테마 볼륨 행 (녹색 점 + 이름 + 음소거 버튼 + 슬라이더 + 값 레이블).""" is_running = pid is not None row = QWidget() - row.setStyleSheet("background: transparent; border-radius: 4px;") hl = QHBoxLayout(row) hl.setContentsMargins(4, 2, 4, 2) hl.setSpacing(4) @@ -926,11 +927,9 @@ def _make_vol_row(self, process: ManagedProcess, pid: Optional[int]) -> QWidget: hl.addWidget(name_lbl, 1) mute_btn = QPushButton() - mute_btn.setFixedSize(22, 22) mute_btn.setCheckable(True) - mute_btn.setStyleSheet(_MUTE_BTN_STYLE) + mute_btn.setProperty("hhRole", "muteToggle") - from PyQt6.QtWidgets import QStyle style = QApplication.style() if style: icon_on = style.standardIcon(QStyle.StandardPixmap.SP_MediaVolume) @@ -1096,7 +1095,7 @@ def _reset_auto_hide(self) -> None: self._auto_hide_timer.start(self._auto_hide_ms) def _poll_cursor(self) -> None: - from PyQt6.QtGui import QCursor + from PySide6.QtGui import QCursor cursor_pos = QCursor.pos() inside = self.rect().contains(self.mapFromGlobal(cursor_pos)) @@ -1147,16 +1146,6 @@ def _build_screenshot_section(self) -> QWidget: ) self._capture_now_btn = QPushButton("지금 촬영") self._capture_now_btn.setFixedHeight(28) - self._capture_now_btn.setStyleSheet(""" - QPushButton { - background: rgba(255,255,255,10); - color: rgba(255,255,255,160); - border: 1px solid rgba(255,255,255,18); - border-radius: 4px; - font-size: 11px; - } - QPushButton:hover { background: rgba(255,255,255,22); color: white; } - """) self._capture_now_btn.clicked.connect(self._on_capture_now_clicked) header.addWidget(title) header.addStretch() @@ -1189,17 +1178,7 @@ def _build_recording_section(self) -> QWidget: ) self._rec_start_btn = QPushButton("지금 녹화") self._rec_start_btn.setFixedHeight(28) - self._rec_start_btn.setStyleSheet(""" - QPushButton { - background: rgba(180,40,40,160); - color: rgba(255,200,200,220); - border: 1px solid rgba(220,60,60,120); - border-radius: 4px; - font-size: 11px; - } - QPushButton:hover { background: rgba(220,50,50,200); color: white; } - QPushButton:pressed { background: rgba(140,20,20,220); } - """) + self._rec_start_btn.setProperty("hhRole", "danger") self._rec_start_btn.clicked.connect(self._on_rec_start_clicked) self._rec_start_btn.hide() header.addWidget(title) @@ -1207,41 +1186,19 @@ def _build_recording_section(self) -> QWidget: header.addWidget(self._rec_start_btn) layout.addLayout(header) - self._rec_status_label = QLabel("○ OBS 오프라인") - self._rec_status_label.setStyleSheet("color: #888; font-size: 12px;") + self._rec_status_label = QLabel("OBS 오프라인") + self._rec_status_label.setProperty("hhState", "default") layout.addWidget(self._rec_status_label) - self._rec_stop_btn = QPushButton("■ 녹화 종료") + self._rec_stop_btn = QPushButton("녹화 종료") self._rec_stop_btn.setFixedHeight(28) - self._rec_stop_btn.setStyleSheet(""" - QPushButton { - background: rgba(160, 30, 30, 160); - color: rgba(255,200,200,220); - border: 1px solid rgba(200, 60, 60, 120); - border-radius: 5px; - font-size: 11px; - } - QPushButton:hover { background: rgba(200, 40, 40, 200); color: white; } - QPushButton:pressed { background: rgba(130, 20, 20, 220); } - """) + self._rec_stop_btn.setProperty("hhRole", "danger") self._rec_stop_btn.clicked.connect(self._on_rec_stop_clicked) self._rec_stop_btn.hide() layout.addWidget(self._rec_stop_btn) # OBS 재연결 버튼 (obs_offline 상태에서만 표시) - self._rec_connect_btn = QPushButton("↺ OBS 재연결") - self._rec_connect_btn.setFixedHeight(26) - self._rec_connect_btn.setStyleSheet(""" - QPushButton { - background: rgba(255,255,255,12); - color: rgba(180,200,240,200); - border: 1px solid rgba(255,255,255,25); - border-radius: 5px; - font-size: 11px; - } - QPushButton:hover { background: rgba(255,255,255,22); color: white; } - QPushButton:pressed { background: rgba(255,255,255,8); } - """) + self._rec_connect_btn = QPushButton("OBS 재연결") self._rec_connect_btn.clicked.connect(self._on_rec_connect_clicked) layout.addWidget(self._rec_connect_btn) @@ -1296,26 +1253,26 @@ def _update_rec_ui(self) -> None: self._rec_elapsed_sec = elapsed mins, secs = divmod(elapsed, 60) hrs, mins = divmod(mins, 60) - self._rec_status_label.setText(f"● REC {hrs:02d}:{mins:02d}:{secs:02d}") - self._rec_status_label.setStyleSheet("color: #e05555; font-size: 12px;") + self._rec_status_label.setText(f"REC {hrs:02d}:{mins:02d}:{secs:02d}") + status_style = "error" self._rec_stop_btn.show() self._rec_start_btn.hide() self._rec_connect_btn.hide() elif state == "idle": - self._rec_status_label.setText("● OBS 대기 중") - self._rec_status_label.setStyleSheet("color: #5aaa5a; font-size: 12px;") + self._rec_status_label.setText("OBS 대기 중") + status_style = "success" self._rec_stop_btn.hide() self._rec_start_btn.show() self._rec_connect_btn.hide() elif state == "connecting": - self._rec_status_label.setText("○ OBS 연결 중...") - self._rec_status_label.setStyleSheet("color: #aaa850; font-size: 12px;") + self._rec_status_label.setText("OBS 연결 중...") + status_style = "warning" self._rec_stop_btn.hide() self._rec_start_btn.hide() self._rec_connect_btn.hide() else: # obs_offline - self._rec_status_label.setText("○ OBS 오프라인") - self._rec_status_label.setStyleSheet("color: #888; font-size: 12px;") + self._rec_status_label.setText("OBS 오프라인") + status_style = "default" self._rec_stop_btn.hide() self._rec_start_btn.hide() self._rec_connect_btn.show() @@ -1323,6 +1280,9 @@ def _update_rec_ui(self) -> None: err = self._get_recording_error() if self._get_recording_error else "" self._rec_status_label.setToolTip(err if err else "") self._rec_connect_btn.setToolTip(err if err else "") + self._rec_status_label.setProperty("hhState", status_style) + self._rec_status_label.style().unpolish(self._rec_status_label) + self._rec_status_label.style().polish(self._rec_status_label) def _update_rec_timer(self) -> None: """1초 tick. recording 상태일 때 표시 시간을 갱신.""" @@ -1358,7 +1318,7 @@ def _refresh_screenshot_section(self) -> None: # 캡처 버튼 활성화 여부 (ScreenshotManager 참조는 MainWindow에 있으므로 항상 활성) self._capture_now_btn.setEnabled(True) - @pyqtSlot() + @Slot() def _refresh_screenshot_thumbnails(self) -> None: """스크린샷 썸네일 그리드를 최신 파일로 갱신합니다.""" self._thumb_request_id += 1 @@ -1405,20 +1365,13 @@ def _refresh_screenshot_thumbnails(self) -> None: self._thumb_grid_layout.addWidget(cell, row, col) # 폴더 버튼 (마지막 셀) - folder_label = f"+{remaining}" if remaining > 0 else "\U0001F4C2" + folder_label = f"+{remaining}" if remaining > 0 else "" folder_btn = QPushButton(folder_label) folder_btn.setFixedSize(_THUMB_W, _THUMB_H) folder_btn.setToolTip("스크린샷 폴더 열기") - folder_btn.setStyleSheet(""" - QPushButton { - background: rgba(255,255,255,8); - color: rgba(180,200,240,200); - border: 1px dashed rgba(255,255,255,25); - border-radius: 3px; - font-size: 11px; - } - QPushButton:hover { background: rgba(255,255,255,18); color: white; } - """) + folder_btn.setProperty("hhRole", "folderAction") + if remaining == 0: + folder_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DirIcon)) _dir = save_dir_str def _open_folder(d: str = _dir) -> None: @@ -1444,8 +1397,8 @@ def _open() -> None: os.startfile(path_str) def _show_context_menu() -> None: - from PyQt6.QtWidgets import QMenu, QMessageBox - from PyQt6.QtGui import QCursor + from PySide6.QtWidgets import QMenu, QMessageBox + from PySide6.QtGui import QCursor menu = QMenu() menu.setStyleSheet(""" QMenu { @@ -1504,7 +1457,7 @@ def _delete_gallery_file( cache: dict, refresh: Callable[[], None], ) -> None: - from PyQt6.QtWidgets import QMessageBox + from PySide6.QtWidgets import QMessageBox reply = QMessageBox.question( self, @@ -1527,7 +1480,7 @@ def _delete_gallery_file( cache.pop(path, None) refresh() - @pyqtSlot(int, str, object) + @Slot(int, str, object) def _apply_thumbnail_result(self, request_id: int, filepath: str, image: object) -> None: if request_id != self._thumb_request_id: return @@ -1568,13 +1521,13 @@ def _capture() -> None: def on_screenshot_captured(self, path: str) -> None: """외부(MainWindow)에서 캡처 완료 시 호출됩니다. 워커 스레드에서 호출 가능.""" - from PyQt6.QtCore import QMetaObject, Qt + from PySide6.QtCore import QMetaObject, Qt QMetaObject.invokeMethod( self, "_refresh_screenshot_thumbnails", Qt.ConnectionType.QueuedConnection, ) - @pyqtSlot() + @Slot() def _refresh_recording_thumbnails(self) -> None: """녹화 썸네일 그리드를 최신 MP4 파일로 갱신합니다.""" self._rec_thumb_request_id += 1 @@ -1613,20 +1566,13 @@ def _refresh_recording_thumbnails(self) -> None: self._rec_thumb_grid_layout.addWidget(cell, row, col) # 폴더 버튼 (마지막 셀) - folder_label = f"+{remaining}" if remaining > 0 else "\U0001F4C2" + folder_label = f"+{remaining}" if remaining > 0 else "" folder_btn = QPushButton(folder_label) folder_btn.setFixedSize(_THUMB_W, _THUMB_H) folder_btn.setToolTip("녹화 폴더 열기") - folder_btn.setStyleSheet(""" - QPushButton { - background: rgba(255,255,255,8); - color: rgba(180,200,240,200); - border: 1px dashed rgba(255,255,255,25); - border-radius: 3px; - font-size: 11px; - } - QPushButton:hover { background: rgba(255,255,255,18); color: white; } - """) + folder_btn.setProperty("hhRole", "folderAction") + if remaining == 0: + folder_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DirIcon)) _dir = output_dir def _open_rec_folder(d: str = _dir) -> None: @@ -1650,8 +1596,8 @@ def _open() -> None: os.startfile(path_str) def _show_context_menu() -> None: - from PyQt6.QtWidgets import QMenu - from PyQt6.QtGui import QCursor + from PySide6.QtWidgets import QMenu + from PySide6.QtGui import QCursor menu = QMenu() menu.setStyleSheet(""" @@ -1696,7 +1642,7 @@ def _show_context_menu() -> None: ) return cell - @pyqtSlot(int, str, object) + @Slot(int, str, object) def _apply_rec_thumbnail_result(self, request_id: int, filepath: str, image: object) -> None: if request_id != self._rec_thumb_request_id: return diff --git a/src/gui/sidebar_settings_dialog.py b/src/gui/sidebar_settings_dialog.py index 6c73004a..00ef8b3f 100644 --- a/src/gui/sidebar_settings_dialog.py +++ b/src/gui/sidebar_settings_dialog.py @@ -1,11 +1,11 @@ """사이드바 설정 대화 상자.""" import os -from PyQt6.QtWidgets import ( +from PySide6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QLabel, QCheckBox, QDoubleSpinBox, QSpinBox, QLineEdit, QGroupBox, QDialogButtonBox, QFormLayout, QComboBox, QPushButton, QFileDialog, ) -from PyQt6.QtCore import Qt, QMetaObject, pyqtSlot, Q_ARG +from PySide6.QtCore import Qt, QMetaObject, Slot, Q_ARG from src.data.data_models import ( GlobalSettings, @@ -352,7 +352,7 @@ def _import_obs_config(self) -> None: if cfg["exe_path"]: self._obs_exe_edit.setText(cfg["exe_path"]) except Exception as e: - from PyQt6.QtWidgets import QMessageBox + from PySide6.QtWidgets import QMessageBox QMessageBox.warning(self, "OBS 설정 불러오기", f"OBS 설정을 읽지 못했습니다:\n{e}") def _capture_trigger_key(self) -> None: @@ -374,7 +374,7 @@ def _capture_trigger_key(self) -> None: ), ) - @pyqtSlot(int) + @Slot(int) def _on_trigger_captured(self, vk: int) -> None: from src.screenshot.key_capture import vk_to_display_name self._ss_trigger_vk = vk @@ -382,7 +382,7 @@ def _on_trigger_captured(self, vk: int) -> None: self._ss_trigger_btn.setText("설정...") self._ss_trigger_btn.setEnabled(True) - @pyqtSlot() + @Slot() def _on_trigger_timeout(self) -> None: self._ss_trigger_btn.setText("설정...") self._ss_trigger_btn.setEnabled(True) diff --git a/src/gui/tray_manager.py b/src/gui/tray_manager.py index b636aaa1..b3f1d357 100644 --- a/src/gui/tray_manager.py +++ b/src/gui/tray_manager.py @@ -1,6 +1,6 @@ -from PyQt6.QtWidgets import QSystemTrayIcon, QMenu, QApplication, QStyle -from PyQt6.QtGui import QAction, QIcon -from PyQt6.QtCore import QObject, Qt # QObject는 많은 Qt 클래스의 기본 클래스입니다 +from PySide6.QtWidgets import QSystemTrayIcon, QMenu, QApplication, QStyle, QWidget +from PySide6.QtGui import QAction, QIcon +from PySide6.QtCore import QObject, Qt # QObject는 많은 Qt 클래스의 기본 클래스입니다 # 타입 힌팅 및 순환 참조 관련 주석: # main_window 인자의 타입 힌트는 좋은 관행이지만, MainWindow가 TrayManager를 임포트하는 경우 @@ -19,6 +19,10 @@ def __init__(self, main_window): # main_window는 MainWindow의 인스턴스가 self._setup_tray_icon_and_menu() + def _presentation_window(self): + getter = getattr(self.main_window, "presentation_window", None) + return getter() if callable(getter) else self.main_window + def _setup_tray_icon_and_menu(self): """트레이 아이콘, 툴팁 및 컨텍스트 메뉴를 설정합니다.""" # 메인 창의 아이콘을 트레이 아이콘으로 사용합니다. @@ -66,23 +70,31 @@ def _ensure_background_survival(self): app_instance = QApplication.instance() if app_instance: app_instance.setQuitOnLastWindowClosed(False) - if self.main_window: - self.main_window.setAttribute(Qt.WidgetAttribute.WA_QuitOnClose, False) + target = self._presentation_window() + if isinstance(target, QWidget): + target.setAttribute(Qt.WidgetAttribute.WA_QuitOnClose, False) def hide_window_to_tray(self, reason: str = "manual"): """메인 창만 숨기고 앱 프로세스와 트레이 아이콘은 유지합니다.""" self._ensure_background_survival() - self.main_window.hide() + self._presentation_window().hide() print(f"TrayManager: 창 숨김 처리 완료. reason={reason}") def toggle_window_visibility(self): """메인 창을 보여주거나 숨깁니다.""" - if self.main_window.isVisible() and not self.main_window.isMinimized(): + target = self._presentation_window() + is_minimized = bool(target.isMinimized()) if hasattr(target, "isMinimized") else False + if target.isVisible() and not is_minimized: self.hide_window_to_tray("toggle") else: - self.main_window.showNormal() # 복원하고 표시합니다. - self.main_window.activateWindow() # 최상단으로 가져옵니다. - self.main_window.raise_() # 다른 창들보다 위에 있도록 보장합니다. + activate = getattr(self.main_window, "activate_and_show", None) + if callable(activate): + activate() + else: + target.show() + target.raise_() + if hasattr(target, "requestActivate"): + target.requestActivate() print("TrayManager: 창 보임.") def _handle_tray_icon_activation(self, reason: QSystemTrayIcon.ActivationReason): diff --git a/src/gui/volume_panel.py b/src/gui/volume_panel.py index 502a09d9..9feb83bd 100644 --- a/src/gui/volume_panel.py +++ b/src/gui/volume_panel.py @@ -2,14 +2,15 @@ import logging from typing import Optional -from PyQt6.QtWidgets import ( +from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QSlider, QFrame, QSizePolicy, QApplication, ) -from PyQt6.QtCore import Qt, QTimer, QPoint, QRunnable, QThreadPool -from PyQt6.QtGui import QIcon +from PySide6.QtCore import Qt, QTimer, QPoint, QRunnable, QThreadPool +from PySide6.QtGui import QIcon from src.data.data_models import ManagedProcess +from src.gui.work_coordinator import retain_detached_qthreadpool from src.utils import audio_control logger = logging.getLogger(__name__) @@ -17,8 +18,8 @@ def _tint_icon_white(icon) -> QIcon: """아이콘 픽셀을 흰색으로 틴팅합니다. DPR 보존으로 HiDPI 대응.""" - from PyQt6.QtGui import QPainter, QColor, QPixmap - from PyQt6.QtCore import Qt as _Qt + from PySide6.QtGui import QPainter, QColor, QPixmap + from PySide6.QtCore import Qt as _Qt pixmap = icon.pixmap(16, 16) if pixmap.isNull(): return icon @@ -73,6 +74,15 @@ def _tint_icon_white(icon) -> QIcon: background: rgba(255,255,255,22); color: white; } +QPushButton:pressed:!checked { + background: rgba(255,255,255,38); + color: white; +} +QPushButton:checked:pressed { + background: rgba(65,105,190,220); + border-color: rgba(130,180,255,220); + color: white; +} QPushButton:disabled { color: rgba(255,255,255,60); border-color: rgba(255,255,255,15); @@ -123,7 +133,7 @@ def __init__(self, data_manager, parent=None, on_hide=None): self._data_manager = data_manager self._volume_save_timers: dict = {} # 볼륨 저장 전용 직렬 스레드풀 (순서 보장, 동시 접근 방지) - self._save_pool = QThreadPool(self) + self._save_pool = QThreadPool() self._save_pool.setMaxThreadCount(1) self._setup_ui() @@ -212,7 +222,7 @@ def _make_row(self, process: ManagedProcess, pid: Optional[int]) -> QWidget: mute_btn.setCheckable(True) mute_btn.setStyleSheet(_MUTE_BTN_STYLE) - from PyQt6.QtWidgets import QStyle + from PySide6.QtWidgets import QStyle icon_on = _tint_icon_white(_system_icon(QStyle.StandardPixmap.SP_MediaVolume)) icon_off = _tint_icon_white(_system_icon(QStyle.StandardPixmap.SP_MediaVolumeMuted)) if not icon_on.isNull(): @@ -331,14 +341,17 @@ def _save_volume_to_db(self, process: ManagedProcess): """프로세스의 볼륨 설정을 워커 스레드에서 DB에 저장.""" self._save_pool.start(_VolumeSaveRunnable(self._data_manager, process)) - def cleanup(self) -> None: + def cleanup(self, deadline_ms: int = 2000) -> bool: """앱 종료 시 대기 중인 볼륨 저장 타이머를 즉시 발화하고 스레드풀 완료를 기다립니다.""" for timer in self._volume_save_timers.values(): if timer.isActive(): timer.stop() timer.timeout.emit() self._volume_save_timers.clear() - self._save_pool.waitForDone(2000) + drained = self._save_pool.waitForDone(max(0, int(deadline_ms))) + if not drained: + retain_detached_qthreadpool(self._save_pool) + return drained def hideEvent(self, event): """패널이 숨겨질 때 (외부 클릭 포함) 콜백을 호출합니다.""" diff --git a/src/gui/widgets_style.py b/src/gui/widgets_style.py new file mode 100644 index 00000000..2f01d1fa --- /dev/null +++ b/src/gui/widgets_style.py @@ -0,0 +1,275 @@ +"""Windows Widgets presentation tokens and stylesheet. + +This module is the single visual authority for the main window and sidebar. +Runtime state remains owned by the existing GUI/controllers. +""" +from __future__ import annotations + +from PySide6.QtCore import QRectF, Qt +from PySide6.QtGui import QColor, QIcon, QPainter, QPalette, QPixmap +from PySide6.QtWidgets import QApplication, QMainWindow, QProgressBar, QWidget + + +def widgets_theme_tokens(dark: bool) -> dict[str, str]: + if dark: + return { + "surface": "#0f0f10", + "surface_raised": "#1a1a1c", + "surface_hover": "#27272a", + "surface_pressed": "#3a3a3e", + "text": "#f5f5f6", + "muted": "#a3a3aa", + "accent": "#2b2b2f", + "accent_hover": "#3a3a3f", + "success": "#3fb950", + "success_soft": "#203b28", + "warning": "#d29922", + "warning_soft": "#40351f", + "danger": "#f85149", + "danger_soft": "#472728", + "focus": "#d8d8dc", + "mute_active": "rgba(80, 130, 220, 160)", + "mute_pressed": "rgba(65, 105, 190, 220)", + } + return { + "surface": "#f3f3f3", + "surface_raised": "#ffffff", + "surface_hover": "#e5e5e7", + "surface_pressed": "#cfcfd3", + "text": "#171719", + "muted": "#696970", + "accent": "#dedee2", + "accent_hover": "#cfcfd4", + "success": "#188038", + "success_soft": "#e6f4ea", + "warning": "#9a6700", + "warning_soft": "#fff4ce", + "danger": "#c5221f", + "danger_soft": "#fce8e6", + "focus": "#45454b", + "mute_active": "rgba(80, 130, 220, 160)", + "mute_pressed": "rgba(65, 105, 190, 220)", + } + + +class CapsuleProgressBar(QProgressBar): + """극소 진행률도 원형 끝을 유지하는 6px 캡슐 진행률 표시입니다.""" + + _BUCKET_COLORS = { + "low": "success", + "medium": "warning", + "high": "#e67e22", + "full": "danger", + } + + def paintEvent(self, event) -> None: # noqa: N802 - Qt override + del event + if self.width() <= 0 or self.height() <= 0: + return + + app = QApplication.instance() + palette = app.palette() if app is not None else self.palette() + dark = ( + palette.color(QPalette.ColorRole.WindowText).lightness() + > palette.color(QPalette.ColorRole.Window).lightness() + ) + tokens = widgets_theme_tokens(dark) + rect = QRectF(0.0, 0.0, float(self.width()), float(self.height())) + radius = rect.height() / 2.0 + + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QColor(tokens["surface"])) + painter.drawRoundedRect(rect, radius, radius) + + span = self.maximum() - self.minimum() + if span <= 0 or self.value() <= self.minimum(): + painter.end() + return + + ratio = min(1.0, max(0.0, (self.value() - self.minimum()) / span)) + fill_width = min(rect.width(), max(rect.height(), rect.width() * ratio)) + fill_rect = QRectF(rect.left(), rect.top(), fill_width, rect.height()) + bucket = str(self.property("hhBucket") or "low") + color_key = self._BUCKET_COLORS.get(bucket, "success") + fill_color = tokens[color_key] if color_key in tokens else color_key + painter.setBrush(QColor(fill_color)) + painter.drawRoundedRect(fill_rect, radius, radius) + painter.end() + + +def tint_icon(icon: QIcon, color: QColor, logical_size: int = 16) -> QIcon: + """표준 아이콘을 현재 테마의 명시적 전경색으로 변환합니다.""" + pixmap = icon.pixmap(logical_size, logical_size) + if pixmap.isNull(): + return icon + tinted = QPixmap(pixmap.size()) + tinted.setDevicePixelRatio(pixmap.devicePixelRatio()) + tinted.fill(Qt.GlobalColor.transparent) + painter = QPainter(tinted) + painter.drawPixmap(0, 0, pixmap) + painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceIn) + painter.fillRect(tinted.rect(), color) + painter.end() + return QIcon(tinted) + + +def apply_widgets_palette(*, dark: bool) -> None: + app = QApplication.instance() + if app is None: + return + tokens = widgets_theme_tokens(dark) + palette = app.palette() + palette.setColor(QPalette.ColorRole.Window, QColor(tokens["surface"])) + palette.setColor(QPalette.ColorRole.WindowText, QColor(tokens["text"])) + palette.setColor(QPalette.ColorRole.Base, QColor(tokens["surface_raised"])) + palette.setColor(QPalette.ColorRole.AlternateBase, QColor(tokens["surface_hover"])) + palette.setColor(QPalette.ColorRole.Text, QColor(tokens["text"])) + palette.setColor(QPalette.ColorRole.Button, QColor(tokens["surface_raised"])) + palette.setColor(QPalette.ColorRole.ButtonText, QColor(tokens["text"])) + palette.setColor(QPalette.ColorRole.Highlight, QColor(tokens["accent"])) + palette.setColor(QPalette.ColorRole.HighlightedText, QColor(tokens["text"])) + app.setPalette(palette) + + +def apply_modern_widgets_style(window: QMainWindow, *, dark: bool) -> None: + central = window.centralWidget() + if central is None: + return + window.setProperty("hhUiVariant", "newgui-2nd") + central.setObjectName("hhMainSurface") + central.setProperty("hhSurface", True) + + for name, attribute in ( + ("primaryAction", "add_game_button"), + ("iconAction", "add_web_shortcut_button"), + ("iconAction", "dashboard_button"), + ("iconAction", "github_button"), + ): + control = getattr(window, attribute, None) + if control is not None: + control.setProperty("hhRole", name) + table = getattr(window, "process_table", None) + if table is not None: + table.setObjectName("processTable") + if central.layout() is not None: + central.layout().setContentsMargins(6, 6, 6, 6) + central.layout().setSpacing(4) + + t = widgets_theme_tokens(dark) + + window.setStyleSheet( + f""" + QWidget#hhMainSurface {{ background: {t['surface']}; color: {t['text']}; }} + QMenuBar {{ background: {t['surface']}; color: {t['text']}; padding: 4px 4px 3px 4px; spacing: 2px; }} + QMenuBar::item {{ padding: 5px 7px 4px 7px; border-radius: 4px; }} + QMenuBar::item:selected {{ background: {t['surface_hover']}; }} + QMenu {{ background: {t['surface_raised']}; color: {t['text']}; border: none; padding: 4px; }} + QMenu::item {{ padding: 5px 16px 5px 8px; border-radius: 4px; }} + QMenu::item:selected {{ background: {t['surface_hover']}; }} + QPushButton {{ + min-height: 26px; padding: 0px 8px; border-radius: 5px; + border: 1px solid transparent; background: {t['surface_raised']}; color: {t['text']}; + }} + QPushButton:hover {{ background: {t['surface_hover']}; border-color: {t['focus']}; }} + QPushButton:focus {{ border-color: {t['focus']}; }} + QPushButton:pressed {{ background: {t['surface_pressed']}; border-color: {t['focus']}; }} + QPushButton[hhRole="primaryAction"] {{ + background: {t['accent']}; color: {t['text']}; font-weight: 600; + }} + QTableWidget#processTable QPushButton[hhRole="primaryAction"] {{ + min-height: 30px; max-height: 30px; + }} + QPushButton[hhRole="primaryAction"]:hover {{ background: {t['accent_hover']}; }} + QPushButton[hhRole="primaryAction"]:pressed {{ background: {t['surface_pressed']}; }} + QPushButton[hhRole="iconAction"] {{ + min-width: 28px; max-width: 28px; min-height: 28px; max-height: 28px; padding: 0px; + }} + QPushButton[hhRole="iconAction"]:pressed {{ background: {t['surface_pressed']}; }} + QPushButton[hhState="success"] {{ background: {t['success_soft']}; color: {t['success']}; }} + QPushButton[hhState="danger"] {{ background: {t['danger_soft']}; color: {t['danger']}; }} + QPushButton[hhState="success"]:pressed, + QPushButton[hhState="danger"]:pressed {{ background: {t['surface_pressed']}; }} + QToolButton {{ + min-width: 30px; min-height: 26px; border: 1px solid transparent; border-radius: 5px; + background: transparent; color: {t['text']}; padding: 0px 7px; + }} + QToolButton:hover, QToolButton:focus {{ background: {t['surface_hover']}; border-color: {t['focus']}; }} + QToolButton:pressed {{ background: {t['surface_pressed']}; border-color: {t['focus']}; }} + QToolButton:checked {{ background: {t['accent']}; color: {t['text']}; }} + QToolButton:checked:pressed {{ background: {t['surface_pressed']}; border-color: {t['focus']}; }} + QToolButton[hhRole="menuCornerAction"] {{ + min-width: 0px; min-height: 0px; padding: 0px; + }} + QTableWidget#processTable {{ + background: {t['surface_raised']}; alternate-background-color: {t['surface_raised']}; + color: {t['text']}; border: none; outline: none; padding: 0px; + gridline-color: transparent; selection-background-color: transparent; + }} + QTableWidget#processTable::item {{ border: none; padding: 0px 2px; }} + QLabel#progressText {{ color: {t['muted']}; }} + QProgressBar {{ + min-height: 6px; max-height: 6px; border: none; border-radius: 3px; + background: transparent; color: transparent; text-align: center; + }} + QWidget#readinessStrip {{ + background: {t['surface']}; border-top: 1px solid {t['surface_hover']}; + }} + QWidget[hhRole="readinessItem"] {{ background: transparent; border: none; }} + QLabel[hhRole="readinessDot"], QLabel[hhRole="readinessText"] {{ + background: transparent; border: none; color: {t['muted']}; + }} + QLabel[hhRole="readinessDot"][hhState="green"] {{ color: {t['success']}; }} + QLabel[hhRole="readinessDot"][hhState="yellow"] {{ color: {t['warning']}; }} + QLabel[hhRole="readinessDot"][hhState="red"] {{ color: {t['danger']}; }} + QCheckBox {{ color: {t['text']}; spacing: 6px; padding-left: 2px; }} + QCheckBox[hhRole="menuCornerToggle"] {{ padding: 1px 4px 1px 2px; }} + QToolTip {{ background: {t['surface_raised']}; color: {t['text']}; border: none; padding: 5px; }} + """ + ) + + # Dynamic properties do not always repolish immediately on Windows. + for attribute in ("add_game_button", "add_web_shortcut_button", "dashboard_button", "github_button"): + control = getattr(window, attribute, None) + if control is not None: + control.style().unpolish(control) + control.style().polish(control) + + +def apply_sidebar_widgets_style(widget: QWidget, *, dark: bool) -> None: + """Apply the same surface/state language to the independent sidebar shell.""" + t = widgets_theme_tokens(dark) + widget.setStyleSheet( + f""" + QWidget {{ color: {t['text']}; }} + QFrame[hhRole="sidebarGroup"] {{ background: {t['surface_raised']}; border: none; border-radius: 6px; }} + QPushButton {{ min-height: 26px; border: 1px solid transparent; border-radius: 5px; padding: 0px 8px; background: {t['surface_raised']}; }} + QPushButton:hover, QPushButton:focus {{ background: {t['surface_hover']}; border-color: {t['focus']}; }} + QPushButton:pressed {{ background: {t['surface_pressed']}; border-color: {t['focus']}; }} + QPushButton:checked {{ color: {t['text']}; background: {t['accent']}; }} + QPushButton[hhRole="danger"] {{ color: {t['danger']}; background: {t['danger_soft']}; }} + QPushButton[hhRole="primaryAction"] {{ color: {t['text']}; background: {t['accent']}; font-weight: 600; }} + QPushButton[hhRole="folderAction"] {{ color: {t['muted']}; background: {t['surface_raised']}; }} + QPushButton[hhRole="danger"]:pressed, + QPushButton[hhRole="primaryAction"]:pressed, + QPushButton[hhRole="folderAction"]:pressed {{ background: {t['surface_pressed']}; border-color: {t['focus']}; }} + QPushButton[hhRole="muteToggle"] {{ + min-width: 20px; max-width: 20px; min-height: 20px; max-height: 20px; + padding: 0px; background: {t['surface_raised']}; + }} + QPushButton[hhRole="muteToggle"]:checked {{ background: {t['mute_active']}; color: white; }} + QPushButton[hhRole="muteToggle"]:checked:hover, + QPushButton[hhRole="muteToggle"]:checked:focus {{ + background: {t['mute_active']}; border-color: {t['focus']}; + }} + QPushButton[hhRole="muteToggle"]:checked:pressed {{ + background: {t['mute_pressed']}; border-color: {t['focus']}; + }} + QLabel[hhState="error"] {{ color: {t['danger']}; }} + QLabel[hhState="success"] {{ color: {t['success']}; }} + QLabel[hhState="warning"] {{ color: {t['warning']}; }} + QLabel[hhState="default"] {{ color: {t['muted']}; }} + QToolTip {{ background: {t['surface_raised']}; color: {t['text']}; border: none; padding: 5px; }} + """ + ) diff --git a/src/gui/work_coordinator.py b/src/gui/work_coordinator.py new file mode 100644 index 00000000..7b317d00 --- /dev/null +++ b/src/gui/work_coordinator.py @@ -0,0 +1,303 @@ +"""Bounded background work scheduling for the Qt GUI.""" +from __future__ import annotations + +from collections import defaultdict, deque +from dataclasses import dataclass +import logging +import threading +import time +from types import MappingProxyType +from typing import Any, Callable, Deque, Mapping + +from PySide6.QtCore import QObject, QRunnable, QThreadPool, Signal, Slot + +logger = logging.getLogger(__name__) + + +_DRAINING_POOLS: set["_PoolLifetime"] = set() +_DRAINING_POOLS_LOCK = threading.Lock() +_DETACHED_RAW_POOLS: set[QThreadPool] = set() +_DETACHED_RAW_POOLS_LOCK = threading.Lock() + + +def retain_detached_qthreadpool(pool: QThreadPool) -> None: + """시간 안에 끝나지 않은 parentless pool의 소멸 대기를 피합니다.""" + with _DETACHED_RAW_POOLS_LOCK: + _DETACHED_RAW_POOLS.add(pool) + + +def _freeze(value: Any) -> Any: + if isinstance(value, Mapping): + return MappingProxyType({key: _freeze(item) for key, item in value.items()}) + if isinstance(value, (list, tuple)): + return tuple(_freeze(item) for item in value) + if isinstance(value, set): + return frozenset(_freeze(item) for item in value) + return value + + +@dataclass(frozen=True, slots=True) +class WorkResult: + lane: str + key: str + generation: int + value: Any + + +@dataclass(frozen=True, slots=True) +class WorkError: + lane: str + key: str + generation: int + exception_type: str + message: str + + +@dataclass(frozen=True, slots=True) +class WorkCoordinatorSnapshot: + accepting: bool + running_telemetry: tuple[str, ...] + pending_telemetry: tuple[str, ...] + running_lifecycle: tuple[str, ...] + pending_lifecycle: Mapping[str, int] + + +@dataclass(slots=True) +class _WorkSpec: + lane: str + key: str + generation: int + function: Callable[..., Any] + args: tuple[Any, ...] + kwargs: dict[str, Any] + + +class _PoolLifetime: + """Keep an unparented pool alive when shutdown reaches its deadline.""" + + def __init__(self, max_threads: int) -> None: + self.pool = QThreadPool() + self.pool.setMaxThreadCount(max_threads) + self.condition = threading.Condition() + self.outstanding = 0 + self.detached = False + + def started(self) -> None: + with self.condition: + self.outstanding += 1 + + def finished(self) -> None: + remove = False + with self.condition: + self.outstanding = max(0, self.outstanding - 1) + if not self.outstanding: + self.condition.notify_all() + remove = self.detached + if remove: + with _DRAINING_POOLS_LOCK: + _DRAINING_POOLS.discard(self) + + def wait_until(self, deadline: float) -> bool: + with self.condition: + while self.outstanding: + remaining = deadline - time.monotonic() + if remaining <= 0: + return False + self.condition.wait(remaining) + return True + + def detach(self) -> None: + with self.condition: + if not self.outstanding: + return + self.detached = True + with _DRAINING_POOLS_LOCK: + _DRAINING_POOLS.add(self) + + +class _WorkerSignals(QObject): + completed = Signal(object, object, object) + + +class _Worker(QRunnable): + def __init__( + self, + spec: _WorkSpec, + signals: _WorkerSignals, + shutdown_event: threading.Event, + task_done: Callable[[], None], + ) -> None: + super().__init__() + self._spec = spec + self._signals = signals + self._shutdown_event = shutdown_event + self._task_done = task_done + + def run(self) -> None: + value: Any = None + error: BaseException | None = None + try: + if not self._shutdown_event.is_set(): + try: + value = self._spec.function(*self._spec.args, **self._spec.kwargs) + except (KeyboardInterrupt, SystemExit) as exc: + error = RuntimeError(f"background worker interrupted: {type(exc).__name__}") + except Exception as exc: + error = exc + try: + self._signals.completed.emit(self._spec, value, error) + except RuntimeError: + logger.debug("GUI background completion relay is already gone") + finally: + self._task_done() + + +class GuiWorkCoordinator(QObject): + """Coalesce telemetry while preserving per-process lifecycle order.""" + + result_ready = Signal(object) + error_ready = Signal(object) + + DEFAULT_MAX_THREADS = 4 + DEFAULT_SHUTDOWN_DEADLINE_SECONDS = 2.0 + + def __init__(self, parent: QObject | None = None, *, max_threads: int = 4) -> None: + super().__init__(parent) + if max_threads < 1 or max_threads > self.DEFAULT_MAX_THREADS: + raise ValueError("max_threads must be between 1 and 4") + self._lifetime = _PoolLifetime(max_threads) + self._signals = _WorkerSignals() + self._signals.completed.connect(self._on_completed) + self._lock = threading.Lock() + self._shutdown_event = threading.Event() + self._accepting = True + self._telemetry_generation: dict[str, int] = defaultdict(int) + self._telemetry_running: dict[str, _WorkSpec] = {} + self._telemetry_pending: dict[str, _WorkSpec] = {} + self._lifecycle_generation: dict[str, int] = defaultdict(int) + self._lifecycle_running: dict[str, _WorkSpec] = {} + self._lifecycle_pending: dict[str, Deque[_WorkSpec]] = defaultdict(deque) + self._signals_disconnected = False + + @property + def pool(self) -> QThreadPool: + return self._lifetime.pool + + def submit_telemetry(self, key: str, function: Callable[..., Any], *args: Any, **kwargs: Any) -> int | None: + key = self._validate(key, function) + with self._lock: + if not self._accepting: + return None + self._telemetry_generation[key] += 1 + generation = self._telemetry_generation[key] + spec = _WorkSpec("telemetry", key, generation, function, args, kwargs) + if key in self._telemetry_running: + self._telemetry_pending[key] = spec + return generation + self._telemetry_running[key] = spec + self._start(spec) + return generation + + def submit_lifecycle(self, process_id: str, function: Callable[..., Any], *args: Any, **kwargs: Any) -> int | None: + key = self._validate(process_id, function) + with self._lock: + if not self._accepting: + return None + self._lifecycle_generation[key] += 1 + generation = self._lifecycle_generation[key] + spec = _WorkSpec("lifecycle", key, generation, function, args, kwargs) + if key in self._lifecycle_running: + self._lifecycle_pending[key].append(spec) + return generation + self._lifecycle_running[key] = spec + self._start(spec) + return generation + + def invalidate_telemetry(self, key: str | None = None) -> None: + with self._lock: + keys = [key] if key is not None else list( + set(self._telemetry_generation) | set(self._telemetry_running) | set(self._telemetry_pending) + ) + for item in keys: + self._telemetry_generation[item] += 1 + self._telemetry_pending.pop(item, None) + + def snapshot(self) -> WorkCoordinatorSnapshot: + with self._lock: + return WorkCoordinatorSnapshot( + accepting=self._accepting, + running_telemetry=tuple(sorted(self._telemetry_running)), + pending_telemetry=tuple(sorted(self._telemetry_pending)), + running_lifecycle=tuple(sorted(self._lifecycle_running)), + pending_lifecycle=MappingProxyType({ + key: len(queue) for key, queue in sorted(self._lifecycle_pending.items()) if queue + }), + ) + + def shutdown(self, *, deadline_seconds: float = 2.0) -> bool: + deadline_seconds = max(0.0, min(float(deadline_seconds), self.DEFAULT_SHUTDOWN_DEADLINE_SECONDS)) + with self._lock: + self._accepting = False + self._shutdown_event.set() + self._telemetry_pending.clear() + self._lifecycle_pending.clear() + for key in tuple(self._telemetry_generation): + self._telemetry_generation[key] += 1 + if not self._signals_disconnected: + try: + self._signals.completed.disconnect(self._on_completed) + except (TypeError, RuntimeError): + pass + self._signals_disconnected = True + drained = self._lifetime.wait_until(time.monotonic() + deadline_seconds) + if not drained: + self._lifetime.detach() + return drained + + @staticmethod + def _validate(key: str, function: Callable[..., Any]) -> str: + normalized = str(key).strip() + if not normalized: + raise ValueError("work key must not be empty") + if not callable(function): + raise TypeError("function must be callable") + return normalized + + def _start(self, spec: _WorkSpec) -> None: + self._lifetime.started() + self._lifetime.pool.start(_Worker(spec, self._signals, self._shutdown_event, self._lifetime.finished)) + + @Slot(object, object, object) + def _on_completed(self, spec: _WorkSpec, value: Any, error: BaseException | None) -> None: + next_spec: _WorkSpec | None = None + emit = False + with self._lock: + if spec.lane == "telemetry": + if self._telemetry_running.get(spec.key) is not spec: + return + self._telemetry_running.pop(spec.key, None) + next_spec = self._telemetry_pending.pop(spec.key, None) + if next_spec is not None and self._accepting: + self._telemetry_running[spec.key] = next_spec + emit = self._accepting and spec.generation == self._telemetry_generation[spec.key] + else: + if self._lifecycle_running.get(spec.key) is not spec: + return + self._lifecycle_running.pop(spec.key, None) + queue = self._lifecycle_pending.get(spec.key) + if queue and self._accepting: + next_spec = queue.popleft() + self._lifecycle_running[spec.key] = next_spec + if queue is not None and not queue: + self._lifecycle_pending.pop(spec.key, None) + emit = self._accepting + if next_spec is not None: + self._start(next_spec) + if not emit: + return + if error is None: + self.result_ready.emit(WorkResult(spec.lane, spec.key, spec.generation, _freeze(value))) + else: + self.error_ready.emit( + WorkError(spec.lane, spec.key, spec.generation, type(error).__name__, str(error)) + ) diff --git a/src/services/hoyolab.py b/src/services/hoyolab.py index a6dd66e2..58523f94 100644 --- a/src/services/hoyolab.py +++ b/src/services/hoyolab.py @@ -7,6 +7,7 @@ import logging import threading import time +from contextlib import contextmanager from dataclasses import dataclass from datetime import datetime, timedelta from typing import Any, Optional @@ -67,7 +68,15 @@ class HoYoLabService: } DAILY_CHECKIN_GAME_ORDER = ("honkai_starrail", "zenless_zone_zero") - def __init__(self, config: Optional[HoYoLabConfig] = None): + MAX_ASYNC_TIMEOUT_SECONDS = 30.0 + CLOSE_TIMEOUT_SECONDS = 2.0 + + def __init__( + self, + config: Optional[HoYoLabConfig] = None, + *, + async_timeout: float = MAX_ASYNC_TIMEOUT_SECONDS, + ): """HoYoLabService 초기화 Args: @@ -79,6 +88,10 @@ def __init__(self, config: Optional[HoYoLabConfig] = None): self._client_lock = threading.RLock() self._request_lock = threading.Lock() self._closed = False + self._async_timeout = min( + max(float(async_timeout), 0.001), + self.MAX_ASYNC_TIMEOUT_SECONDS, + ) def is_available(self) -> bool: """genshin.py 라이브러리가 사용 가능한지 확인""" @@ -118,26 +131,75 @@ def _get_client_unlocked(self) -> Optional["genshin.Client"]: def _get_client(self) -> Optional["genshin.Client"]: """genshin.py 클라이언트 인스턴스 반환 (lazy initialization)""" - with self._client_lock: + deadline = self._operation_deadline() + with self._lock_until(self._client_lock, deadline, "client"): return self._get_client_unlocked() + + def _operation_deadline(self, timeout_seconds: float | None = None) -> float: + timeout = self._async_timeout + if timeout_seconds is not None: + timeout = min(timeout, max(float(timeout_seconds), 0.0)) + return time.monotonic() + timeout + + @staticmethod + def _remaining_seconds(deadline: float) -> float: + return max(float(deadline) - time.monotonic(), 0.0) + + @contextmanager + def _lock_until(self, lock, deadline: float, label: str): + remaining = self._remaining_seconds(deadline) + if remaining <= 0.0 or not lock.acquire(timeout=remaining): + raise TimeoutError(f"HoYoLab {label} lock deadline이 만료되었습니다.") + try: + yield + finally: + lock.release() - def _run_async(self, coro): + def _run_async( + self, + coro, + *, + timeout_seconds: float | None = None, + deadline: float | None = None, + ): """비동기 코루틴을 동기적으로 실행 - - GUI 스레드에서 안전하게 비동기 API를 호출하기 위한 래퍼. + + 호출 스레드에 실행 중인 이벤트 루프가 없어야 합니다. 실행 중인 루프를 + 우회하려고 임시 스레드를 만들면 timeout 뒤에도 provider 호출이 살아남을 + 수 있으므로, async 호출자는 provider의 비동기 API를 직접 사용해야 합니다. """ try: - # 기존 이벤트 루프가 있는지 확인 - try: - loop = asyncio.get_running_loop() - # 이미 실행 중인 루프가 있으면 새 스레드에서 실행 - import concurrent.futures - with concurrent.futures.ThreadPoolExecutor() as executor: - future = executor.submit(asyncio.run, coro) - return future.result(timeout=30) - except RuntimeError: - # 실행 중인 루프가 없으면 직접 실행 - return asyncio.run(coro) + asyncio.get_running_loop() + except RuntimeError: + pass + else: + closer = getattr(coro, "close", None) + if callable(closer): + closer() + raise RuntimeError( + "실행 중인 이벤트 루프에서는 HoYoLab 동기 API를 호출할 수 없습니다. " + "비동기 provider API를 직접 await 하세요." + ) + + timeout = self._async_timeout + if timeout_seconds is not None: + timeout = min(timeout, max(float(timeout_seconds), 0.0)) + if deadline is not None: + timeout = min(timeout, self._remaining_seconds(deadline)) + if timeout <= 0.0: + closer = getattr(coro, "close", None) + if callable(closer): + closer() + raise TimeoutError("HoYoLab 요청 deadline이 만료되었습니다.") + try: + return asyncio.run( + asyncio.wait_for(coro, timeout=timeout) + ) + except asyncio.TimeoutError as exc: + display_timeout = round(timeout, 3) + raise TimeoutError( + f"HoYoLab 요청이 {display_timeout:g}초 안에 완료되지 않았습니다." + ) from exc except Exception as e: logger.error(f"비동기 실행 오류: {e}") raise @@ -159,50 +221,66 @@ def get_stamina(self, hoyolab_game_id: str) -> Optional[StaminaInfo]: logger.warning(f"지원하지 않는 게임 타입: {hoyolab_game_id}") return None - def claim_daily_rewards(self, game_ids: Optional[list[str]] = None) -> list[HoYoLabDailyCheckInResult]: + def claim_daily_rewards( + self, + game_ids: Optional[list[str]] = None, + *, + timeout_seconds: float | None = None, + ) -> list[HoYoLabDailyCheckInResult]: """HoYoLAB 일일 출석 체크를 순차 실행합니다. 임시 검증 버튼에서 사용하는 실제 POST 경로입니다. 기본 순서는 현재 호스트에서 추적 중인 HoYoLAB 게임인 붕괴: 스타레일 → 젠레스 존 제로입니다. """ targets = list(game_ids or self.DAILY_CHECKIN_GAME_ORDER) + deadline = self._operation_deadline(timeout_seconds) if not GENSHIN_AVAILABLE: return [self._daily_checkin_result(game_id, "unavailable", "genshin.py 라이브러리를 사용할 수 없습니다.") for game_id in targets] if not self.is_configured(): return [self._daily_checkin_result(game_id, "auth_required", "HoYoLab 인증 정보가 없습니다.") for game_id in targets] - with self._client_lock: - client = self._get_client_unlocked() - if not client: - return [self._daily_checkin_result(game_id, "auth_required", "HoYoLab 클라이언트를 초기화하지 못했습니다.") for game_id in targets] - try: - with self._request_lock: + with self._lock_until(self._client_lock, deadline, "client"): + client = self._get_client_unlocked() + if not client: + return [self._daily_checkin_result(game_id, "auth_required", "HoYoLab 클라이언트를 초기화하지 못했습니다.") for game_id in targets] + with self._lock_until(self._request_lock, deadline, "request"): if self._closed: return [self._daily_checkin_result(game_id, "unavailable", "HoYoLab 서비스가 종료되었습니다.") for game_id in targets] - return self._run_async(self._async_claim_daily_rewards(client, targets)) + return self._run_async( + self._async_claim_daily_rewards(client, targets), + deadline=deadline, + ) except Exception as exc: logger.error("HoYoLab 일일 출석 순차 실행 실패: %s", exc) return [self._daily_checkin_result(game_id, "network_error", str(exc)) for game_id in targets] - def get_daily_reward_status(self, game_ids: Optional[list[str]] = None) -> list[HoYoLabDailyCheckInResult]: + def get_daily_reward_status( + self, + game_ids: Optional[list[str]] = None, + *, + timeout_seconds: float | None = None, + ) -> list[HoYoLabDailyCheckInResult]: """HoYoLAB 일일 출석 상태를 POST 없이 조회합니다.""" targets = list(game_ids or self.DAILY_CHECKIN_GAME_ORDER) + deadline = self._operation_deadline(timeout_seconds) if not GENSHIN_AVAILABLE: return [self._daily_checkin_result(game_id, "unavailable", "genshin.py 라이브러리를 사용할 수 없습니다.") for game_id in targets] if not self.is_configured(): return [self._daily_checkin_result(game_id, "auth_required", "HoYoLab 인증 정보가 없습니다.") for game_id in targets] - with self._client_lock: - client = self._get_client_unlocked() - if not client: - return [self._daily_checkin_result(game_id, "auth_required", "HoYoLab 클라이언트를 초기화하지 못했습니다.") for game_id in targets] - try: - with self._request_lock: + with self._lock_until(self._client_lock, deadline, "client"): + client = self._get_client_unlocked() + if not client: + return [self._daily_checkin_result(game_id, "auth_required", "HoYoLab 클라이언트를 초기화하지 못했습니다.") for game_id in targets] + with self._lock_until(self._request_lock, deadline, "request"): if self._closed: return [self._daily_checkin_result(game_id, "unavailable", "HoYoLab 서비스가 종료되었습니다.") for game_id in targets] - return self._run_async(self._async_get_daily_reward_statuses(client, targets)) + return self._run_async( + self._async_get_daily_reward_statuses(client, targets), + deadline=deadline, + ) except Exception as exc: logger.error("HoYoLab 일일 출석 상태 조회 실패: %s", exc) return [self._daily_checkin_result(game_id, "network_error", str(exc)) for game_id in targets] @@ -314,17 +392,17 @@ def _to_int(value: Any) -> int | None: def get_starrail_stamina(self) -> Optional[StaminaInfo]: """붕괴: 스타레일 개척력 정보 조회""" - with self._client_lock: - client = self._get_client_unlocked() - if not client: - return None - + deadline = self._operation_deadline() try: - with self._request_lock: + with self._lock_until(self._client_lock, deadline, "client"): + client = self._get_client_unlocked() + if not client: + return None + with self._lock_until(self._request_lock, deadline, "request"): if self._closed: logger.debug("닫힌 HoYoLab 서비스에서 스타레일 요청을 건너뜁니다.") return None - return self._run_async(self._async_get_starrail_stamina(client)) + return self._run_async(self._async_get_starrail_stamina(client), deadline=deadline) except Exception as e: logger.error(f"스타레일 스태미나 조회 실패: {e}") return None @@ -361,17 +439,17 @@ async def _async_get_starrail_stamina(self, client: "genshin.Client") -> Optiona def get_zzz_stamina(self) -> Optional[StaminaInfo]: """젠레스 존 제로 배터리 정보 조회""" - with self._client_lock: - client = self._get_client_unlocked() - if not client: - return None - + deadline = self._operation_deadline() try: - with self._request_lock: + with self._lock_until(self._client_lock, deadline, "client"): + client = self._get_client_unlocked() + if not client: + return None + with self._lock_until(self._request_lock, deadline, "request"): if self._closed: logger.debug("닫힌 HoYoLab 서비스에서 ZZZ 요청을 건너뜁니다.") return None - return self._run_async(self._async_get_zzz_stamina(client)) + return self._run_async(self._async_get_zzz_stamina(client), deadline=deadline) except Exception as e: logger.error(f"ZZZ 배터리 조회 실패: {e}") return None @@ -407,20 +485,45 @@ async def _async_get_zzz_stamina(self, client: "genshin.Client") -> Optional[Sta logger.error(f"ZZZ API 호출 실패: {e}") return None - def close(self) -> None: + def close(self) -> bool: """클라이언트 연결 종료""" - with self._client_lock: - self._closed = True - client = self._client - self._client = None + deadline = self._operation_deadline(min(self._async_timeout, self.CLOSE_TIMEOUT_SECONDS)) + try: + with self._lock_until(self._client_lock, deadline, "client"): + self._closed = True + client = self._client + except Exception as exc: + logger.warning("HoYoLab 클라이언트 종료 진입 실패: %s", exc, exc_info=True) + return False + + if not client: + return True + + try: + with self._lock_until(self._request_lock, deadline, "request"): + with self._lock_until(self._client_lock, deadline, "client"): + if self._client is client: + self._client = None + try: + self._run_async(client.close(), deadline=deadline) + except Exception: + with self._client_lock: + if self._client is None: + self._client = client + self._closed = False + raise + except Exception as exc: + with self._client_lock: + self._closed = False + logger.warning( + "HoYoLab 클라이언트 종료 실패로 기존 인스턴스를 유지합니다: %s", + exc, + exc_info=True, + ) + return False - if client: - try: - with self._request_lock: - self._run_async(client.close()) - except Exception as exc: - logger.debug("HoYoLab 클라이언트 종료 중 예외 발생: %s", exc, exc_info=True) - logger.info("HoYoLab 클라이언트 연결 종료") + logger.info("HoYoLab 클라이언트 연결 종료") + return True # 전역 서비스 인스턴스 (싱글톤) @@ -441,6 +544,5 @@ def reset_hoyolab_service() -> None: """HoYoLabService 인스턴스 리셋 (설정 변경 시 호출)""" global _service_instance with _service_lock: - if _service_instance: - _service_instance.close() - _service_instance = None + if _service_instance is None or _service_instance.close(): + _service_instance = None diff --git a/src/services/nikke.py b/src/services/nikke.py index 48a1cea9..c5f65fb5 100644 --- a/src/services/nikke.py +++ b/src/services/nikke.py @@ -15,6 +15,10 @@ logger = logging.getLogger(__name__) +class _NikkeDeadlineExceeded(TimeoutError): + """Raised before another HTTP request starts after the shared deadline.""" + + @dataclass class GameResourceSnapshot: provider: str @@ -73,10 +77,15 @@ class NikkeService: DAILY_CHECKIN_TASK_TYPE = 1 DAILY_CHECKIN_TASK_ID = "15" WEB_REFERER = "https://www.blablalink.com/nikke/" + MAX_HTTP_TIMEOUT_SECONDS = 10.0 + MAX_DAILY_CHECKIN_TIMEOUT_SECONDS = 30.0 def __init__(self, config: Optional[NikkeConfig] = None, timeout: float = 10.0): self._config = config or NikkeConfig() - self._timeout = timeout + self._timeout = min( + max(float(timeout), 0.001), + self.MAX_HTTP_TIMEOUT_SECONDS, + ) def is_configured(self) -> bool: return self._config.is_configured() @@ -165,16 +174,28 @@ def _json_response(response: requests.Response) -> dict[str, Any]: except ValueError as exc: raise RuntimeError("BlablaLink API가 JSON이 아닌 응답을 반환했습니다.") from exc - def _request(self, method: str, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]: + def _request( + self, + method: str, + path: str, + payload: dict[str, Any] | None = None, + *, + timeout_seconds: float | None = None, + deadline: float | None = None, + ) -> dict[str, Any]: session_payload, cookies = self._request_session() if not cookies: return {"code": "auth_required", "msg": "BlablaLink 세션이 없습니다."} + timeout = self._request_timeout_seconds( + timeout_seconds=timeout_seconds, + deadline=deadline, + ) http_session = requests.Session() try: http_session.cookies.update(self._cookie_mapping(cookies)) request_kwargs = { "headers": self._request_headers(), - "timeout": self._timeout, + "timeout": timeout, } if method.upper() == "GET": response = http_session.get(self.API_BASE + path, params=payload or {}, **request_kwargs) @@ -187,11 +208,61 @@ def _request(self, method: str, path: str, payload: dict[str, Any] | None = None if callable(closer): closer() - def _post(self, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]: - return self._request("POST", path, payload) + def _request_timeout_seconds( + self, + *, + timeout_seconds: float | None = None, + deadline: float | None = None, + ) -> float: + """Return one HTTP timeout while preserving the shared operation deadline.""" + + timeout = self._timeout + if timeout_seconds is not None: + timeout = min(timeout, max(float(timeout_seconds), 0.001)) + if deadline is not None: + remaining = float(deadline) - time.monotonic() + if remaining <= 0.0: + raise _NikkeDeadlineExceeded("NIKKE 요청 deadline이 만료되었습니다.") + timeout = min(timeout, remaining) + return min(timeout, self.MAX_HTTP_TIMEOUT_SECONDS) + + def _daily_checkin_deadline(self, timeout_seconds: float | None) -> float: + budget = self.MAX_DAILY_CHECKIN_TIMEOUT_SECONDS + if timeout_seconds is not None: + budget = min(budget, max(float(timeout_seconds), 0.0)) + return time.monotonic() + budget + + def _post( + self, + path: str, + payload: dict[str, Any] | None = None, + *, + timeout_seconds: float | None = None, + deadline: float | None = None, + ) -> dict[str, Any]: + return self._request( + "POST", + path, + payload, + timeout_seconds=timeout_seconds, + deadline=deadline, + ) - def _get(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]: - return self._request("GET", path, params) + def _get( + self, + path: str, + params: dict[str, Any] | None = None, + *, + timeout_seconds: float | None = None, + deadline: float | None = None, + ) -> dict[str, Any]: + return self._request( + "GET", + path, + params, + timeout_seconds=timeout_seconds, + deadline=deadline, + ) def _call_endpoint(self, method: str, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]: method = method.upper() @@ -247,13 +318,19 @@ def check_login(self) -> tuple[bool, str]: code = self._response_code(body) return (code in (0, "0", None)), str(body.get("msg") or body.get("message") or "ok") - def get_daily_checkin_status(self) -> NikkeDailyCheckInStatus: + def get_daily_checkin_status(self, *, timeout_seconds: float | None = None) -> NikkeDailyCheckInStatus: """BlablaLink NIKKE 일일 출석 체크 상태를 읽기 전용으로 조회합니다. 실제 출석 처리 endpoint인 ``DailyCheckIn`` POST는 호출하지 않습니다. ShiftyPad 웹앱이 출석 버튼 노출에 사용하는 task status endpoint만 조회하여 오늘 출석 가능/완료/인증 필요 상태를 판별합니다. """ + deadline = self._daily_checkin_deadline(timeout_seconds) + return self._get_daily_checkin_status(deadline) + + def _get_daily_checkin_status(self, deadline: float) -> NikkeDailyCheckInStatus: + """Run both status fallbacks against one absolute monotonic deadline.""" + now = datetime.now() if not self.is_configured(): return NikkeDailyCheckInStatus( @@ -267,6 +344,7 @@ def get_daily_checkin_status(self) -> NikkeDailyCheckInStatus: body = self._get( self.DAILY_CHECKIN_STATUS_PATH, {"get_top": get_top, "intl_game_id": "nikke"}, + deadline=deadline, ) except Exception as exc: logger.error("NIKKE 출석 상태 조회 실패: %s", exc) @@ -278,7 +356,7 @@ def get_daily_checkin_status(self) -> NikkeDailyCheckInStatus: return NikkeDailyCheckInStatus("route_error", now, message="BlablaLink 출석 task를 찾지 못했습니다.") - def claim_daily_checkin(self) -> NikkeDailyCheckInStatus: + def claim_daily_checkin(self, *, timeout_seconds: float | None = None) -> NikkeDailyCheckInStatus: """BlablaLink NIKKE 일일 출석 체크를 실제 POST로 실행합니다. 안전한 task id 확인을 위해 먼저 읽기 전용 status endpoint를 호출합니다. @@ -286,14 +364,19 @@ def claim_daily_checkin(self) -> NikkeDailyCheckInStatus: 완료되었거나 인증/route 문제가 있는 경우에는 POST를 생략하고 해당 상태를 그대로 반환합니다. """ - status = self.get_daily_checkin_status() + deadline = self._daily_checkin_deadline(timeout_seconds) + status = self._get_daily_checkin_status(deadline) if status.status != "ready": status.raw_debug = {**status.raw_debug, "post_called": False} return status task_id = status.task_id or self.DAILY_CHECKIN_TASK_ID try: - body = self._post(self.DAILY_CHECKIN_POST_PATH, {"task_id": task_id}) + body = self._post( + self.DAILY_CHECKIN_POST_PATH, + {"task_id": task_id}, + deadline=deadline, + ) except Exception as exc: logger.error("NIKKE 출석 체크 POST 실패: %s", exc) return NikkeDailyCheckInStatus( @@ -305,7 +388,10 @@ def claim_daily_checkin(self) -> NikkeDailyCheckInStatus: completed_times=status.completed_times, need_completed_times=status.need_completed_times, message=str(exc), - raw_debug={"task_id": task_id, "post_called": True}, + raw_debug={ + "task_id": task_id, + "post_called": not isinstance(exc, _NikkeDeadlineExceeded), + }, ) return self._parse_daily_checkin_post_result(body, status, task_id=task_id) diff --git a/src/utils/browser_cookie_extractor.py b/src/utils/browser_cookie_extractor.py index 2ad25159..fd1a9e93 100644 --- a/src/utils/browser_cookie_extractor.py +++ b/src/utils/browser_cookie_extractor.py @@ -404,11 +404,14 @@ def _normalise_provider_cookies( @staticmethod def cookie_header(cookies: dict[str, Any]) -> str: """requests 헤더에 넣을 수 있는 Cookie 문자열을 생성합니다.""" + cookie_safe_chars = "!#$%&'()*+-./:<=>?@[]^_`{|}~" parts = [] for name, value in sorted(cookies.items()): if value is None: continue - parts.append(f"{quote(str(name), safe='')}={quote(str(value), safe='!#$%&\'()*+-./:<=>?@[]^_`{|}~')}") + encoded_name = quote(str(name), safe="") + encoded_value = quote(str(value), safe=cookie_safe_chars) + parts.append(f"{encoded_name}={encoded_value}") return "; ".join(parts) def _get_local_state_path(self, browser: str) -> Optional[Path]: diff --git a/src/utils/clipboard.py b/src/utils/clipboard.py index 0b59c0a5..6ffc5fe0 100644 --- a/src/utils/clipboard.py +++ b/src/utils/clipboard.py @@ -10,9 +10,9 @@ import time from pathlib import Path -from PyQt6.QtCore import QBuffer, QIODevice, QMimeData, QUrl -from PyQt6.QtGui import QImage -from PyQt6.QtWidgets import QApplication +from PySide6.QtCore import QBuffer, QIODevice, QMimeData, QUrl +from PySide6.QtGui import QImage +from PySide6.QtWidgets import QApplication logger = logging.getLogger(__name__) diff --git a/src/utils/process.py b/src/utils/process.py index e96f5107..fd197a16 100644 --- a/src/utils/process.py +++ b/src/utils/process.py @@ -5,9 +5,9 @@ import sys import hashlib import configparser -from PyQt6.QtWidgets import QFileIconProvider # 아이콘 제공자 -from PyQt6.QtCore import QFileInfo # 파일 정보 객체 -from PyQt6.QtGui import QIcon, QPixmap +from PySide6.QtWidgets import QFileIconProvider # 아이콘 제공자 +from PySide6.QtCore import QFileInfo # 파일 정보 객체 +from PySide6.QtGui import QIcon, QPixmap # QFileIconProvider는 애플리케이션 컨텍스트에서 생성되는 것이 좋을 수 있으나, # 여기서 간단히 사용하기 위해 전역 또는 함수 내 지역 변수로 생성합니다. @@ -61,7 +61,7 @@ def get_qicon_for_file( QIcon 객체 또는 None """ try: - from PyQt6.QtCore import Qt + from PySide6.QtCore import Qt # 고해상도 아이콘 추출 시도 (대시보드 로직 활용) from src.api.dashboard.icons import extract_icon_from_exe, get_icon_for_size, safe_icon_cache_key diff --git a/src/utils/windows.py b/src/utils/windows.py index 9419e1f5..a9aa22d3 100644 --- a/src/utils/windows.py +++ b/src/utils/windows.py @@ -22,6 +22,183 @@ _DWMWA_USE_IMMERSIVE_DARK_MODE = 20 _DWMWA_CAPTION_COLOR = 35 _DWMWA_TEXT_COLOR = 36 +_DWMWA_EXTENDED_FRAME_BOUNDS = 9 +_MONITOR_DEFAULTTONEAREST = 2 +_SWP_FRAME_MOVE_FLAGS = 0x0001 | 0x0004 | 0x0010 + + +class _MonitorInfo(ctypes.Structure): + _fields_ = [ + ("cbSize", ctypes.wintypes.DWORD), + ("rcMonitor", ctypes.wintypes.RECT), + ("rcWork", ctypes.wintypes.RECT), + ("dwFlags", ctypes.wintypes.DWORD), + ] + + +def _configure_window_geometry_api(user32) -> None: + user32.GetMonitorInfoW.argtypes = [ctypes.wintypes.HMONITOR, ctypes.POINTER(_MonitorInfo)] + user32.GetMonitorInfoW.restype = ctypes.wintypes.BOOL + user32.GetWindowRect.argtypes = [ctypes.wintypes.HWND, ctypes.POINTER(ctypes.wintypes.RECT)] + user32.GetWindowRect.restype = ctypes.wintypes.BOOL + user32.SetWindowPos.argtypes = [ + ctypes.wintypes.HWND, + ctypes.wintypes.HWND, + ctypes.c_int, + ctypes.c_int, + ctypes.c_int, + ctypes.c_int, + ctypes.wintypes.UINT, + ] + user32.SetWindowPos.restype = ctypes.wintypes.BOOL + + +def _get_monitor_work_area(user32, monitor): + monitor_info = _MonitorInfo() + monitor_info.cbSize = ctypes.sizeof(_MonitorInfo) + if not user32.GetMonitorInfoW(monitor, ctypes.byref(monitor_info)): + return None + work = monitor_info.rcWork + return work.left, work.top, work.right, work.bottom + + +def _get_window_frame_rects(user32, hwnd): + """Win32 외곽 RECT와 실제로 보이는 DWM 프레임 RECT를 함께 반환합니다.""" + native_hwnd = ctypes.wintypes.HWND(hwnd) + outer = ctypes.wintypes.RECT() + if not user32.GetWindowRect(native_hwnd, ctypes.byref(outer)): + return None + + visible = ctypes.wintypes.RECT(outer.left, outer.top, outer.right, outer.bottom) + try: + dwmapi = ctypes.WinDLL("dwmapi", use_last_error=True) + dwmapi.DwmGetWindowAttribute.argtypes = [ + ctypes.wintypes.HWND, + ctypes.wintypes.DWORD, + ctypes.c_void_p, + ctypes.wintypes.DWORD, + ] + dwmapi.DwmGetWindowAttribute.restype = ctypes.c_long + candidate = ctypes.wintypes.RECT() + result = dwmapi.DwmGetWindowAttribute( + native_hwnd, + _DWMWA_EXTENDED_FRAME_BOUNDS, + ctypes.byref(candidate), + ctypes.sizeof(candidate), + ) + if result == 0 and candidate.right > candidate.left and candidate.bottom > candidate.top: + visible = candidate + except (AttributeError, OSError): + pass + return outer, visible + + +def snap_rect_to_work_area( + rect: tuple[int, int, int, int], + work_area: tuple[int, int, int, int], + threshold: int, +) -> tuple[int, int, int, int]: + """창 크기를 유지한 채 가까운 작업 영역 경계에 사각형을 붙입니다.""" + left, top, right, bottom = rect + work_left, work_top, work_right, work_bottom = work_area + width = right - left + height = bottom - top + distance = max(0, int(threshold)) + + if abs(left - work_left) <= distance: + left, right = work_left, work_left + width + elif abs(right - work_right) <= distance: + left, right = work_right - width, work_right + + if abs(top - work_top) <= distance: + top, bottom = work_top, work_top + height + elif abs(bottom - work_bottom) <= distance: + top, bottom = work_bottom - height, work_bottom + + return left, top, right, bottom + + +def snap_windows_window_to_work_area(hwnd: int, *, threshold_logical: int = 15) -> bool: + """현재 창의 보이는 DWM 프레임을 가까운 모니터 작업 영역 경계에 붙입니다.""" + if not is_windows() or not hwnd: + return False + + try: + user32 = ctypes.WinDLL("user32", use_last_error=True) + _configure_window_geometry_api(user32) + user32.MonitorFromWindow.argtypes = [ctypes.wintypes.HWND, ctypes.wintypes.DWORD] + user32.MonitorFromWindow.restype = ctypes.wintypes.HMONITOR + native_hwnd = ctypes.wintypes.HWND(hwnd) + monitor = user32.MonitorFromWindow(native_hwnd, _MONITOR_DEFAULTTONEAREST) + if not monitor: + return False + work_area = _get_monitor_work_area(user32, monitor) + frame_rects = _get_window_frame_rects(user32, hwnd) + if work_area is None or frame_rects is None: + return False + outer, visible = frame_rects + + dpi = 96 + get_dpi_for_window = getattr(user32, "GetDpiForWindow", None) + if get_dpi_for_window is not None: + get_dpi_for_window.argtypes = [ctypes.wintypes.HWND] + get_dpi_for_window.restype = ctypes.wintypes.UINT + reported_dpi = int(get_dpi_for_window(ctypes.wintypes.HWND(hwnd))) + if reported_dpi > 0: + dpi = reported_dpi + threshold = max(1, round(threshold_logical * dpi / 96)) + original = (visible.left, visible.top, visible.right, visible.bottom) + snapped = snap_rect_to_work_area( + original, + work_area, + threshold, + ) + if snapped == original: + return False + left = outer.left + snapped[0] - visible.left + top = outer.top + snapped[1] - visible.top + return bool(user32.SetWindowPos(native_hwnd, None, left, top, 0, 0, _SWP_FRAME_MOVE_FLAGS)) + except Exception as exc: + logger.debug("Windows 창 가시 프레임 자석 적용 실패: %s", exc) + return False + + +def position_windows_window_bottom_right(hwnd: int, cursor_x: int, cursor_y: int) -> bool: + """커서 모니터의 작업 영역 우하단에 보이는 DWM 프레임을 맞춥니다.""" + if not is_windows() or not hwnd: + return False + + try: + user32 = ctypes.WinDLL("user32", use_last_error=True) + _configure_window_geometry_api(user32) + user32.MonitorFromPoint.argtypes = [ctypes.wintypes.POINT, ctypes.wintypes.DWORD] + user32.MonitorFromPoint.restype = ctypes.wintypes.HMONITOR + + monitor = user32.MonitorFromPoint( + ctypes.wintypes.POINT(int(cursor_x), int(cursor_y)), + _MONITOR_DEFAULTTONEAREST, + ) + if not monitor: + return False + work_area = _get_monitor_work_area(user32, monitor) + frame_rects = _get_window_frame_rects(user32, hwnd) + if work_area is None or frame_rects is None: + return False + outer, visible = frame_rects + left = outer.left + work_area[2] - visible.right + top = outer.top + work_area[3] - visible.bottom + return bool(user32.SetWindowPos( + ctypes.wintypes.HWND(hwnd), + None, + left, + top, + 0, + 0, + _SWP_FRAME_MOVE_FLAGS, + )) + except Exception as exc: + logger.debug("Windows 창 우하단 배치 실패: %s", exc) + return False def is_windows() -> bool: return os.name == 'nt' diff --git a/tests/test_api_runtime_stability.py b/tests/test_api_runtime_stability.py index 40d72e6e..b9d11b3f 100644 --- a/tests/test_api_runtime_stability.py +++ b/tests/test_api_runtime_stability.py @@ -1,3 +1,4 @@ +import ast from pathlib import Path import requests @@ -58,6 +59,7 @@ def test_gui_health_endpoint_contract_is_present(): assert '@app.middleware("http")' in source assert "slow_api_request method=%s path=%s status=%s duration_ms=%.1f pid=%s thread=%s" in source assert '@app.get("/api/gui/ping")' in source + assert "app.add_exception_handler(DatabaseAccessUnavailable, database_access_exception_handler)" in source assert '"server_time": time.time()' in source assert '@app.get("/api/gui/health")' in source assert '"db_ready": db_ready' in source @@ -67,6 +69,8 @@ def test_gui_health_endpoint_contract_is_present(): assert '"dashboard_static_ready": dashboard_static["ready"]' in source assert '"static_probe_ms": round(static_probe_ms, 2)' in source assert '"total_ms": round((time.perf_counter() - started_at) * 1000, 2)' in source + assert '"release_id"' not in source[source.index('@app.get("/api/gui/ping")') : source.index("import uvicorn")] + assert '"git_sha"' not in source[source.index('@app.get("/api/gui/ping")') : source.index("import uvicorn")] def test_sqlite_engine_uses_short_lived_connections_for_host_stability(): @@ -78,82 +82,99 @@ def test_sqlite_engine_uses_short_lived_connections_for_host_stability(): assert "pooled SQLite connection" in source -def test_api_server_lifecycle_recovers_stale_orphan_processes(): +def test_api_server_lifecycle_recovers_only_identified_api_processes(): source = Path("homework_helper.pyw").read_text(encoding="utf-8") + start_source = source[source.index("def start_api_server()") : source.index("def run_server_main(")] + stop_source = source[source.index("def stop_api_server(") : source.index("def ensure_process_table_schema(")] - assert "def _terminate_existing_api_server" in source + assert "API_GRACEFUL_SHUTDOWN_TIMEOUT_SECONDS = 5.0" in source + assert "def _process_looks_like_homework_api_server" in source assert "def _find_api_listener_pids" in source - assert "def _is_existing_api_server_reusable" in source - assert "_is_existing_server_healthy() and _is_existing_api_server_reusable()" in source - assert "orphan 서버를 재사용하지 않고 재시작합니다." in source - assert "api_listener_pids = _find_api_listener_pids(resolve_api_port())" in source - assert "proc.kill()" in source - assert 'metadata_file = os.path.join(data_dir, "db_server_meta.json")' in source - assert '"parent_create_time": _process_create_time(parent_process_id)' in source - assert "def start_parent_watchdog" in source - assert "parent watchdog 시작" in source - assert "os._exit(0)" in source - assert "shutdown_api_resources(\"uvicorn_returned\")" in source - - -def test_server_only_entrypoint_supports_ssh_testbench_before_gui_side_effects(): - source = Path("homework_helper.pyw").read_text(encoding="utf-8") - main_tail = source[source.index('if __name__ == "__main__":') :] - - assert "def _wants_server_only_mode" in source - assert '{"--server", "--testbench-server", "--run-server"}' in source - assert "run_server_main()" in main_tail - assert main_tail.index("multiprocessing.freeze_support()") < main_tail.index("if _wants_server_only_mode():") - assert main_tail.index("if _wants_server_only_mode():") < main_tail.index("cleanup_old_mei_folders()") - assert main_tail.index("if _wants_server_only_mode():") < main_tail.index("check_admin_requirement()") - assert "get_server_mutex_name()" in source - assert '"testbench_mode": is_testbench_mode()' in source - assert '"testbench_session_id": get_testbench_session_id()' in source - - -def test_gui_parent_passes_remote_server_mode_bind_host_to_api_child(): - source = Path("homework_helper.pyw").read_text(encoding="utf-8") - - assert "def _desired_child_api_bind_host()" in source - assert "def _is_loopback_api_host(" in source - assert '"remote_server_mode_enabled"' in source - assert "remote_server_mode_enabled and (not explicit_host or _is_loopback_api_host(explicit_host))" in source - assert "loopback HH_API_HOST=" in source - assert 'return "0.0.0.0", "remote_server_mode_enabled"' in source - assert 'return explicit_host, "HH_API_HOST"' in source - assert 'os.environ["HH_API_HOST"] = child_bind_host' in source - assert "api_server_process.start()" in source - assert source.index('os.environ["HH_API_HOST"] = child_bind_host') < source.index( - "api_server_process.start()" - ) - assert "if child_bind_host:" in source - assert 'os.environ.pop("HH_API_HOST", None)' in source - - -def test_api_server_logs_and_records_effective_bind_host_for_diagnostics(): - source = Path("homework_helper.pyw").read_text(encoding="utf-8") - - assert "API 바인딩 설정 확인: HH_API_HOST=" in source - assert "API 바인딩 설정 확인: remote_server_mode_enabled=" in source - assert 'metadata["api_host"] = api_host' in source - assert 'metadata["remote_exposed"] = api_host not in {"127.0.0.1", "localhost", "::1"}' in source - - -def test_remote_server_mode_blocks_legacy_routes_for_non_loopback_clients(): - source = Path("homework_helper.pyw").read_text(encoding="utf-8") - - assert "async def remote_exposure_boundary_middleware(request, call_next):" in source - assert "remote_exposed and not _request_from_loopback(request)" in source - assert 'path != "/remote"' in source - assert 'not path.startswith("/remote/")' in source - assert "Remote server mode exposes only the authenticated /remote API" in source - - -def test_remote_server_mode_keeps_dashboard_icon_routes_public_for_remote_clients(): + assert "def _terminate_existing_api_server" in source + assert "_terminate_existing_api_server(timeout=API_GRACEFUL_SHUTDOWN_TIMEOUT_SECONDS)" in start_source + assert "HomeworkHelper API 서버로 확인되지 않아 종료하지 않습니다." in source + assert "API 포트를 점유한 프로세스를 HomeworkHelper API 서버로 확인할 수 없어" in start_source + assert "api_server_shutdown_event = multiprocessing.Event()" in start_source + assert "daemon=False" in start_source + assert "process.terminate()" in stop_source + assert "process.kill()" in stop_source + assert "_server_pid_file_path()" in stop_source + assert "_server_metadata_file_path()" in stop_source + + +def _load_stop_api_server_contract(): source = Path("homework_helper.pyw").read_text(encoding="utf-8") - - assert "def _is_remote_public_icon_request(path: str, method: str) -> bool:" in source - assert 'method in {"GET", "HEAD"}' in source - assert 'path.startswith("/api/dashboard/icons/")' in source - assert 'path.startswith("/api/dashboard/resource-icons/")' in source - assert "not _is_remote_public_icon_request(path, request.method.upper())" in source + tree = ast.parse(source) + selected_nodes = [] + for node in tree.body: + if isinstance(node, ast.Assign): + target_names = {target.id for target in node.targets if isinstance(target, ast.Name)} + if target_names & { + "API_GRACEFUL_SHUTDOWN_TIMEOUT_SECONDS", + "api_server_process", + "api_server_shutdown_event", + }: + selected_nodes.append(node) + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == "stop_api_server": + selected_nodes.append(node) + module = ast.fix_missing_locations(ast.Module(body=selected_nodes, type_ignores=[])) + namespace = {} + exec(compile(module, "homework_helper.pyw", "exec"), namespace) + return namespace + + +def test_stop_api_server_sets_event_and_clears_owned_child(): + namespace = _load_stop_api_server_contract() + namespace["_server_pid_file_path"] = lambda: "server.pid" + namespace["_server_metadata_file_path"] = lambda: "server.json" + + class FakeOs: + @staticmethod + def remove(_path): + raise FileNotFoundError + + namespace["os"] = FakeOs() + + class FakeEvent: + def __init__(self): + self.set_count = 0 + + def set(self): + self.set_count += 1 + + class FakeProcess: + pid = 43210 + + def __init__(self): + self.alive = True + self.join_timeouts = [] + self.terminate_count = 0 + self.kill_count = 0 + + def is_alive(self): + return self.alive + + def join(self, timeout): + self.join_timeouts.append(timeout) + if self.terminate_count: + self.alive = False + + def terminate(self): + self.terminate_count += 1 + + def kill(self): + self.kill_count += 1 + self.alive = False + + event = FakeEvent() + process = FakeProcess() + namespace["api_server_shutdown_event"] = event + namespace["api_server_process"] = process + + assert namespace["stop_api_server"]() is True + assert event.set_count == 1 + assert process.terminate_count == 1 + assert process.kill_count == 0 + assert process.join_timeouts == [5.0, 5.0] + assert namespace["api_server_process"] is None + assert namespace["api_server_shutdown_event"] is None diff --git a/tests/test_beholder.py b/tests/test_beholder.py index 9594a491..ca7f83fe 100644 --- a/tests/test_beholder.py +++ b/tests/test_beholder.py @@ -83,6 +83,89 @@ def test_beholder_blocks_extreme_legacy_session_close_and_keeps_session_open(mon assert db.query(models.BeholderIncident).count() == 1 +def test_equivalent_pending_and_denied_incidents_are_reused(monkeypatch): + SessionLocal = _session_factory(monkeypatch) + db = SessionLocal() + operation = beholder.BeholderOperation( + kind="runtime_stop", + actor="process_monitor", + evidence={ + "changed_fields": ["end_timestamp"], + "context": {"session_id": 1, "process_id": "game-a"}, + "proposed_values": {"end_timestamp": 200.0}, + }, + ) + values = { + "severity": beholder.SEVERITY_CRITICAL, + "operation": operation, + "target_summary": "session_id=1, process_id=game-a", + "suspected_cause": "이미 종료된 기록입니다.", + "current_state_summary": "현재 상태=closed", + "proposed_change_summary": "동일 종료 요청", + "risk_score": 90, + "risk_factors": ["invalid_current_status:closed"], + "safe_recommendation": "차단을 유지하세요.", + } + + first = beholder.create_incident(db, **values) + pending_duplicate = beholder.create_incident(db, **values) + + assert pending_duplicate.id == first.id + assert db.query(models.BeholderIncident).count() == 1 + + beholder.mark_incident(db, first.id, beholder.STATUS_DENIED) + denied_duplicate = beholder.create_incident(db, **values) + + assert denied_duplicate.id == first.id + assert denied_duplicate.status == beholder.STATUS_DENIED + assert db.query(models.BeholderIncident).count() == 1 + + +def test_changed_incident_context_creates_a_new_incident(monkeypatch): + SessionLocal = _session_factory(monkeypatch) + db = SessionLocal() + operation = beholder.BeholderOperation(kind="runtime_stop", actor="process_monitor") + common = { + "severity": beholder.SEVERITY_CRITICAL, + "operation": operation, + "target_summary": "session_id=1, process_id=game-a", + "suspected_cause": "이미 종료된 기록입니다.", + "proposed_change_summary": "동일 종료 요청", + "risk_score": 90, + "risk_factors": ["invalid_current_status:closed"], + "safe_recommendation": "차단을 유지하세요.", + } + + first = beholder.create_incident(db, current_state_summary="현재 상태=closed", **common) + second = beholder.create_incident(db, current_state_summary="현재 상태=quarantined", **common) + + assert second.id != first.id + assert db.query(models.BeholderIncident).count() == 2 + + +def test_fallback_user_summary_does_not_expose_internal_identity(monkeypatch): + SessionLocal = _session_factory(monkeypatch) + db = SessionLocal() + incident = beholder.create_incident( + db, + severity=beholder.SEVERITY_WARNING, + operation=beholder.BeholderOperation(kind="runtime_stop", actor="process_monitor"), + target_summary="session_id=1, process_id=secret-uuid", + suspected_cause="internal cause", + current_state_summary="owner=internal", + proposed_change_summary="end_timestamp=123", + risk_score=50, + risk_factors=["internal_factor"], + safe_recommendation="차단을 유지하세요.", + ) + + summary = beholder.incident_to_dict(incident)["user_summary"] + + assert "session_id" not in summary + assert "secret-uuid" not in summary + assert "process_monitor" not in summary + + def test_beholder_allows_long_session_with_override_token(monkeypatch): SessionLocal = _session_factory(monkeypatch) db = SessionLocal() @@ -1331,81 +1414,6 @@ def _patch(*args, **kwargs): } -def test_hoyolab_reconcile_persists_only_final_stamina_fields(): - from src.core.hoyolab_reconcile import _StaminaPersistTask - from src.data.data_models import ManagedProcess - - process = ManagedProcess( - id="game-a", - name="Game A", - monitoring_path="/games/a.exe", - launch_path="/games/a.exe", - last_played_timestamp=123.0, - stamina_tracking_enabled=True, - hoyolab_game_id="genshin", - stamina_current=100, - stamina_max=240, - stamina_updated_at=1000.0, - ) - - class FakeDataManager: - runtime_updates = [] - stamina_updates = [] - session_updates = [] - - def get_process_by_id(self, process_id): - assert process_id == "game-a" - return process - - def update_process_runtime_state(self, updated_process): - self.runtime_updates.append(updated_process) - return True - - def update_process_stamina(self, process_id, stamina_current, stamina_max, stamina_updated_at): - self.stamina_updates.append((process_id, stamina_current, stamina_max, stamina_updated_at)) - return True - - def update_session_stamina(self, session_id, stamina_at_end): - self.session_updates.append((session_id, stamina_at_end)) - return True - - class Finished: - def __init__(self): - self.payloads = [] - - def emit(self, *args): - self.payloads.append(args) - - class Signals: - def __init__(self): - self.finished = Finished() - - data_manager = FakeDataManager() - signals = Signals() - task = _StaminaPersistTask( - process_id="game-a", - process_name="Game A", - session_id=7, - lifecycle_token=1, - request_seq=1, - fetched_current=90, - fetched_max=240, - fetched_at=1778497000.0, - exit_timestamp=1778497000.0, - allow_session_correction=True, - applied_session_stamina=100, - data_manager=data_manager, - should_abort=lambda: False, - signals=signals, - ) - - task.run() - - assert data_manager.stamina_updates == [("game-a", 90, 240, 1778497000.0)] - assert data_manager.runtime_updates == [] - assert data_manager.session_updates == [(7, 90)] - assert signals.finished.payloads[0][3]["persist_succeeded"] is True - def test_negative_session_stamina_is_blocked_without_mutating_session(monkeypatch, tmp_path): SessionLocal = _session_factory(monkeypatch) @@ -1504,6 +1512,85 @@ def test_process_editor_cannot_mutate_runtime_fields(monkeypatch, tmp_path): assert unchanged.last_played_timestamp == dt.datetime(2026, 5, 8, 12, 0).timestamp() +def test_process_editor_persists_trimmed_direct_launch_args(monkeypatch, tmp_path): + SessionLocal = _session_factory(monkeypatch) + import src.data.crud as crud_mod + monkeypatch.setattr(crud_mod, "base_dir", str(tmp_path)) + db = SessionLocal() + + process = crud.create_process(db, schemas.ProcessCreateSchema( + id="zzz", + name="Zenless Zone Zero", + monitoring_path="C:/Games/ZZZ.exe", + launch_path="C:/Games/ZZZ.url", + preferred_launch_type="direct", + launch_args_enabled=True, + launch_args=" -use-d3d12 ", + )) + + assert process.launch_args_enabled is True + assert process.launch_args == "-use-d3d12" + + updated = crud.update_process(db, process.id, schemas.ProcessCreateSchema( + name="Zenless Zone Zero", + monitoring_path="C:/Games/ZZZ.exe", + launch_path="C:/Games/ZZZ.url", + preferred_launch_type="direct", + launch_args_enabled=False, + launch_args=" ", + )) + + assert updated.launch_args_enabled is False + assert updated.launch_args == "" + + +def test_managed_process_from_dict_backfills_launch_args_defaults(): + from src.data.data_models import ManagedProcess + + process = ManagedProcess.from_dict({ + "id": "legacy", + "name": "Legacy Game", + "monitoring_path": "C:/Games/Legacy.exe", + "launch_path": "C:/Games/Legacy.url", + }) + + assert process.preferred_launch_type == "shortcut" + assert process.launch_args_enabled is False + assert process.launch_args == "" + + +def test_process_editor_blocks_unsafe_direct_launch_args(monkeypatch, tmp_path): + SessionLocal = _session_factory(monkeypatch) + import src.data.crud as crud_mod + monkeypatch.setattr(crud_mod, "base_dir", str(tmp_path)) + db = SessionLocal() + process = crud.create_process(db, schemas.ProcessCreateSchema( + id="args-guard", + name="Args Guard", + monitoring_path="C:/Games/ArgsGuard.exe", + launch_path="C:/Games/ArgsGuard.url", + )) + + invalid_values = [ + "-use-d3d12\n--bad", + "-use-d3d12\r--bad", + "-use-d3d12\x00--bad", + "x" * (beholder.MAX_LAUNCH_ARGS_LENGTH + 1), + ] + + for value in invalid_values: + with pytest.raises(beholder.BeholderBlocked) as blocked: + crud.update_process(db, process.id, schemas.ProcessCreateSchema( + name="Args Guard", + monitoring_path="C:/Games/ArgsGuard.exe", + launch_path="C:/Games/ArgsGuard.url", + launch_args_enabled=True, + launch_args=value, + )) + + assert "invalid_process_value" in blocked.value.incident.risk_factors + + def test_web_shortcut_editor_preserves_and_cannot_mutate_runtime_reset_timestamp(monkeypatch, tmp_path): SessionLocal = _session_factory(monkeypatch) import src.data.crud as crud_mod diff --git a/tests/test_build_release.py b/tests/test_build_release.py index 844d9540..a2d36c3c 100644 --- a/tests/test_build_release.py +++ b/tests/test_build_release.py @@ -1,5 +1,6 @@ import json import sys +from types import SimpleNamespace from pathlib import Path import pytest @@ -26,6 +27,133 @@ def test_select_build_target_maps_host_os_to_release_target(): build.select_build_target("Linux") +def test_console_output_replaces_characters_unsupported_by_cp949(tmp_path): + output_path = tmp_path / "console.txt" + with output_path.open("w", encoding="cp949", errors="strict") as stream: + build.configure_console_output(stream, stream) + print("✓ 빌드 완료", file=stream) + + assert "? 빌드 완료" in output_path.read_text(encoding="cp949") + + +def test_windows_bootstrap_creates_venv_and_delegates_before_installing(tmp_path): + calls = [] + managed_python = tmp_path / ".venv" / "Scripts" / "python.exe" + + def runner(command, **kwargs): + calls.append((command, kwargs)) + if command[-3:-1] == ["-m", "venv"]: + managed_python.parent.mkdir(parents=True) + managed_python.touch() + return SimpleNamespace(returncode=0) + + exit_code = build.bootstrap_windows_build_runtime( + ["--no-gui"], + system_name="Windows", + project_root=tmp_path, + python_executable="C:/Python314/python.exe", + python_version=(3, 14), + runner=runner, + launcher_finder=lambda _name: None, + ) + + assert exit_code == 0 + assert calls[0][0][-2:] == ["venv", str(tmp_path / ".venv")] + assert calls[1][0] == [str(managed_python), str(tmp_path / "build.py"), "--no-gui"] + + +def test_windows_bootstrap_reuses_managed_venv_without_activation(tmp_path): + managed_python = tmp_path / ".venv" / "Scripts" / "python.exe" + managed_python.parent.mkdir(parents=True) + managed_python.touch() + calls = [] + + def runner(command, **kwargs): + calls.append(command) + return SimpleNamespace(returncode=0) + + exit_code = build.bootstrap_windows_build_runtime( + [], + system_name="Windows", + project_root=tmp_path, + python_executable=str(managed_python), + python_version=(3, 14), + runner=runner, + ) + + assert exit_code is None + assert len(calls) == 1 + assert calls[0][1:4] == ["-m", "pip", "install"] + + +def test_main_parses_help_before_windows_runtime_bootstrap(monkeypatch): + monkeypatch.setattr( + build, + "bootstrap_windows_build_runtime", + lambda _argv: (_ for _ in ()).throw(AssertionError("bootstrap must not run")), + ) + + with pytest.raises(SystemExit) as raised: + build.main(["--help"]) + + assert raised.value.code == 0 + + +def test_host_runtime_has_no_manifest_identity_contract(): + source = Path("homework_helper.pyw").read_text(encoding="utf-8") + coordination = Path("src/data/database_coordination.py").read_text(encoding="utf-8") + spec = Path("homework_helper.spec").read_text(encoding="utf-8") + + assert not Path("src/core/runtime_identity.py").exists() + assert "runtime-manifest.json" not in source + coordination + spec + assert '"release_id": runtime_identity_payload' not in source + assert '"git_sha": runtime_identity_payload' not in source + + +def test_windows_bootstrap_requires_python314_launcher(tmp_path): + with pytest.raises(build.BuildConfigError, match="Python 3.14"): + build.bootstrap_windows_build_runtime( + [], + system_name="Windows", + project_root=tmp_path, + python_executable="C:/Python313/python.exe", + python_version=(3, 13), + launcher_finder=lambda _name: None, + ) + + +def test_windows_runtime_accepts_only_python314_with_pyside6(): + def installed(names): + return { + name: ("missing" if name == "PyQt6" else "6.11.1" if name == "PySide6" else "1.0") + for name in names + } + + versions = build.validate_windows_build_runtime( + python_version=(3, 14), + distribution_versions=installed, + ) + + assert versions["PySide6"] == "6.11.1" + + +def test_windows_runtime_rejects_old_python_or_mixed_qt_bindings(): + with pytest.raises(build.BuildConfigError, match="Python 3.14"): + build.validate_windows_build_runtime( + python_version=(3, 13), + distribution_versions=lambda names: {name: "1.0" for name in names}, + ) + + def mixed(names): + return {name: "6.11.1" for name in names} + + with pytest.raises(build.BuildConfigError, match="PySide6만"): + build.validate_windows_build_runtime( + python_version=(3, 14), + distribution_versions=mixed, + ) + + def test_make_version_info_uses_git_hash_and_dirty_suffix(): info = build.make_version_info("windows-host", _config(), git_hash="21080f2", dirty=False) @@ -215,7 +343,8 @@ def test_macos_pkg_preinstall_script_stops_running_client(tmp_path): preinstall = scripts_dir / "preinstall" assert preinstall.exists() - assert preinstall.stat().st_mode & 0o111 + if sys.platform != "win32": + assert preinstall.stat().st_mode & 0o111 script = preinstall.read_text(encoding="utf-8") assert "HomeworkHelperRemote" in script @@ -325,10 +454,11 @@ def fake_run_output(command, *, cwd): "--timestamp=none", "--sign", "Local Identity", - "dist/macos/HomeworkHelperRemote.app", + str(Path("dist/macos/HomeworkHelperRemote.app")), ] - assert ["codesign", "--verify", "--deep", "--strict", "--verbose=2", "dist/macos/HomeworkHelperRemote.app"] in calls - assert ["codesign", "--display", "--requirements", "-", "--verbose=4", "dist/macos/HomeworkHelperRemote.app"] in calls + app_path = str(Path("dist/macos/HomeworkHelperRemote.app")) + assert ["codesign", "--verify", "--deep", "--strict", "--verbose=2", app_path] in calls + assert ["codesign", "--display", "--requirements", "-", "--verbose=4", app_path] in calls def test_macos_packager_rejects_adhoc_codesign_identity(monkeypatch): diff --git a/tests/test_clipboard.py b/tests/test_clipboard.py index 508d8c1c..50f1915b 100644 --- a/tests/test_clipboard.py +++ b/tests/test_clipboard.py @@ -1,6 +1,6 @@ from pathlib import Path -from PyQt6.QtGui import QImage, QColor +from PySide6.QtGui import QImage, QColor from src.utils.clipboard import build_file_clipboard_mime_data, _build_hdrop_payload diff --git a/tests/test_daily_checkin_runtime.py b/tests/test_daily_checkin_runtime.py new file mode 100644 index 00000000..6bf87a3f --- /dev/null +++ b/tests/test_daily_checkin_runtime.py @@ -0,0 +1,562 @@ +from __future__ import annotations + +import ast +import asyncio +import logging +import threading +import time +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from src.core.daily_checkin_singleflight import ( + DailyCheckInAlreadyInFlight, + DailyCheckInSingleFlight, + bounded_provider_timeout_seconds, + monotonic_deadline, + remaining_deadline_seconds, +) +from src.core import daily_checkin +from src.services.hoyolab import HoYoLabService +from src.services.nikke import NikkeDailyCheckInStatus, NikkeService + + +class _ConfiguredHoYoLab: + def is_configured(self): + return True + + def load_credentials(self): + return {"ltuid": 1, "ltoken_v2": "token"} + + +class _ConfiguredNikke: + def is_configured(self): + return True + + def load_session(self): + return {"cookies": {"session_id": "test"}} + + +def test_hoyolab_sync_bridge_cancels_timed_out_coroutine_without_late_result(): + cancelled = threading.Event() + completed = threading.Event() + + async def slow_provider_call(): + try: + await asyncio.sleep(0.2) + completed.set() + finally: + cancelled.set() + + service = HoYoLabService( + config=_ConfiguredHoYoLab(), + async_timeout=0.01, + ) + + with pytest.raises(TimeoutError, match="0.01초"): + service._run_async(slow_provider_call()) + + assert cancelled.wait(0.1) + time.sleep(0.03) + assert not completed.is_set() + + +def test_hoyolab_sync_bridge_rejects_running_loop_without_spawning_thread(monkeypatch): + created_threads: list[str] = [] + original_start = threading.Thread.start + + def tracking_start(thread): + created_threads.append(thread.name) + return original_start(thread) + + async def invoke_from_running_loop(): + service = HoYoLabService(config=_ConfiguredHoYoLab()) + coroutine = asyncio.sleep(0) + with pytest.raises(RuntimeError, match="비동기 provider API를 직접 await"): + service._run_async(coroutine) + assert getattr(coroutine, "cr_frame", None) is None + + monkeypatch.setattr(threading.Thread, "start", tracking_start) + asyncio.run(invoke_from_running_loop()) + + assert created_threads == [] + + +def test_hoyolab_timeout_is_preserved_as_existing_network_error_status(): + class SlowClient: + async def claim_daily_reward(self, *, game, lang): + await asyncio.sleep(0.2) + raise AssertionError("cancelled provider call must not finish") + + service = HoYoLabService( + config=_ConfiguredHoYoLab(), + async_timeout=0.01, + ) + service._client = SlowClient() + + results = service.claim_daily_rewards(["honkai_starrail"]) + + assert [result.status for result in results] == ["network_error"] + assert "0.01초" in results[0].message + + +def test_hoyolab_timeout_cannot_exceed_thirty_seconds(): + service = HoYoLabService( + config=_ConfiguredHoYoLab(), + async_timeout=300, + ) + + assert service._async_timeout == 30.0 + + +def test_hoyolab_request_lock_wait_uses_same_operation_deadline(): + service = HoYoLabService( + config=_ConfiguredHoYoLab(), + async_timeout=30, + ) + service._client = object() + service._request_lock.acquire() + started_at = time.monotonic() + try: + results = service.claim_daily_rewards( + ["honkai_starrail"], + timeout_seconds=0.01, + ) + finally: + service._request_lock.release() + + assert time.monotonic() - started_at < 0.2 + assert [result.status for result in results] == ["network_error"] + assert "request lock deadline" in results[0].message + + +def test_hoyolab_client_lock_wait_uses_same_operation_deadline(): + service = HoYoLabService( + config=_ConfiguredHoYoLab(), + async_timeout=30, + ) + service._client = object() + locked = threading.Event() + release = threading.Event() + + def hold_client_lock(): + with service._client_lock: + locked.set() + release.wait(1) + + holder = threading.Thread(target=hold_client_lock) + holder.start() + assert locked.wait(1) + try: + results = service.get_daily_reward_status( + ["honkai_starrail"], + timeout_seconds=0.01, + ) + finally: + release.set() + holder.join(timeout=1) + + assert not holder.is_alive() + assert [result.status for result in results] == ["network_error"] + assert "client lock deadline" in results[0].message + + +def test_hoyolab_lock_is_released_when_protected_work_raises(): + service = HoYoLabService(config=_ConfiguredHoYoLab()) + + with pytest.raises(RuntimeError, match="provider failed"): + with service._lock_until( + service._request_lock, + service._operation_deadline(1.0), + "request", + ): + raise RuntimeError("provider failed") + + assert service._request_lock.acquire(blocking=False) + service._request_lock.release() + + +def test_hoyolab_close_clears_client_only_after_bounded_close_succeeds(): + class Client: + def __init__(self): + self.closed = False + + async def close(self): + self.closed = True + + client = Client() + service = HoYoLabService(config=_ConfiguredHoYoLab(), async_timeout=0.1) + service._client = client + + assert service.close() is True + assert client.closed is True + assert service._client is None + assert service._closed is True + + +def test_hoyolab_close_timeout_keeps_service_reusable(): + client = object() + service = HoYoLabService(config=_ConfiguredHoYoLab(), async_timeout=0.01) + service._client = client + service._request_lock.acquire() + try: + assert service.close() is False + finally: + service._request_lock.release() + + assert service._client is client + assert service._closed is False + + +def test_nikke_each_http_request_timeout_is_capped_at_ten_seconds(monkeypatch): + observed_timeouts: list[float] = [] + + class FakeResponse: + def raise_for_status(self): + return None + + def json(self): + return {"code": 0} + + class FakeSession: + def __init__(self): + self.cookies = {} + + def get(self, url, **kwargs): + observed_timeouts.append(kwargs["timeout"]) + return FakeResponse() + + def close(self): + return None + + monkeypatch.setattr("src.services.nikke.requests.Session", FakeSession) + service = NikkeService(config=_ConfiguredNikke(), timeout=60) + + service._get("test") + + assert observed_timeouts == [10.0] + + +def test_nikke_status_fallback_and_post_share_one_absolute_deadline(monkeypatch): + observed_timeouts: list[float] = [] + closed_sessions: list[bool] = [] + post_calls: list[str] = [] + monotonic_values = iter((0.0, 8.0, 14.0, 16.0)) + + class FakeResponse: + def raise_for_status(self): + return None + + def json(self): + return {"code": 0} + + class FakeSession: + def __init__(self): + self.cookies = {} + + def get(self, url, **kwargs): + observed_timeouts.append(kwargs["timeout"]) + return FakeResponse() + + def post(self, url, **kwargs): + post_calls.append(url) + return FakeResponse() + + def close(self): + closed_sessions.append(True) + + parse_calls = 0 + + def parse_status(_body, now, *, get_top): + nonlocal parse_calls + parse_calls += 1 + if parse_calls == 1: + return NikkeDailyCheckInStatus("route_error", now) + return NikkeDailyCheckInStatus("ready", now, task_id="15") + + monkeypatch.setattr("src.services.nikke.requests.Session", FakeSession) + monkeypatch.setattr("src.services.nikke.time.monotonic", lambda: next(monotonic_values)) + service = NikkeService(config=_ConfiguredNikke(), timeout=60) + monkeypatch.setattr(service, "_parse_daily_checkin_status", parse_status) + + result = service.claim_daily_checkin(timeout_seconds=15.0) + + assert result.status == "network_error" + assert "deadline" in result.message + assert observed_timeouts == [7.0, 1.0] + assert post_calls == [] + assert closed_sessions == [True, True] + assert result.raw_debug["post_called"] is False + + +def test_nikke_operation_timeout_is_capped_at_thirty_seconds(monkeypatch): + monkeypatch.setattr("src.services.nikke.time.monotonic", lambda: 100.0) + service = NikkeService(config=_ConfiguredNikke()) + + assert service._daily_checkin_deadline(300.0) == 130.0 + assert service._daily_checkin_deadline(5.0) == 105.0 + + +def test_daily_checkin_singleflight_rejects_duplicate_and_releases_cross_thread(): + controller = DailyCheckInSingleFlight() + lease = controller.acquire_or_raise("run", "hoyolab", "p1", "g1", 100.0) + + with pytest.raises(DailyCheckInAlreadyInFlight) as duplicate: + controller.acquire_or_raise("run", "hoyolab", "p1", "g1", 100.0) + assert duplicate.value.code == "daily_checkin_in_flight" + + releaser = threading.Thread(target=lease.release) + releaser.start() + releaser.join(timeout=1) + assert not releaser.is_alive() + + replacement = controller.try_acquire("run", "hoyolab", "p1", "g1", 100.0) + assert replacement is not None + replacement.release() + replacement.release() + assert controller.snapshot() == () + + +def test_daily_checkin_singleflight_finally_release_does_not_block_other_keys(): + controller = DailyCheckInSingleFlight() + + with pytest.raises(RuntimeError, match="provider failed"): + with controller.acquire_or_raise("run", "hoyolab", "p1", "g1", 100.0): + other = controller.try_acquire("run", "hoyolab", "p2", "g2", 100.0) + assert other is not None + other.release() + raise RuntimeError("provider failed") + + assert controller.snapshot() == () + replacement = controller.try_acquire("run", "hoyolab", "p1", "g1", 100.0) + assert replacement is not None + replacement.release() + + +def test_run_due_deadline_helpers_use_monotonic_remaining_budget(): + deadline = monotonic_deadline(60.0, now=lambda: 100.0) + + assert deadline == 160.0 + assert remaining_deadline_seconds(deadline, now=lambda: 125.5) == 34.5 + assert remaining_deadline_seconds(deadline, now=lambda: 170.0) == 0.0 + assert bounded_provider_timeout_seconds(deadline, 30.0, now=lambda: 125.5) == 30.0 + assert bounded_provider_timeout_seconds(deadline, 30.0, now=lambda: 150.0) == 10.0 + assert bounded_provider_timeout_seconds(deadline, 30.0, now=lambda: 170.0) == 0.0 + + +@dataclass(frozen=True) +class _RunDueTargetSnapshot: + process_id: str + process_name: str + user_preset_id: str | None + descriptor: object + + +def _load_daily_checkin_endpoint(name, namespace): + source = Path("homework_helper.pyw").read_text(encoding="utf-8") + tree = ast.parse(source) + function = next( + node + for node in ast.walk(tree) + if isinstance(node, ast.FunctionDef) and node.name == name + ) + function.decorator_list = [] + module = ast.fix_missing_locations(ast.Module(body=[function], type_ignores=[])) + exec(compile(module, "homework_helper.pyw", "exec"), namespace) + return namespace[name] + + +@pytest.mark.parametrize( + ("endpoint_name", "provider_method"), + [ + ("run_daily_checkin", "execute_daily_checkin"), + ("probe_daily_checkin_status", "probe_daily_checkin_status"), + ], +) +def test_single_daily_checkin_deadline_covers_snapshot_provider_and_persistence( + monkeypatch, + endpoint_name, + provider_method, +): + clock = {"now": 100.0} + deadline = 130.0 + descriptor = daily_checkin.DAILY_CHECKIN_DESCRIPTORS[daily_checkin.GAME_HONKAI_STARRAIL] + target = _RunDueTargetSnapshot("process-1", "Game", None, descriptor) + database_phases = [] + provider_timeouts = [] + recorded_deadlines = [] + controller = DailyCheckInSingleFlight() + + @contextmanager + def database_session(route_name, **kwargs): + database_phases.append((route_name, kwargs)) + if route_name.endswith("snapshot"): + clock["now"] = 112.0 + yield object() + + def provider_call(_descriptor, *, timeout_seconds): + provider_timeouts.append(timeout_seconds) + clock["now"] = 129.0 + return daily_checkin.DailyCheckInAttemptResult( + provider=descriptor.provider, + game_id=descriptor.game_id, + game_name=descriptor.game_name, + status="success", + attempted_at=time.time(), + ) + + monkeypatch.setattr(daily_checkin, provider_method, provider_call) + + def record_result(_db, _target, _result, *args, **kwargs): + recorded_deadlines.append(kwargs["deadline"]) + return {"status": "success"} + + namespace = { + "SINGLE_DAILY_CHECKIN_TOTAL_DEADLINE_SECONDS": 30.0, + "_database_session": database_session, + "_daily_checkin_target_snapshot": lambda *_args: target, + "_record_daily_checkin_result": record_result, + "_record_daily_checkin_probe": record_result, + "bounded_provider_timeout_seconds": lambda absolute, maximum: min( + absolute - clock["now"], maximum + ), + "daily_checkin_singleflight": controller, + "monotonic_deadline": lambda timeout: clock["now"] + timeout, + "schemas": SimpleNamespace( + DailyCheckInRunRequest=object, + DailyCheckInStatusProbeRequest=object, + ), + } + endpoint = _load_daily_checkin_endpoint(endpoint_name, namespace) + request = SimpleNamespace(process_id="process-1", game_id=descriptor.game_id, trigger="manual") + + assert endpoint(request) == {"status": "success"} + assert provider_timeouts == [18.0] + assert recorded_deadlines == [deadline] + assert [kwargs["deadline"] for _route, kwargs in database_phases] == [deadline, deadline] + assert controller.snapshot() == () + + +def test_run_due_multi_target_deadline_reserves_persistence_and_leaves_no_resources( + monkeypatch, +): + controller = DailyCheckInSingleFlight() + processes = { + f"process-{index}": SimpleNamespace( + id=f"process-{index}", + name=f"Game {index}", + user_preset_id=daily_checkin.GAME_HONKAI_STARRAIL, + hoyolab_game_id=None, + resource_provider=None, + resource_key=None, + ) + for index in range(3) + } + settings = [ + SimpleNamespace(process_id=process_id, game_id=daily_checkin.GAME_HONKAI_STARRAIL) + for process_id in processes + ] + + class FakeCrud: + @staticmethod + def get_enabled_daily_checkin_settings(_db): + return settings + + @staticmethod + def get_process_by_id(*, db, process_id): + return processes[process_id] + + @staticmethod + def get_daily_checkin_logs_for_period(*_args, **_kwargs): + return [] + + database_phases = [] + + @contextmanager + def database_session(route_name, **kwargs): + database_phases.append((route_name, kwargs)) + yield object() + + provider_calls: list[float] = [] + + def execute_provider(descriptor, *, timeout_seconds): + provider_calls.append(timeout_seconds) + time.sleep(timeout_seconds + 0.003) + return daily_checkin.DailyCheckInAttemptResult( + provider=descriptor.provider, + game_id=descriptor.game_id, + game_name=descriptor.game_name, + status="success", + attempted_at=time.time(), + message="claimed", + post_called=True, + ) + + monkeypatch.setattr(daily_checkin, "execute_daily_checkin", execute_provider) + started_threads: list[str] = [] + original_thread_start = threading.Thread.start + + def track_thread_start(thread): + started_threads.append(thread.name) + return original_thread_start(thread) + + monkeypatch.setattr(threading.Thread, "start", track_thread_start) + persisted = [] + + def record_result(_db, target, result, _trigger, **_kwargs): + persisted.append((target, result)) + return { + "process_id": target.process_id, + "status": result.status, + "post_called": result.post_called, + } + + started_at = time.monotonic() + deadline = started_at + 0.6 + namespace = { + "RUN_DUE_TOTAL_DEADLINE_SECONDS": 60.0, + "RUN_DUE_MIN_PERSISTENCE_RESERVE_SECONDS": 5.0, + "RUN_DUE_MAX_PERSISTENCE_RESERVE_SECONDS": 10.0, + "RUN_DUE_PERSISTENCE_RESERVE_PER_TARGET_SECONDS": 0.5, + "_DailyCheckInTargetSnapshot": _RunDueTargetSnapshot, + "_configure_database_deadline": lambda *_args, **_kwargs: 1.0, + "_database_session": database_session, + "_record_daily_checkin_result": record_result, + "bounded_provider_timeout_seconds": bounded_provider_timeout_seconds, + "crud": FakeCrud, + "daily_checkin_singleflight": controller, + "logger": logging.getLogger("test.run_due"), + "monotonic_deadline": lambda _timeout: deadline, + "remaining_deadline_seconds": remaining_deadline_seconds, + "schemas": SimpleNamespace(DailyCheckInRunDueRequest=object), + "time": time, + } + endpoint = _load_daily_checkin_endpoint("run_due_daily_checkins", namespace) + + response = endpoint(SimpleNamespace(trigger="deadline-test")) + completed_at = time.monotonic() + + assert completed_at <= deadline + 0.3 + assert len(provider_calls) == 1 + assert 0.0 < provider_calls[0] <= 0.35 + assert response["attempted"] == 3 + assert response["skipped"] == [] + assert [item["status"] for item in response["logs"]] == [ + "success", + "network_error", + "network_error", + ] + assert [item["post_called"] for item in response["logs"]] == [True, False, False] + assert all( + result.raw_debug == {"deadline_exhausted": True} + for _target, result in persisted[1:] + ) + assert [phase for phase, _kwargs in database_phases] == [ + "POST /daily-checkin/run-due snapshot", + "POST /daily-checkin/run-due persist", + ] + assert controller.snapshot() == () + assert started_threads == [] diff --git a/tests/test_dashboard_static_build.py b/tests/test_dashboard_static_build.py index 925c5ef3..94fa8495 100644 --- a/tests/test_dashboard_static_build.py +++ b/tests/test_dashboard_static_build.py @@ -92,3 +92,13 @@ def test_host_window_icon_uses_current_packaged_asset(): assert r"img\app_icon.ico" not in main_window assert "assets/icons/app/app_icon.ico" in spec assert r"assets\icons\app\app_icon.ico" in installer + + +def test_dashboard_requests_have_deadline_abort_and_retry_ui(): + source = Path("src/api/dashboard/frontend/src/App.tsx").read_text(encoding="utf-8") + + assert "REQUEST_TIMEOUT_MS = 10_000" in source + assert "new AbortController()" in source + assert "controller.abort()" in source + assert "fetch(url, { signal })" in source + assert "다시 시도" in source diff --git a/tests/test_database_access_coordination.py b/tests/test_database_access_coordination.py new file mode 100644 index 00000000..01853914 --- /dev/null +++ b/tests/test_database_access_coordination.py @@ -0,0 +1,620 @@ +from __future__ import annotations + +import concurrent.futures +import hashlib +import sqlite3 +import threading +import time +from contextlib import closing +from pathlib import Path + +import pytest +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient + +from src.data.database_coordination import ( + DatabaseAccessUnavailable, + DatabaseDrainTimeout, + DatabaseFaultStatePersistenceError, + DatabaseMaintenanceCoordinator, + database_access_exception_handler, +) + + +def _write_marker_database(path, marker: str) -> None: + with closing(sqlite3.connect(path)) as connection: + connection.execute("CREATE TABLE marker (value TEXT NOT NULL)") + connection.execute("INSERT INTO marker (value) VALUES (?)", (marker,)) + connection.commit() + + +def _read_marker_database(path) -> str: + with closing(sqlite3.connect(path)) as connection: + return str(connection.execute("SELECT value FROM marker").fetchone()[0]) + + +def _sha256(path) -> str: + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + + +def _restore_client(monkeypatch, tmp_path): + import src.api.beholder_routes as routes + + data_dir = tmp_path / "homework_helper_data" + backup_dir = tmp_path / "backups" + data_dir.mkdir() + backup_dir.mkdir() + current_db = data_dir / "app_data.db" + backup_db = backup_dir / "app_data.backup.1.db" + _write_marker_database(current_db, "old") + _write_marker_database(backup_db, "new") + coordinator = DatabaseMaintenanceCoordinator( + fault_state_path=data_dir / "database_fault_state.json" + ) + + monkeypatch.setattr(routes, "base_dir", str(tmp_path)) + monkeypatch.setattr(routes, "data_dir", str(data_dir)) + monkeypatch.setattr(routes, "db_path", str(current_db)) + monkeypatch.setattr(routes, "database_coordinator", coordinator) + monkeypatch.setattr(routes.engine, "dispose", lambda: None) + monkeypatch.setattr( + routes, + "_strict_prepare_live_database", + lambda: routes._require_valid_sqlite_backup(current_db), + ) + + app = FastAPI() + app.include_router(routes.router) + return TestClient(app), routes, coordinator, current_db + + +def test_request_lease_can_be_released_on_another_thread_and_is_idempotent(): + coordinator = DatabaseMaintenanceCoordinator() + lease = coordinator.acquire_request("cross-thread") + + thread = threading.Thread(target=lambda: (lease.release(), lease.release())) + thread.start() + thread.join(timeout=1) + + assert not thread.is_alive() + assert coordinator.snapshot().active_requests == 0 + assert coordinator.snapshot().mode == "normal" + + +def test_normal_request_leases_are_not_serialized(): + coordinator = DatabaseMaintenanceCoordinator() + barrier = threading.Barrier(3) + errors: list[BaseException] = [] + + def request(name: str) -> None: + try: + with coordinator.acquire_request(name): + barrier.wait(timeout=1) + barrier.wait(timeout=1) + except BaseException as exc: # pragma: no cover - assertion reports it + errors.append(exc) + + threads = [threading.Thread(target=request, args=(f"route-{index}",)) for index in range(2)] + for thread in threads: + thread.start() + + barrier.wait(timeout=1) + assert coordinator.snapshot().active_requests == 2 + barrier.wait(timeout=1) + for thread in threads: + thread.join(timeout=1) + + assert errors == [] + assert coordinator.snapshot().active_requests == 0 + + +def test_fastapi_sync_dependency_survives_cross_thread_finalization(): + coordinator = DatabaseMaintenanceCoordinator() + enter_threads: list[int] = [] + finalizer_thread_pairs: list[tuple[int, int]] = [] + observations_lock = threading.Lock() + app = FastAPI() + + def get_resource(): + lease = coordinator.acquire_request("fastapi-test") + entry_thread = threading.get_ident() + with observations_lock: + enter_threads.append(entry_thread) + try: + yield object() + finally: + with observations_lock: + finalizer_thread_pairs.append((entry_thread, threading.get_ident())) + lease.release() + + @app.get("/db") + def read_db(_resource=Depends(get_resource)): + time.sleep(0.002) + return {"ok": True} + + with TestClient(app) as client: + for batch in range(10): + with concurrent.futures.ThreadPoolExecutor(max_workers=20) as pool: + responses = list( + pool.map(lambda _: client.get("/db"), range(batch * 100, (batch + 1) * 100)) + ) + assert all(response.status_code == 200 for response in responses) + assert coordinator.snapshot().active_requests == 0 + + assert len(enter_threads) == len(finalizer_thread_pairs) == 1_000 + assert any(entry != finalizer for entry, finalizer in finalizer_thread_pairs) + assert coordinator.snapshot().active_requests == 0 + + +def test_maintenance_rejects_new_requests_and_returns_to_normal_after_release(): + coordinator = DatabaseMaintenanceCoordinator() + maintenance = coordinator.begin_maintenance("restore") + + with pytest.raises(DatabaseAccessUnavailable) as raised: + coordinator.acquire_request("blocked") + assert raised.value.code == "database_maintenance" + assert raised.value.retry_after_seconds == 2 + assert coordinator.try_acquire_request("checkpoint") is None + + maintenance.release() + with coordinator.acquire_request("allowed"): + assert coordinator.snapshot().active_requests == 1 + + +def test_fastapi_exception_handler_preserves_maintenance_response_contract(): + coordinator = DatabaseMaintenanceCoordinator() + app = FastAPI() + app.add_exception_handler(DatabaseAccessUnavailable, database_access_exception_handler) + + def get_db_lease(): + with coordinator.acquire_request("protected"): + yield object() + + @app.get("/protected") + def protected(_lease=Depends(get_db_lease)): + return {"ok": True} + + maintenance = coordinator.begin_maintenance("restore") + with TestClient(app) as client: + response = client.get("/protected") + maintenance.release() + + assert response.status_code == 503 + assert response.headers["Retry-After"] == "2" + assert response.json() == { + "detail": "database maintenance in progress", + "code": "database_maintenance", + "retry_after_seconds": 2, + } + + +def test_drain_timeout_does_not_enter_maintenance_or_lose_active_lease(): + coordinator = DatabaseMaintenanceCoordinator() + request = coordinator.acquire_request("slow") + + with pytest.raises(DatabaseDrainTimeout) as raised: + coordinator.begin_maintenance("restore", drain_timeout_seconds=0.01) + + assert raised.value.active_requests == 1 + assert coordinator.snapshot().mode == "normal" + assert coordinator.snapshot().active_requests == 1 + request.release() + + +def test_drain_waits_for_existing_request_and_rejects_new_admission(): + coordinator = DatabaseMaintenanceCoordinator() + request = coordinator.acquire_request("existing") + maintenance_ready = threading.Event() + + def begin_maintenance() -> None: + lease = coordinator.begin_maintenance("restore", drain_timeout_seconds=1) + maintenance_ready.set() + lease.release() + + thread = threading.Thread(target=begin_maintenance) + thread.start() + deadline = time.monotonic() + 1 + while coordinator.snapshot().mode != "draining" and time.monotonic() < deadline: + time.sleep(0.001) + + with pytest.raises(DatabaseAccessUnavailable): + coordinator.acquire_request("new") + request.release() + assert maintenance_ready.wait(timeout=1) + thread.join(timeout=1) + assert coordinator.snapshot().mode == "normal" + + +def test_fault_state_is_persistent_and_only_fault_recovery_can_clear_it(tmp_path): + state_path = tmp_path / "database_fault_state.json" + coordinator = DatabaseMaintenanceCoordinator(fault_state_path=state_path) + maintenance = coordinator.begin_maintenance("restore") + maintenance.mark_faulted("database_restore_rollback_failed") + + assert state_path.exists() + restarted = DatabaseMaintenanceCoordinator(fault_state_path=state_path) + assert restarted.snapshot().mode == "faulted" + assert restarted.snapshot().fault_code == "database_restore_rollback_failed" + with pytest.raises(DatabaseAccessUnavailable) as raised: + restarted.acquire_request("ordinary") + assert raised.value.code == "database_faulted" + with pytest.raises(DatabaseAccessUnavailable): + restarted.begin_maintenance("ordinary") + + recovery = restarted.begin_fault_recovery("backup-restore") + recovery.release() + assert restarted.snapshot().mode == "normal" + assert not state_path.exists() + + +@pytest.mark.parametrize("failure_point", ["mkdir", "write", "replace"]) +def test_fault_state_persistence_failure_keeps_restart_admission_fail_closed( + monkeypatch, + tmp_path, + failure_point, +): + import src.data.database_coordination as coordination + + state_path = tmp_path / "database_fault_state.json" + coordinator = DatabaseMaintenanceCoordinator(fault_state_path=state_path) + maintenance = coordinator.begin_maintenance("restore") + guard_path = state_path.with_name(state_path.name + ".guard") + assert guard_path.exists() + + with monkeypatch.context() as scoped: + if failure_point == "mkdir": + original_mkdir = Path.mkdir + + def fail_state_parent_mkdir(path, *args, **kwargs): + if path == state_path.parent: + raise OSError("injected sentinel mkdir failure") + return original_mkdir(path, *args, **kwargs) + + scoped.setattr(Path, "mkdir", fail_state_parent_mkdir) + elif failure_point == "write": + original_open = Path.open + sentinel_temporary = state_path.with_name(state_path.name + ".tmp") + + def fail_sentinel_write(path, mode="r", *args, **kwargs): + if path == sentinel_temporary and mode == "wb": + raise OSError("injected sentinel write failure") + return original_open(path, mode, *args, **kwargs) + + scoped.setattr(Path, "open", fail_sentinel_write) + else: + original_replace = coordination.os.replace + + def fail_sentinel_replace(source, target): + if Path(target) == state_path: + raise OSError("injected sentinel replace failure") + return original_replace(source, target) + + scoped.setattr(coordination.os, "replace", fail_sentinel_replace) + + with pytest.raises(DatabaseFaultStatePersistenceError) as raised: + maintenance.mark_faulted("database_restore_rollback_failed") + + assert raised.value.operation == "sentinel_write" + assert coordinator.snapshot().mode == "faulted" + restarted = DatabaseMaintenanceCoordinator(fault_state_path=state_path) + assert restarted.snapshot().mode == "faulted" + with pytest.raises(DatabaseAccessUnavailable) as denied: + restarted.acquire_request("ordinary-after-restart") + assert denied.value.code == "database_faulted" + + +def test_checkpoint_admission_is_nonblocking_and_records_timestamp(): + coordinator = DatabaseMaintenanceCoordinator() + lease = coordinator.try_acquire_request("checkpoint") + assert lease is not None + coordinator.record_checkpoint(123.5) + lease.release() + assert coordinator.snapshot().last_checkpoint_at == 123.5 + + +def test_beholder_dependency_releases_lease_when_session_creation_fails(monkeypatch): + import src.api.beholder_routes as routes + + coordinator = DatabaseMaintenanceCoordinator() + monkeypatch.setattr(routes, "database_coordinator", coordinator) + monkeypatch.setattr( + routes, + "SessionLocal", + lambda: (_ for _ in ()).throw(RuntimeError("session creation failed")), + ) + + dependency = routes.get_db() + with pytest.raises(RuntimeError, match="session creation failed"): + next(dependency) + + assert coordinator.snapshot().active_requests == 0 + + +def test_restore_prevalidation_and_successful_atomic_replace(monkeypatch, tmp_path): + client, _routes, coordinator, current_db = _restore_client(monkeypatch, tmp_path) + + response = client.post("/api/beholder/backups/restore", json={"slot": 1}) + + assert response.status_code == 200, response.text + assert response.json()["ok"] is True + assert _read_marker_database(current_db) == "new" + assert coordinator.snapshot().mode == "normal" + assert response.json()["previous_snapshot"] + + +def test_restore_reports_sentinel_clear_failure_without_reverting_database(monkeypatch, tmp_path): + client, _routes, coordinator, current_db = _restore_client(monkeypatch, tmp_path) + guard_path = current_db.parent / "database_fault_state.json.guard" + original_unlink = Path.unlink + + def fail_guard_cleanup(path, *args, **kwargs): + if path == guard_path: + raise OSError("injected sentinel clear failure") + return original_unlink(path, *args, **kwargs) + + monkeypatch.setattr(Path, "unlink", fail_guard_cleanup) + + response = client.post("/api/beholder/backups/restore", json={"slot": 1}) + + assert response.status_code == 500 + assert response.json()["code"] == "database_restore_sentinel_clear_failed" + assert response.json()["database_restored"] is True + assert "injected sentinel clear failure" in response.json()["sentinel_clear_error"] + assert response.json()["previous_snapshot"] + assert _read_marker_database(current_db) == "new" + assert coordinator.snapshot().mode == "faulted" + assert coordinator.snapshot().fault_code == "database_fault_state_clear_failed" + assert guard_path.exists() + + +@pytest.mark.parametrize( + ("method", "path", "payload"), + [ + ("GET", "/api/beholder/backups", None), + ("POST", "/api/beholder/backups/restore-preview", {"slot": 1}), + ], +) +@pytest.mark.parametrize("initial_mode", ["normal", "faulted"]) +def test_backup_summary_read_drains_before_concurrent_restore_replace( + monkeypatch, + tmp_path, + method, + path, + payload, + initial_mode, +): + client, routes, coordinator, current_db = _restore_client(monkeypatch, tmp_path) + if initial_mode == "faulted": + coordinator.begin_maintenance("prepare-fault").mark_faulted("test_restore_fault") + + original_summary = routes._db_summary + summary_open = threading.Event() + release_summary = threading.Event() + + def blocking_live_summary(path_to_summarize): + if Path(path_to_summarize) != Path(current_db): + return original_summary(path_to_summarize) + with closing(sqlite3.connect(f"file:{current_db}?mode=ro", uri=True)) as connection: + assert connection.execute("SELECT value FROM marker").fetchone()[0] == "old" + summary_open.set() + assert release_summary.wait(timeout=2) + return original_summary(path_to_summarize) + + monkeypatch.setattr(routes, "_db_summary", blocking_live_summary) + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: + summary_future = pool.submit(client.request, method, path, json=payload) + assert summary_open.wait(timeout=1) + assert coordinator.snapshot().active_requests == 1 + restore_future = pool.submit( + client.post, + "/api/beholder/backups/restore", + json={"slot": 1}, + ) + deadline = time.monotonic() + 1 + while coordinator.snapshot().mode != "draining" and time.monotonic() < deadline: + time.sleep(0.001) + + assert coordinator.snapshot().mode == "draining" + assert _read_marker_database(current_db) == "old" + assert not restore_future.done() + blocked_response = client.request(method, path, json=payload) + assert blocked_response.status_code == 503 + assert blocked_response.headers["Retry-After"] == "2" + assert blocked_response.json()["code"] == "database_maintenance" + release_summary.set() + summary_response = summary_future.result(timeout=2) + restore_response = restore_future.result(timeout=2) + + assert summary_response.status_code == 200 + assert restore_response.status_code == 200 + assert _read_marker_database(current_db) == "new" + assert coordinator.snapshot().mode == "normal" + + +def test_backup_summary_routes_reject_reads_during_maintenance(monkeypatch, tmp_path): + client, _routes, coordinator, _current_db = _restore_client(monkeypatch, tmp_path) + maintenance = coordinator.begin_maintenance("restore") + try: + responses = ( + client.get("/api/beholder/backups"), + client.post("/api/beholder/backups/restore-preview", json={"slot": 1}), + ) + finally: + maintenance.release() + + for response in responses: + assert response.status_code == 503 + assert response.headers["Retry-After"] == "2" + assert response.json() == { + "detail": "database maintenance in progress", + "code": "database_maintenance", + "retry_after_seconds": 2, + } + + +def test_restore_checkpoint_failure_leaves_live_database_unchanged(monkeypatch, tmp_path): + client, routes, coordinator, current_db = _restore_client(monkeypatch, tmp_path) + monkeypatch.setattr( + routes, + "_checkpoint_live_database", + lambda _path: (_ for _ in ()).throw(RuntimeError("checkpoint failed")), + ) + + response = client.post("/api/beholder/backups/restore", json={"slot": 1}) + + assert response.status_code == 500 + assert response.json()["code"] == "database_restore_failed" + assert _read_marker_database(current_db) == "old" + assert coordinator.snapshot().mode == "normal" + + +def test_restore_aborts_on_busy_wal_checkpoint_before_sidecar_removal_or_replace( + monkeypatch, + tmp_path, +): + client, _routes, coordinator, current_db = _restore_client(monkeypatch, tmp_path) + writer = sqlite3.connect(current_db) + reader = sqlite3.connect(current_db) + try: + assert writer.execute("PRAGMA journal_mode=WAL").fetchone()[0].lower() == "wal" + writer.execute("CREATE TABLE wal_probe (value TEXT NOT NULL)") + writer.execute("INSERT INTO wal_probe (value) VALUES ('held-reader')") + writer.commit() + reader.execute("BEGIN") + assert reader.execute("SELECT value FROM wal_probe").fetchone()[0] == "held-reader" + checkpoint = writer.execute("PRAGMA wal_checkpoint(PASSIVE)").fetchone() + assert checkpoint is not None and checkpoint[0] == 0 + before_hash = _sha256(current_db) + wal_path = Path(str(current_db) + "-wal") + shm_path = Path(str(current_db) + "-shm") + assert wal_path.exists() + assert shm_path.exists() + + response = client.post("/api/beholder/backups/restore", json={"slot": 1}) + + assert response.status_code == 500 + assert response.json()["code"] == "database_restore_failed" + assert "미조정 연결" in response.json()["restore_error"] + assert _sha256(current_db) == before_hash + assert _read_marker_database(current_db) == "old" + assert wal_path.exists() + assert shm_path.exists() + assert coordinator.snapshot().mode == "normal" + finally: + reader.close() + writer.close() + + +def test_strict_restore_preparation_order_is_create_all_migration_integrity( + monkeypatch, +): + import src.api.beholder_routes as routes + + calls: list[str] = [] + monkeypatch.setattr(routes.engine, "dispose", lambda: calls.append("dispose")) + monkeypatch.setattr( + routes.Base.metadata, + "create_all", + lambda *, bind: calls.append("create_all"), + ) + monkeypatch.setattr( + routes, + "auto_migrate_database", + lambda *, strict: calls.append(f"migration:{strict}"), + ) + monkeypatch.setattr( + routes, + "_require_valid_sqlite_backup", + lambda path: calls.append(f"integrity:{path}"), + ) + + routes._strict_prepare_live_database() + + assert calls == [ + "dispose", + "create_all", + "migration:True", + f"integrity:{routes.db_path}", + ] + + +def test_restore_validation_failure_rolls_back_previous_database(monkeypatch, tmp_path): + client, routes, coordinator, current_db = _restore_client(monkeypatch, tmp_path) + calls = 0 + + def fail_new_database_once(): + nonlocal calls + calls += 1 + if calls == 1: + raise RuntimeError("strict migration failed") + routes._require_valid_sqlite_backup(current_db) + + monkeypatch.setattr(routes, "_strict_prepare_live_database", fail_new_database_once) + + response = client.post("/api/beholder/backups/restore", json={"slot": 1}) + + assert response.status_code == 500 + assert response.json()["code"] == "database_restore_rolled_back" + assert _read_marker_database(current_db) == "old" + assert calls == 2 + assert coordinator.snapshot().mode == "normal" + + +def test_restore_and_rollback_failure_persists_fault_but_allows_backup_recovery( + monkeypatch, + tmp_path, +): + client, routes, coordinator, current_db = _restore_client(monkeypatch, tmp_path) + monkeypatch.setattr( + routes, + "_strict_prepare_live_database", + lambda: (_ for _ in ()).throw(RuntimeError("database cannot be prepared")), + ) + + failed = client.post("/api/beholder/backups/restore", json={"slot": 1}) + + assert failed.status_code == 500 + assert failed.json()["code"] == "database_restore_rollback_failed" + assert coordinator.snapshot().mode == "faulted" + assert (tmp_path / "homework_helper_data" / "database_fault_state.json").exists() + assert client.get("/api/beholder/backups").status_code == 200 + assert client.post("/api/beholder/backups/restore-preview", json={"slot": 1}).status_code == 200 + + monkeypatch.setattr( + routes, + "_strict_prepare_live_database", + lambda: routes._require_valid_sqlite_backup(current_db), + ) + recovered = client.post("/api/beholder/backups/restore", json={"slot": 1}) + + assert recovered.status_code == 200 + assert coordinator.snapshot().mode == "normal" + assert not (tmp_path / "homework_helper_data" / "database_fault_state.json").exists() + assert _read_marker_database(current_db) == "new" + + +def test_restore_rollback_error_contract_survives_fault_sentinel_write_failure( + monkeypatch, + tmp_path, +): + client, routes, coordinator, _current_db = _restore_client(monkeypatch, tmp_path) + monkeypatch.setattr( + routes, + "_strict_prepare_live_database", + lambda: (_ for _ in ()).throw(RuntimeError("database cannot be prepared")), + ) + monkeypatch.setattr( + coordinator, + "_write_fault_state", + lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("sentinel unavailable")), + ) + + response = client.post("/api/beholder/backups/restore", json={"slot": 1}) + + assert response.status_code == 500 + assert response.json()["code"] == "database_restore_rollback_failed" + assert coordinator.snapshot().mode == "faulted" + restarted = DatabaseMaintenanceCoordinator( + fault_state_path=tmp_path / "homework_helper_data" / "database_fault_state.json" + ) + assert restarted.snapshot().mode == "faulted" diff --git a/tests/test_game_resource_integrations.py b/tests/test_game_resource_integrations.py index 5b8c8432..9939fb96 100644 --- a/tests/test_game_resource_integrations.py +++ b/tests/test_game_resource_integrations.py @@ -3,6 +3,7 @@ import time import datetime as dt import inspect +import logging from pathlib import Path from types import SimpleNamespace @@ -1080,21 +1081,15 @@ def test_nikke_resource_persist_task_updates_process_and_session_percent(): resource_status="ok", ) - class FakeDataManager: + class FakeTransport: process_updates = [] session_updates = [] - def get_process_by_id(self, process_id): - assert process_id == "nikke" - return process - def update_process_resource(self, process_id, percent, updated_at, status, label): self.process_updates.append((process_id, percent, updated_at, status, label)) - return True def update_session_resource(self, session_id, resource_percent_at_end): self.session_updates.append((session_id, resource_percent_at_end)) - return True class Finished: def __init__(self): @@ -1107,7 +1102,7 @@ class Signals: def __init__(self): self.finished = Finished() - data_manager = FakeDataManager() + transport = FakeTransport() signals = Signals() task = _ResourcePersistTask( process_id="nikke", @@ -1122,18 +1117,35 @@ def __init__(self): exit_timestamp=0.0, allow_session_correction=True, applied_session_percent=10.0, - data_manager=data_manager, - should_abort=lambda: False, + process_changed=True, + transport=transport, signals=signals, ) task.run() - assert data_manager.process_updates == [("nikke", 20.0, 3600.0, "ok", "전초기지 방어 보상")] - assert data_manager.session_updates == [(7, pytest.approx(15.8333333333))] + assert transport.process_updates == [("nikke", 20.0, 3600.0, "ok", "전초기지 방어 보상")] + assert transport.session_updates == [(7, pytest.approx(15.8333333333))] assert signals.finished.payloads[0][3]["persist_succeeded"] is True +def test_reconcile_unchanged_values_do_not_write_new_fetch_timestamp(): + hoyolab_source = Path("src/core/hoyolab_reconcile.py").read_text(encoding="utf-8") + resource_source = Path("src/core/resource_reconcile.py").read_text(encoding="utf-8") + + assert "process.stamina_updated_at != fetched_at" not in hoyolab_source + assert "process.resource_updated_at != fetched_at" not in resource_source + + +def test_provider_health_persist_reports_unsupported_transport(caplog): + from src.core.provider_health_persist import ProviderHealthPersistTask + + with caplog.at_level(logging.WARNING): + ProviderHealthPersistTask(object(), {"provider": "test"}, context="test").run() + + assert "provider health 저장 실패" in caplog.text + + def test_reconcile_provider_health_writes_are_queued_off_main_path(): from src.core.hoyolab_reconcile import HoYoStaminaReconcileCoordinator from src.core.resource_reconcile import NikkeResourceReconcileCoordinator @@ -1163,6 +1175,7 @@ def start(self, task): hoyolab_pool = FakePool() hoyolab = HoYoStaminaReconcileCoordinator.__new__(HoYoStaminaReconcileCoordinator) hoyolab._data_manager = FailIfCalledDataManager() + hoyolab._transport = object() hoyolab._health_pool = hoyolab_pool hoyolab._notifier = None @@ -1192,6 +1205,7 @@ def start(self, task): nikke_pool = FakePool() nikke = NikkeResourceReconcileCoordinator.__new__(NikkeResourceReconcileCoordinator) nikke._data_manager = FailIfCalledDataManager() + nikke._transport = object() nikke._health_pool = nikke_pool nikke._notifier = None diff --git a/tests/test_gui_layout.py b/tests/test_gui_layout.py index 7686e976..f164b104 100644 --- a/tests/test_gui_layout.py +++ b/tests/test_gui_layout.py @@ -2,11 +2,13 @@ from pathlib import Path from types import MethodType, SimpleNamespace +import pytest + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") -from PyQt6.QtCore import QPoint, QRect, QSize, Qt -from PyQt6.QtGui import QColor, QIcon, QImage, QPixmap -from PyQt6.QtWidgets import QApplication, QLabel +from PySide6.QtCore import QPoint, QRect, QSize, Qt +from PySide6.QtGui import QColor, QIcon, QImage, QPixmap +from PySide6.QtWidgets import QApplication, QLabel, QProgressBar, QPushButton, QStyle, QStyleOptionButton from src.data.data_models import ( GlobalSettings, @@ -34,6 +36,7 @@ def _noop(*_args, **_kwargs): class _FakeApiClient: def __init__(self, processes): + self.app_instance_id = "gui-layout-test" self.managed_processes = processes self.web_shortcuts = [] self.global_settings = GlobalSettings( @@ -329,17 +332,36 @@ def test_menu_bar_dropdown_actions_are_text_only(monkeypatch, tmp_path): _stop_window(window, app) -def test_main_window_uses_icon_only_remote_readiness_indicators(): +def test_main_window_uses_three_read_only_bottom_readiness_items(): source = Path("src/gui/main_window.py").read_text(encoding="utf-8") assert "showMessage(" not in source - assert '("beholder", "●")' in source - assert '("remote", "●")' in source - assert '("admin", "●")' in source - assert "remoteReadiness_server" not in source - assert "remoteReadiness_power" not in source - assert "remoteReadiness_tailscale" not in source - assert "QGraphicsDropShadowEffect" in source + assert 'setObjectName("readinessStrip")' in source + assert "system_status_button" not in source + assert 'for key in ("beholder", "remote", "admin")' in source + assert "QGraphicsDropShadowEffect" not in source + assert "QStatusBar" not in source + + +def test_bottom_readiness_items_show_compact_state_and_keep_details_in_tooltips(monkeypatch, tmp_path): + app = _qapp() + main_window = _patch_main_window_deps(monkeypatch, tmp_path) + window = main_window.MainWindow(_FakeApiClient([])) + try: + window._set_remote_readiness_indicator("remote", "yellow", "원격 연결 확인 필요") + remote_dot, remote_text = window._readiness_status_widgets["remote"] + assert remote_dot.property("hhState") == "yellow" + assert remote_text.text() == "원격 준비 중" + assert remote_text.toolTip() == "원격 연결 확인 필요" + + window._set_remote_readiness_indicator("beholder", "red", "데이터 보호 오류") + data_dot, data_text = window._readiness_status_widgets["beholder"] + assert data_dot.property("hhState") == "red" + assert data_text.text() == "데이터 오류" + assert data_text.toolTip() == "데이터 보호 오류" + assert all(not label.hasMouseTracking() for pair in window._readiness_status_widgets.values() for label in pair) + finally: + _stop_window(window, app) def test_remote_server_mode_is_owned_by_remote_settings_dialog_only(): @@ -378,7 +400,83 @@ def _stop_window(window, app): app.processEvents() -def test_main_table_hides_headers_and_uses_fixed_name_sort(monkeypatch, tmp_path): +def test_main_window_launch_args_apply_only_to_direct_targets(): + import src.gui.main_window as main_window + + process = ManagedProcess( + id="zzz", + name="Zenless Zone Zero", + monitoring_path="/Games/ZZZ.exe", + launch_path="/Games/ZZZ.url", + preferred_launch_type="direct", + launch_args_enabled=True, + launch_args=" -use-d3d12 ", + ) + probe = SimpleNamespace() + + assert main_window.MainWindow._launch_args_for_process( + probe, + process, + "direct", + "/Games/ZZZ.exe", + ) == "-use-d3d12" + assert main_window.MainWindow._launch_args_for_process( + probe, + process, + "shortcut", + "/Games/ZZZ.exe", + ) == "-use-d3d12" + assert main_window.MainWindow._launch_args_for_process( + probe, + process, + "launcher", + "/Games/ZZZ.exe", + ) is None + assert main_window.MainWindow._launch_args_for_process( + probe, + process, + "direct", + "/Games/ZZZ.url", + ) is None + + +def test_process_dialog_returns_launch_args_opt_in(monkeypatch, tmp_path): + app = _qapp() + import src.gui.dialogs as dialogs + from src.utils.game_preset_manager import GamePresetManager + + monkeypatch.setattr(GamePresetManager, "USER_CONFIG_DIR", tmp_path / "HomeworkHelper", raising=False) + monkeypatch.setattr( + GamePresetManager, + "USER_PRESET_FILE", + tmp_path / "HomeworkHelper" / "game_presets_user.json", + raising=False, + ) + + dialog = dialogs.ProcessDialog() + try: + dialog.name_edit.setText("Zenless Zone Zero") + dialog.monitoring_path_edit.setText("/Games/ZZZ.exe") + dialog.launch_path_edit.setText("/Games/ZZZ.url") + direct_index = dialog.launch_type_combo.findData("direct") + dialog.launch_type_combo.setCurrentIndex(direct_index) + dialog.launch_args_enabled_checkbox.setChecked(True) + dialog.launch_args_edit.setText(" -use-d3d12 ") + + data = dialog.get_data() + + assert data is not None + assert data["preferred_launch_type"] == "direct" + assert data["launch_args_enabled"] is True + assert data["launch_args"] == "-use-d3d12" + assert dialog.launch_args_edit.isEnabled() is True + finally: + dialog.close() + dialog.deleteLater() + app.processEvents() + + +def test_main_game_table_restores_name_sort_and_centered_icons(monkeypatch, tmp_path): app = _qapp() main_window = _patch_main_window_deps(monkeypatch, tmp_path) icon_requests = [] @@ -401,7 +499,6 @@ def test_main_table_hides_headers_and_uses_fixed_name_sort(monkeypatch, tmp_path assert not window.process_table.horizontalHeader().isVisible() assert not window.process_table.verticalHeader().isVisible() - assert window.process_table.verticalHeader().width() == 0 assert not window.process_table.isSortingEnabled() assert [ window.process_table.item(row, window.COL_NAME).text() @@ -409,48 +506,67 @@ def test_main_table_hides_headers_and_uses_fixed_name_sort(monkeypatch, tmp_path ] == ["Alpha", "Beta", "Zeta"] assert [request[2] for request in icon_requests] == ["a", "b", "z"] assert {request[1] for request in icon_requests} == {window._TABLE_ICON_LOGICAL_SIZE} - assert window.process_table.iconSize().width() == window._TABLE_ICON_LOGICAL_SIZE - assert window.process_table.columnWidth(window.COL_ICON) <= ( - window._TABLE_ICON_LOGICAL_SIZE + window._TABLE_ICON_COLUMN_PADDING - ) icon_cell = window.process_table.cellWidget(0, window.COL_ICON) assert isinstance(icon_cell, QLabel) assert icon_cell.alignment() & Qt.AlignmentFlag.AlignHCenter assert icon_cell.alignment() & Qt.AlignmentFlag.AlignVCenter + assert window.process_table.columnWidth(window.COL_ICON) <= ( + window._TABLE_ICON_LOGICAL_SIZE + window._TABLE_ICON_COLUMN_PADDING + ) assert all( - window._TABLE_ROW_HEIGHT <= window.process_table.rowHeight(row) <= window._TABLE_ROW_HEIGHT + 4 + window.process_table.rowHeight(row) == window._TABLE_ROW_HEIGHT for row in range(window.process_table.rowCount()) ) + launch_cell = window.process_table.cellWidget(0, window.COL_LAUNCH_BTN) + launch_button = launch_cell.findChild(QPushButton) + assert launch_button is not None + assert launch_button.height() == window._TABLE_LAUNCH_BUTTON_HEIGHT finally: _stop_window(window, app) -def test_main_table_enables_overflow_scrollbar_instead_of_oversizing_screen(monkeypatch, tmp_path): +@pytest.mark.parametrize("process_count", [0, 1, 2, 3, 4, 8]) +def test_main_table_window_fits_all_rows_without_scroll(monkeypatch, tmp_path, process_count): app = _qapp() main_window = _patch_main_window_deps(monkeypatch, tmp_path) - long_name = "Extremely Long Game Name " + ("X" * 800) - window = main_window.MainWindow( - _FakeApiClient([ - ManagedProcess(id="long", name=long_name, monitoring_path="long.exe", launch_path="long.exe"), - ]) - ) + processes = [ + ManagedProcess( + id=f"game-{index}", + name=f"Game {index + 1}", + monitoring_path=f"game-{index}.exe", + launch_path=f"game-{index}.exe", + ) + for index in range(process_count) + ] + window = main_window.MainWindow(_FakeApiClient(processes)) try: window.show() app.processEvents() window._adjust_window_size_to_content() app.processEvents() - screen = window.screen() or QApplication.primaryScreen() - max_width = int(screen.availableGeometry().width() * window._SCREEN_SIZE_RATIO) - assert window.width() <= max(max_width, window._MIN_WINDOW_WIDTH) + assert window.process_table.rowCount() == process_count + assert window.process_table.verticalScrollBarPolicy() == Qt.ScrollBarPolicy.ScrollBarAlwaysOff + assert window.process_table.horizontalScrollBarPolicy() == Qt.ScrollBarPolicy.ScrollBarAlwaysOff assert window.minimumSize() == window.size() assert window.maximumSize() == window.size() - assert window.process_table.horizontalScrollBarPolicy() == Qt.ScrollBarPolicy.ScrollBarAsNeeded + assert window.size() == window.sizeHint().expandedTo( + QSize(window._MIN_WINDOW_WIDTH, window._MIN_WINDOW_HEIGHT) + ) + expected_height = window.process_table.frameWidth() * 2 + sum( + window.process_table.rowHeight(row) + for row in range(window.process_table.rowCount()) + ) + if process_count == 0: + expected_height += window._TABLE_ROW_HEIGHT + assert window.process_table.height() == expected_height + assert window.top_button_area.width() == window.process_table.width() + assert window.readiness_strip.width() == window.process_table.width() finally: _stop_window(window, app) -def test_restore_window_state_preserves_fixed_content_size(monkeypatch, tmp_path): +def test_restore_window_state_reapplies_content_fixed_size(monkeypatch, tmp_path): app = _qapp() main_window = _patch_main_window_deps(monkeypatch, tmp_path) window = main_window.MainWindow( @@ -470,31 +586,87 @@ def test_restore_window_state_preserves_fixed_content_size(monkeypatch, tmp_path _stop_window(window, app) -def test_relative_window_anchor_keeps_bottom_right_across_height_changes(): - import src.gui.main_window as main_window +def test_dashboard_button_uses_original_chart_glyph(monkeypatch, tmp_path): + app = _qapp() + main_window = _patch_main_window_deps(monkeypatch, tmp_path) + window = main_window.MainWindow(_FakeApiClient([])) + try: + assert window.dashboard_button.text() == "📊" + assert window.dashboard_button.icon().isNull() + assert window.dashboard_button.size() == window.add_web_shortcut_button.size() + assert window.dashboard_button.size().toTuple() == (30, 30) + assert window.dashboard_button.size() == window.dashboard_button.sizeHint() + finally: + _stop_window(window, app) - virtual_available = QRect(0, 0, 2560, 1560) - window_rect = QRect(2200, 1260, 360, 300) - anchor = main_window.MainWindow._window_anchor_from_rect( - window_rect, - virtual_available, - "Moonlight", - ) +def test_style_controlled_main_buttons_are_not_smaller_than_size_hints(monkeypatch, tmp_path): + app = _qapp() + main_window = _patch_main_window_deps(monkeypatch, tmp_path) + process = ManagedProcess(id="game", name="Game", monitoring_path="game.exe", launch_path="game.exe") + window = main_window.MainWindow(_FakeApiClient([process])) + try: + window.show() + app.processEvents() + buttons = window.findChildren(QPushButton) - assert anchor["horizontal"] == "right" - assert anchor["vertical"] == "bottom" - assert anchor["right_gap"] == 0 - assert anchor["bottom_gap"] == 0 + assert buttons + assert all( + button.width() >= button.sizeHint().width() + and button.height() >= button.sizeHint().height() + for button in buttons + ) + finally: + _stop_window(window, app) - physical_available = QRect(0, 0, 2560, 1400) - restored = main_window.MainWindow._position_from_window_anchor( - anchor, - physical_available, - QSize(360, 300), - ) - assert restored == QPoint(2200, 1100) +def test_web_button_and_theme_changes_refit_through_same_fixed_size_path(monkeypatch, tmp_path): + app = _qapp() + main_window = _patch_main_window_deps(monkeypatch, tmp_path) + client = _FakeApiClient([ + ManagedProcess(id="a", name="Alpha", monitoring_path="a.exe", launch_path="a.exe"), + ]) + window = main_window.MainWindow(client) + try: + window.show() + app.processEvents() + initial_size = window.size() + + client.web_shortcuts = [ + WebShortcut(id=f"web-{index}", name=f"주요 서비스 바로가기 {index + 1}", url="https://example.com") + for index in range(5) + ] + window._load_and_display_web_buttons() + app.processEvents() + assert window.width() > initial_size.width() + assert window.minimumSize() == window.maximumSize() == window.size() + + window._apply_theme("dark") + app.processEvents() + assert window.minimumSize() == window.maximumSize() == window.size() + + client.web_shortcuts = [] + window._load_and_display_web_buttons() + app.processEvents() + assert window.width() == initial_size.width() + assert window.minimumSize() == window.maximumSize() == window.size() + finally: + _stop_window(window, app) + + +def test_main_window_position_is_not_persisted_when_show_always_reanchors(): + source = Path("src/gui/main_window.py").read_text(encoding="utf-8") + + assert 'setValue("window_geometry"' not in source + assert "restoreGeometry(" not in source + assert "_position_on_cursor_screen_bottom_right" in source + assert "position_windows_window_bottom_right(" in source + assert "_pending_bottom_right_placement" in source + assert "def moveEvent(self, event):" in source + assert "snap_windows_window_to_work_area(" in source + assert "_WindowsMovingEventFilter" not in source + assert "QAbstractNativeEventFilter" not in source + assert "QApplication.screenAt(QCursor.pos())" in source def test_web_shortcut_click_uses_runtime_marker(monkeypatch, tmp_path): @@ -535,20 +707,10 @@ def test_dashboard_button_uses_data_manager_base_url(monkeypatch, tmp_path): data_manager.base_url = "http://127.0.0.1:43210" window = main_window.MainWindow(data_manager) opened = [] - health_urls = [] - - class _Response: - status_code = 200 - - def json(self): - return {"ok": True, "dashboard_static_ready": True} - - monkeypatch.setattr(main_window.requests, "get", lambda url, **_kwargs: health_urls.append(url) or _Response()) window.open_webpage = lambda url: opened.append(url) try: window._open_dashboard() - assert health_urls == ["http://127.0.0.1:43210/api/gui/health"] assert opened == ["http://127.0.0.1:43210/dashboard"] finally: _stop_window(window, app) @@ -576,6 +738,162 @@ def test_resource_icon_label_centers_pixmap_in_fixed_space(monkeypatch, tmp_path _stop_window(window, app) +def test_progress_cell_uses_full_width_bar_and_only_real_resource_icon(monkeypatch, tmp_path): + app = _qapp() + main_window = _patch_main_window_deps(monkeypatch, tmp_path) + process = ManagedProcess(id="a", name="Alpha", monitoring_path="a.exe", launch_path="a.exe") + window = main_window.MainWindow(_FakeApiClient([process])) + icon_path = tmp_path / "resource.png" + pixmap = QPixmap(16, 16) + pixmap.fill(QColor("#5cc8ff")) + assert pixmap.save(str(icon_path)) + without_icon = None + with_icon = None + try: + monkeypatch.setattr(window, "_get_stamina_icon_path", lambda _process: None) + without_icon = window._create_progress_bar_widget(process, 50.0, "50분") + without_icon.resize(240, window._TABLE_ROW_HEIGHT) + without_icon.show() + app.processEvents() + assert len(without_icon.findChildren(QLabel)) == 1 + assert without_icon.findChild(QLabel, "progressText").alignment() & Qt.AlignmentFlag.AlignRight + assert without_icon.findChild(QProgressBar).width() == without_icon.contentsRect().width() + + monkeypatch.setattr(window, "_get_stamina_icon_path", lambda _process: str(icon_path)) + with_icon = window._create_progress_bar_widget(process, 50.0, "50분") + with_icon.resize(240, window._TABLE_ROW_HEIGHT) + with_icon.show() + app.processEvents() + assert len(with_icon.findChildren(QLabel)) == 2 + labels = with_icon.findChildren(QLabel) + resource_icon = next(label for label in labels if label.objectName() != "progressText") + progress_text = with_icon.findChild(QLabel, "progressText") + assert resource_icon.geometry().left() == with_icon.contentsRect().left() + assert progress_text.geometry().right() == with_icon.contentsRect().right() + assert resource_icon.geometry().right() < progress_text.geometry().left() + finally: + if without_icon is not None: + without_icon.close() + if with_icon is not None: + with_icon.close() + _stop_window(window, app) + + +def test_always_on_top_corner_has_safe_margin_and_right_alignment(monkeypatch, tmp_path): + app = _qapp() + main_window = _patch_main_window_deps(monkeypatch, tmp_path) + window = main_window.MainWindow(_FakeApiClient([])) + try: + window.show() + app.processEvents() + margins = window._menu_corner_layout.contentsMargins() + assert margins.left() == 0 + assert margins.top() == margins.bottom() == 0 + assert margins.right() == 0 + assert window._menu_corner_layout.count() == 2 + assert window._menu_corner_layout.itemAt(0).widget() is window._always_on_top_cb + assert window._menu_corner_layout.itemAt(1).widget() is window._volume_btn + menu_rect = window.menuBar().contentsRect() + corner_rect = window._menu_corner_container.geometry() + action_rect = window.menuBar().actionGeometry(window.menuBar().actions()[0]) + checkbox_rect = window._always_on_top_cb.geometry().translated(corner_rect.topLeft()) + volume_rect = window._volume_btn.geometry().translated(corner_rect.topLeft()) + # QMenuBar 자체의 4px 오른쪽 스타일 여백만 남고 별도 stretch는 없습니다. + assert 0 <= menu_rect.right() - corner_rect.right() <= 4 + assert menu_rect.contains(corner_rect) + assert checkbox_rect.center().y() == action_rect.center().y() + assert volume_rect.center().y() == action_rect.center().y() + assert window._always_on_top_cb.width() >= window._always_on_top_cb.sizeHint().width() + assert checkbox_rect.right() + window._menu_corner_layout.spacing() < volume_rect.left() + + option = QStyleOptionButton() + window._always_on_top_cb.initStyleOption(option) + indicator_rect = window._always_on_top_cb.style().subElementRect( + QStyle.SubElement.SE_CheckBoxIndicator, + option, + window._always_on_top_cb, + ) + assert indicator_rect.top() > 0 + assert indicator_rect.bottom() < window._always_on_top_cb.height() - 1 + finally: + _stop_window(window, app) + + +def test_volume_button_icon_uses_theme_text_color(monkeypatch, tmp_path): + app = _qapp() + main_window = _patch_main_window_deps(monkeypatch, tmp_path) + window = main_window.MainWindow(_FakeApiClient([])) + try: + window._apply_theme("dark") + app.processEvents() + image = window._volume_btn.icon().pixmap(16, 16).toImage() + visible_colors = [ + image.pixelColor(x, y) + for y in range(image.height()) + for x in range(image.width()) + if image.pixelColor(x, y).alpha() > 0 + ] + + assert visible_colors + assert all(color.lightness() > 200 for color in visible_colors) + finally: + _stop_window(window, app) + + +def test_move_event_uses_main_window_handle_and_15px_snap_threshold(monkeypatch, tmp_path): + app = _qapp() + main_window = _patch_main_window_deps(monkeypatch, tmp_path) + calls = [] + monkeypatch.setattr( + main_window, + "snap_windows_window_to_work_area", + lambda hwnd, *, threshold_logical: calls.append((hwnd, threshold_logical)) or False, + ) + window = main_window.MainWindow(_FakeApiClient([])) + try: + window.show() + app.processEvents() + calls.clear() + window.move(window.pos() + QPoint(20, 20)) + app.processEvents() + + assert calls + assert calls[-1] == (int(window.winId()), 15) + finally: + _stop_window(window, app) + + +@pytest.mark.parametrize( + ("rect", "expected"), + [ + ((7, 200, 207, 300), (0, 200, 200, 300)), + ((1709, 200, 1909, 300), (1720, 200, 1920, 300)), + ((200, 8, 400, 108), (200, 0, 400, 100)), + ((200, 931, 400, 1031), (200, 940, 400, 1040)), + ((5, 935, 205, 1035), (0, 940, 200, 1040)), + ((20, 20, 220, 120), (20, 20, 220, 120)), + ], +) +def test_snap_rect_to_work_area_preserves_size_and_snaps_edges(rect, expected): + from src.utils.windows import snap_rect_to_work_area + + snapped = snap_rect_to_work_area(rect, (0, 0, 1920, 1040), 12) + + assert snapped == expected + assert snapped[2] - snapped[0] == rect[2] - rect[0] + assert snapped[3] - snapped[1] == rect[3] - rect[1] + + +def test_snap_rect_to_offset_secondary_monitor_work_area(): + from src.utils.windows import snap_rect_to_work_area + + assert snap_rect_to_work_area( + (-1910, 50, -1710, 150), + (-1920, 40, 0, 1080), + 12, + ) == (-1920, 40, -1720, 140) + + def test_sidebar_activates_when_running_cache_already_exists_without_monitor_change(monkeypatch, tmp_path): app = _qapp() main_window = _patch_main_window_deps(monkeypatch, tmp_path) @@ -1376,6 +1694,11 @@ def start(self, interval): heartbeat_timer = FakeTimer() ui_timer = FakeTimer() process_monitor = types.SimpleNamespace(active_monitored_processes={"game-a": {"session_id": 1}}) + timer_registry = main_window.DesiredTimerRegistry() + timer_registry.register("monitor", monitor_timer, interval_ms=1000) + timer_registry.register("scheduler", scheduler_timer, interval_ms=1000) + timer_registry.register("heartbeat", heartbeat_timer, interval_ms=30000) + timer_registry.register("ui_refresh", ui_timer, interval_ms=1000) window = types.SimpleNamespace( process_monitor=process_monitor, monitor_timer=monitor_timer, @@ -1383,6 +1706,8 @@ def start(self, interval): runtime_heartbeat_timer=heartbeat_timer, ui_refresh_timer=ui_timer, _UI_REFRESH_INTERVAL_MS=1000, + _timer_registry=timer_registry, + _work_coordinator=types.SimpleNamespace(invalidate_telemetry=lambda: None), ) main_window.MainWindow._suspend_runtime_after_beholder_restore(window) diff --git a/tests/test_gui_work_stability.py b/tests/test_gui_work_stability.py new file mode 100644 index 00000000..dc7379f8 --- /dev/null +++ b/tests/test_gui_work_stability.py @@ -0,0 +1,573 @@ +import os +import ctypes +import threading +import time +from types import SimpleNamespace + +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PySide6.QtCore import QObject, QThread, Slot +from PySide6.QtWidgets import QApplication, QLabel, QPushButton, QTextEdit + +from src.gui.power_events import ( + PBT_APMRESUMEAUTOMATIC, + WM_POWERBROADCAST, + DesiredTimerRegistry, + WindowsPowerEventFilter, + WindowsPowerEventParser, +) +from src.gui.work_coordinator import GuiWorkCoordinator, WorkResult +from src.core.process_monitor import ProcessLifecycleEvent +from src.gui.sidebar.sidebar_widget import _VideoThumbnailLoadTask + + +def _qapp() -> QApplication: + return QApplication.instance() or QApplication([]) + + +def _pump_until(predicate, *, timeout: float = 2.0) -> None: + app = _qapp() + deadline = time.monotonic() + timeout + while not predicate() and time.monotonic() < deadline: + app.processEvents() + time.sleep(0.002) + assert predicate() + + +def test_telemetry_keeps_one_running_and_only_the_latest_pending_result() -> None: + _qapp() + coordinator = GuiWorkCoordinator(max_threads=1) + started = threading.Event() + release = threading.Event() + calls: list[str] = [] + results: list[str] = [] + + def first() -> str: + calls.append("first") + started.set() + release.wait(1.0) + return "obsolete" + + def value(name: str) -> str: + calls.append(name) + return name + + coordinator.result_ready.connect(lambda item: results.append(item.value)) + coordinator.submit_telemetry("process_scan", first) + assert started.wait(1.0) + coordinator.submit_telemetry("process_scan", value, "replaced") + coordinator.submit_telemetry("process_scan", value, "latest") + + snapshot = coordinator.snapshot() + assert snapshot.running_telemetry == ("process_scan",) + assert snapshot.pending_telemetry == ("process_scan",) + + release.set() + _pump_until(lambda: calls == ["first", "latest"] and results == ["latest"]) + assert coordinator.shutdown(deadline_seconds=0.2) + + +def test_lifecycle_is_fifo_per_process_and_parallel_between_processes() -> None: + _qapp() + coordinator = GuiWorkCoordinator(max_threads=4) + first_started = threading.Event() + other_started = threading.Event() + release = threading.Event() + calls: list[str] = [] + results: list[str] = [] + + def blocked_first() -> str: + calls.append("a-start") + first_started.set() + release.wait(1.0) + calls.append("a-end") + return "a-first" + + def immediate(name: str, marker: threading.Event | None = None) -> str: + calls.append(name) + if marker is not None: + marker.set() + return name + + coordinator.result_ready.connect(lambda item: results.append(item.value)) + coordinator.submit_lifecycle("game-a", blocked_first) + assert first_started.wait(1.0) + coordinator.submit_lifecycle("game-a", immediate, "a-second") + coordinator.submit_lifecycle("game-b", immediate, "b-first", other_started) + + assert other_started.wait(1.0) + assert "a-second" not in calls + release.set() + _pump_until(lambda: len(results) == 3) + + assert calls.index("a-end") < calls.index("a-second") + assert results.index("a-first") < results.index("a-second") + assert coordinator.shutdown(deadline_seconds=0.2) + + +def test_completion_is_delivered_on_gui_thread_and_late_result_is_discarded() -> None: + app = _qapp() + coordinator = GuiWorkCoordinator(max_threads=1) + + class Receiver(QObject): + def __init__(self) -> None: + super().__init__() + self.threads: list[QThread] = [] + + @Slot(object) + def receive(self, _result: WorkResult) -> None: + self.threads.append(QThread.currentThread()) + + receiver = Receiver() + coordinator.result_ready.connect(receiver.receive) + coordinator.submit_telemetry("heartbeat", lambda: "ok") + _pump_until(lambda: bool(receiver.threads)) + assert receiver.threads == [app.thread()] + + started = threading.Event() + release = threading.Event() + + def blocked() -> str: + started.set() + release.wait(1.0) + return "late" + + coordinator.submit_telemetry("readiness", blocked) + assert started.wait(1.0) + before = len(receiver.threads) + started_at = time.monotonic() + assert coordinator.shutdown(deadline_seconds=0.02) is False + assert time.monotonic() - started_at < 0.2 + release.set() + time.sleep(0.03) + app.processEvents() + assert len(receiver.threads) == before + + +def test_power_resume_parser_debounces_and_filter_never_consumes_event() -> None: + ticks = iter((100.0, 102.0, 106.0)) + parser = WindowsPowerEventParser(clock=lambda: next(ticks)) + assert parser.parse(WM_POWERBROADCAST, PBT_APMRESUMEAUTOMATIC) is not None + assert parser.parse(WM_POWERBROADCAST, PBT_APMRESUMEAUTOMATIC) is None + assert parser.parse(WM_POWERBROADCAST, PBT_APMRESUMEAUTOMATIC) is not None + assert parser.parse(0, PBT_APMRESUMEAUTOMATIC) is None + + events = [] + event_filter = WindowsPowerEventFilter( + events.append, + parser=WindowsPowerEventParser(clock=lambda: 200.0), + decoder=lambda _message: (WM_POWERBROADCAST, PBT_APMRESUMEAUTOMATIC), + ) + assert event_filter.nativeEventFilter(b"windows_generic_MSG", object()) == (False, 0) + assert len(events) == 1 + + +def test_desired_timer_registry_does_not_restart_suspended_or_shutdown_timer() -> None: + class Timer: + def __init__(self) -> None: + self.active = True + self.starts: list[int] = [] + self.stops = 0 + + def start(self, msec: int) -> None: + self.active = True + self.starts.append(msec) + + def stop(self) -> None: + self.active = False + self.stops += 1 + + def isActive(self) -> bool: + return self.active + + active = Timer() + suspended = Timer() + registry = DesiredTimerRegistry() + registry.register("active", active, interval_ms=1000) + registry.register("suspended", suspended, interval_ms=2000) + registry.suspend("database_restore", ("suspended",)) + registry.restart_desired() + + assert active.starts == [1000] + assert suspended.starts == [] + assert not suspended.active + + registry.shutdown() + registry.restart_desired() + assert active.starts == [1000] + assert not active.active + + +def _lifecycle_command(): + from src.gui.main_window import _LifecycleCommand + + event = ProcessLifecycleEvent( + process_id="game-a", + process_name="Game A", + session_id=None, + timestamp=100.0, + stamina_tracking_enabled=False, + hoyolab_game_id=None, + pid=321, + ) + return _LifecycleCommand("start", event, 321, 100.0, "instance:game-a:321:100.000000") + + +def test_lifecycle_persistence_uses_exact_backoff_sequence_then_succeeds() -> None: + from src.gui.main_window import MainWindow + + class ShutdownEvent: + def __init__(self) -> None: + self.delays: list[float] = [] + + def is_set(self) -> bool: + return False + + def wait(self, delay: float) -> bool: + self.delays.append(delay) + return False + + class Transport: + def __init__(self) -> None: + self.attempts = 0 + + def start_session(self, **_kwargs): + self.attempts += 1 + if self.attempts <= 5: + raise ConnectionError("temporary") + return {"id": 77} + + shutdown = ShutdownEvent() + transport = Transport() + window = SimpleNamespace( + _background_transport=transport, + _app_instance_id="instance", + _lifecycle_shutdown_event=shutdown, + _lifecycle_session_lock=threading.Lock(), + _lifecycle_session_ids={}, + ) + + result = MainWindow._persist_lifecycle_command(window, _lifecycle_command()) + + assert result.succeeded is True + assert result.session_id == 77 + assert result.attempts == 6 + assert shutdown.delays == [1.0, 2.0, 5.0, 10.0, 30.0] + + +def test_exhausted_lifecycle_persistence_records_one_failure_for_token() -> None: + from src.gui.main_window import MainWindow + + class ShutdownEvent: + def is_set(self) -> bool: + return False + + def wait(self, _delay: float) -> bool: + return False + + class Transport: + def __init__(self) -> None: + self.failure_payloads = [] + + def start_session(self, **_kwargs): + raise ConnectionError("offline") + + def post_json(self, path, payload, **_kwargs): + assert path == "/api/beholder/runtime/lifecycle-failure" + self.failure_payloads.append(payload) + return SimpleNamespace(payload={"ok": True}) + + transport = Transport() + window = SimpleNamespace( + _background_transport=transport, + _app_instance_id="instance", + _lifecycle_shutdown_event=ShutdownEvent(), + _lifecycle_session_lock=threading.Lock(), + _lifecycle_session_ids={}, + ) + + result = MainWindow._persist_lifecycle_command(window, _lifecycle_command()) + + assert result.succeeded is False + assert result.attempts == 6 + assert len(transport.failure_payloads) == 1 + assert transport.failure_payloads[0]["runtime_token"] == "instance:game-a:321:100.000000" + + +def test_beholder_block_is_not_retried_or_reported_as_lifecycle_failure() -> None: + import requests + from src.api.client import BeholderIncidentRequired + from src.gui.main_window import MainWindow + + response = requests.Response() + response.status_code = 409 + incident = {"id": 7, "status": "pending", "operation_kind": "runtime_start"} + + class ShutdownEvent: + def __init__(self) -> None: + self.delays: list[float] = [] + + def is_set(self) -> bool: + return False + + def wait(self, delay: float) -> bool: + self.delays.append(delay) + return False + + class Transport: + attempts = 0 + + def start_session(self, **_kwargs): + self.attempts += 1 + raise BeholderIncidentRequired(response, incident) + + def post_json(self, *_args, **_kwargs): + raise AssertionError("Beholder 차단은 lifecycle failure incident를 만들면 안 됩니다") + + transport = Transport() + shutdown = ShutdownEvent() + window = SimpleNamespace( + _background_transport=transport, + _app_instance_id="instance", + _lifecycle_shutdown_event=shutdown, + _lifecycle_session_lock=threading.Lock(), + _lifecycle_session_ids={}, + ) + + result = MainWindow._persist_lifecycle_command(window, _lifecycle_command()) + + assert result.blocked is True + assert result.beholder_incident == incident + assert result.attempts == 1 + assert transport.attempts == 1 + assert shutdown.delays == [] + + +def test_background_transport_preserves_beholder_409_payload(monkeypatch) -> None: + import src.api.client as client_module + from src.api.client import BackgroundApiTransport, BeholderIncidentRequired + + incident = {"id": 8, "status": "pending", "operation_kind": "runtime_stop"} + + class Response: + status_code = 409 + text = "blocked" + + def json(self): + return {"beholder_incident": incident} + + def raise_for_status(self): + raise AssertionError("409 incident는 일반 HTTP 오류 경로로 보내면 안 됩니다") + + class Session: + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def request(self, *_args, **_kwargs): + return Response() + + monkeypatch.setattr(client_module.requests, "Session", Session) + + with pytest.raises(BeholderIncidentRequired) as raised: + BackgroundApiTransport("http://127.0.0.1:1").get_json("/blocked", timeout=1.0) + + assert raised.value.incident == incident + + +def test_incomplete_lifecycle_identity_is_rejected_without_api_call() -> None: + from dataclasses import replace + from src.gui.main_window import MainWindow + + command = replace(_lifecycle_command(), runtime_token="") + + class Transport: + def start_session(self, **_kwargs): + raise AssertionError("불완전한 lifecycle 명령은 API로 보내면 안 됩니다") + + window = SimpleNamespace( + _background_transport=Transport(), + _app_instance_id="instance", + _lifecycle_shutdown_event=threading.Event(), + _lifecycle_session_lock=threading.Lock(), + _lifecycle_session_ids={}, + ) + + result = MainWindow._persist_lifecycle_command(window, command) + + assert result.succeeded is False + assert result.attempts == 0 + assert result.error == "invalid lifecycle identity" + + +def test_beholder_dialog_cannot_reenter_and_close_snoozes_for_current_run(monkeypatch) -> None: + import src.gui.main_window as main_window_module + from src.gui.main_window import MainWindow + + incident = {"id": 41, "status": "pending", "operation_kind": "runtime_stop"} + opens: list[int] = [] + + class Dialog: + action = None + + def __init__(self, _incident, _parent): + opens.append(1) + + def exec(self): + MainWindow._apply_beholder_incidents(window, (incident,)) + return 0 + + class DataManager: + def resolve_beholder_incident(self, *_args): + raise AssertionError("닫기/X는 DB 결정을 저장하면 안 됩니다") + + window = SimpleNamespace( + _beholder_dialog_active=False, + _beholder_seen_incidents=set(), + data_manager=DataManager(), + process_monitor=SimpleNamespace(), + showNormal=lambda: None, + raise_=lambda: None, + activateWindow=lambda: None, + ) + monkeypatch.setattr(main_window_module, "BeholderIncidentDialog", Dialog) + + MainWindow._apply_beholder_incidents(window, (incident,)) + MainWindow._apply_beholder_incidents(window, (incident,)) + + assert len(opens) == 1 + assert window._beholder_dialog_active is False + assert window._beholder_seen_incidents == {41} + + +def test_beholder_dialog_hides_internal_identity_until_technical_details() -> None: + from src.gui.beholder_dialog import BeholderIncidentDialog + + _qapp() + dialog = BeholderIncidentDialog({ + "id": 41, + "status": "pending", + "user_title": "이미 끝난 플레이 기록의 재종료를 차단했습니다", + "user_summary": "삭제된 게임 항목의 기존 기록은 이미 종료되어 있습니다.", + "user_impact": "현재 데이터는 변경되지 않았습니다.", + "safe_recommendation": "차단을 유지하세요.", + "operation_kind": "runtime_stop", + "actor": "process_monitor", + "target_summary": "session_id=1, process_id=secret-uuid", + "current_state_summary": "status=closed", + "proposed_change_summary": "end_timestamp=123", + "risk_factors": ["invalid_current_status:closed"], + "available_actions": [{ + "id": "deny", + "label": "차단 유지", + "description": "기존 기록을 유지합니다.", + "outcome": "현재 데이터는 변경되지 않습니다.", + "recommended": True, + }], + }) + + visible_copy = "\n".join(label.text() for label in dialog.findChildren(QLabel)) + details = dialog.findChild(QTextEdit) + button_copy = [button.text() for button in dialog.findChildren(QPushButton)] + + assert "secret-uuid" not in visible_copy + assert "session_id" not in visible_copy + assert details is not None and details.isHidden() + assert "secret-uuid" in details.toPlainText() + assert any("차단 유지" in text for text in button_copy) + assert all("이번 한 번 허용" not in text for text in button_copy) + + +def test_stop_persistence_keeps_cached_resource_baseline() -> None: + from src.gui.main_window import MainWindow, _LifecycleCommand + + event = ProcessLifecycleEvent( + process_id="game-a", + process_name="Game A", + session_id=77, + timestamp=200.0, + stamina_tracking_enabled=True, + hoyolab_game_id="genshin", + pid=321, + stamina_at_end=45, + stamina_max=200, + resource_tracking_enabled=True, + resource_provider="nikke_blablalink", + resource_key="nikke_outpost_storage", + resource_percent_at_end=12.5, + ) + command = _LifecycleCommand("stop", event, 321, 100.0, "instance:game-a:321:100.000000") + + class ShutdownEvent: + def is_set(self) -> bool: + return False + + def wait(self, _delay: float) -> bool: + return False + + class Transport: + def __init__(self) -> None: + self.end_calls = [] + + def end_session(self, **kwargs): + self.end_calls.append(kwargs) + return {"id": 77} + + def patch_json(self, *_args, **_kwargs): + return SimpleNamespace(payload={}) + + transport = Transport() + window = SimpleNamespace( + _background_transport=transport, + _app_instance_id="instance", + _lifecycle_shutdown_event=ShutdownEvent(), + _lifecycle_session_lock=threading.Lock(), + _lifecycle_session_ids={}, + ) + + result = MainWindow._persist_lifecycle_command(window, command) + + assert result.succeeded is True + assert transport.end_calls == [{ + "session_id": 77, + "end_timestamp": 200.0, + "stamina_at_end": 45, + "resource_percent_at_end": 12.5, + "timeout": 10.0, + }] + + +def test_shell_thumbnail_balances_com_on_early_return(monkeypatch) -> None: + class Ole32: + def __init__(self) -> None: + self.uninitializes = 0 + + def CoInitializeEx(self, *_args) -> int: + return 1 # S_FALSE still requires CoUninitialize. + + def CoUninitialize(self) -> None: + self.uninitializes += 1 + + class Shell32: + def SHCreateItemFromParsingName(self, *_args) -> int: + return 1 + + ole32 = Ole32() + monkeypatch.setattr( + ctypes, + "windll", + SimpleNamespace( + shell32=Shell32(), + ole32=ole32, + gdi32=SimpleNamespace(), + user32=SimpleNamespace(), + ), + raising=False, + ) + + assert _VideoThumbnailLoadTask._extract_thumbnail("missing.mp4", 8, 8) is None + assert ole32.uninitializes == 1 diff --git a/tests/test_instance_manager.py b/tests/test_instance_manager.py new file mode 100644 index 00000000..dc220495 --- /dev/null +++ b/tests/test_instance_manager.py @@ -0,0 +1,118 @@ +from pathlib import Path + +from src.core import instance_manager + + +def test_windows_executable_identity_is_case_insensitive_and_path_scoped(): + first = instance_manager.instance_identity( + r"C:\Program Files\HomeworkHelper\homework_helper.exe", + windows=True, + ) + same = instance_manager.instance_identity( + r"c:\PROGRAM FILES\HomeworkHelper\.\homework_helper.exe", + windows=True, + ) + other = instance_manager.instance_identity( + r"D:\Portable\HomeworkHelper\homework_helper.exe", + windows=True, + ) + + assert first == same + assert first.digest != other.digest + assert first.server_name != other.server_name + assert first.server_name.startswith(instance_manager.APP_UNIQUE_KEY + "_") + + +def test_single_instance_shared_memory_is_scoped_to_executable_path(): + first = instance_manager.SingleInstanceApplication( + "test-a", + executable_path="/install/path-a/homework_helper.exe", + ) + first_key = first._shared_memory.key() + first.cleanup() + + second = instance_manager.SingleInstanceApplication( + "test-b", + executable_path="/install/path-b/homework_helper.exe", + ) + second_key = second._shared_memory.key() + second.cleanup() + + assert first_key != second_key + + +def test_sender_routes_path_a_and_b_to_different_servers(monkeypatch): + clients = [] + + class Client: + def __init__(self): + self.server_name = None + self.payload = b"" + + def connectToServer(self, name): + self.server_name = name + + def waitForConnected(self, _timeout): + return False + + def client_factory(): + client = Client() + clients.append(client) + return client + + monkeypatch.setattr(instance_manager, "QLocalSocket", client_factory) + + assert instance_manager.send_instance_command( + "show_window", + executable_path="/install/path-a/homework_helper.exe", + ) == instance_manager.InstanceCommandResult.NO_RUNNING_INSTANCE + assert instance_manager.send_instance_command( + "show_window", + executable_path="/install/path-b/homework_helper.exe", + ) == instance_manager.InstanceCommandResult.NO_RUNNING_INSTANCE + + assert clients[0].server_name != clients[1].server_name + + +def test_sender_treats_ack_for_other_path_as_unsafe_target(monkeypatch): + path_a = "/install/path-a/homework_helper.exe" + path_b = "/install/path-b/homework_helper.exe" + identity_b = instance_manager.instance_identity(path_b) + + class Client: + def connectToServer(self, _name): + return None + + def waitForConnected(self, _timeout): + return True + + def write(self, _payload): + return None + + def waitForBytesWritten(self, _timeout): + return True + + def waitForReadyRead(self, _timeout): + return True + + def readAll(self): + return f"ack:{identity_b.digest}:show_window\n".encode("utf-8") + + def disconnectFromServer(self): + return None + + monkeypatch.setattr(instance_manager, "QLocalSocket", Client) + + result = instance_manager.send_instance_command( + "show_window", + executable_path=path_a, + ) + + assert result == instance_manager.InstanceCommandResult.UNSAFE_TARGET + + +def test_ipc_server_retry_never_falls_back_to_global_unscoped_key(): + source = Path("src/core/instance_manager.py").read_text(encoding="utf-8") + + assert ".listen(APP_UNIQUE_KEY)" not in source + assert ".removeServer(APP_UNIQUE_KEY)" not in source diff --git a/tests/test_instance_manager_ipc.py b/tests/test_instance_manager_ipc.py new file mode 100644 index 00000000..ac096ec6 --- /dev/null +++ b/tests/test_instance_manager_ipc.py @@ -0,0 +1,173 @@ +from src.core import instance_manager + + +class _FakeSocket: + def __init__(self, payload: bytes): + self.payload = payload + self._hh_received_command = False + self._hh_command_buffer = bytearray() + self.responses = [] + self.disconnected = False + + def readAll(self): + payload, self.payload = self.payload, b"" + return payload + + def write(self, payload): + self.responses.append(bytes(payload)) + + def flush(self): + return True + + def disconnectFromServer(self): + self.disconnected = True + + def abort(self): + self.disconnected = True + + def deleteLater(self): + return None + + +class _Window: + def __init__(self): + self.shown = 0 + + def activate_and_show(self): + self.shown += 1 + + +class _FakeClientSocket: + def __init__(self, *, connected=True, written=True, ready=True, response=None): + self.connected = connected + self.written = written + self.ready = ready + self.response = response + self.payload = b"" + self.aborted = False + self.server_name = None + + def connectToServer(self, name): + self.server_name = name + + def waitForConnected(self, _timeout): + return self.connected + + def write(self, payload): + self.payload = bytes(payload) + + def waitForBytesWritten(self, _timeout): + return self.written + + def waitForReadyRead(self, _timeout): + return self.ready + + def readAll(self): + if self.response is None: + identity, command = instance_manager.parse_instance_message(self.payload) + return f"ack:{identity}:{command.value}\n".encode("utf-8") + return self.response + + def disconnectFromServer(self): + return None + + def abort(self): + self.aborted = True + + +def _manager(window): + manager = instance_manager.SingleInstanceApplication("test") + manager._main_window_ref = window + return manager + + +def test_parse_instance_message_accepts_only_versioned_show_window(): + identity = instance_manager.instance_identity() + + assert instance_manager.parse_instance_message( + instance_manager.encode_instance_command(instance_manager.InstanceCommand.SHOW_WINDOW, identity) + ) == (identity.digest, instance_manager.InstanceCommand.SHOW_WINDOW) + assert instance_manager.parse_instance_message(b"show_window\n") == (None, None) + assert instance_manager.parse_instance_message(b"HHIPC1 wrong unsupported_command\n") == ("wrong", None) + + +def test_command_sender_uses_path_scoped_identity(monkeypatch): + client = _FakeClientSocket() + monkeypatch.setattr(instance_manager, "QLocalSocket", lambda: client) + + result = instance_manager.send_instance_command(instance_manager.InstanceCommand.SHOW_WINDOW) + + assert result == instance_manager.InstanceCommandResult.SUCCESS + expected_identity = instance_manager.instance_identity() + assert client.server_name == expected_identity.server_name + assert client.payload == instance_manager.encode_instance_command( + instance_manager.InstanceCommand.SHOW_WINDOW, + expected_identity, + ) + assert int(instance_manager.send_instance_command("unknown")) == 5 + + +def test_command_sender_distinguishes_missing_server_and_ack_timeout(monkeypatch): + missing = _FakeClientSocket(connected=False) + monkeypatch.setattr(instance_manager, "QLocalSocket", lambda: missing) + assert int(instance_manager.send_instance_command("show_window")) == 3 + + timeout = _FakeClientSocket(ready=False) + monkeypatch.setattr(instance_manager, "QLocalSocket", lambda: timeout) + assert int(instance_manager.send_instance_command("show_window")) == 4 + assert timeout.aborted is True + + +def test_ipc_server_dispatches_show_window_and_acknowledges(): + window = _Window() + manager = _manager(window) + socket = _FakeSocket( + instance_manager.encode_instance_command(instance_manager.InstanceCommand.SHOW_WINDOW, manager._identity) + ) + manager._active_client_sockets.add(socket) + + manager._read_ipc_message(socket) + + assert window.shown == 1 + assert socket.responses == [f"ack:{manager._identity.digest}:show_window\n".encode("utf-8")] + assert socket.disconnected is True + manager.cleanup() + + +def test_ipc_server_rejects_unknown_command(): + window = _Window() + manager = _manager(window) + socket = _FakeSocket(b"unsupported_command\n") + manager._active_client_sockets.add(socket) + + manager._read_ipc_message(socket) + + assert socket.responses == [b"error:unsafe_target\n"] + assert window.shown == 0 + manager.cleanup() + + +def test_payloadless_connection_never_activates_window(): + window = _Window() + manager = _manager(window) + socket = _FakeSocket(b"") + manager._active_client_sockets.add(socket) + + manager._finish_ipc_connection(socket) + + assert window.shown == 0 + manager.cleanup() + + +def test_cleanup_marks_connections_inactive_before_abort(): + window = _Window() + manager = _manager(window) + socket = _FakeSocket(b"") + manager._active_client_sockets.add(socket) + + manager.cleanup() + manager._finish_ipc_connection(socket) + + assert manager._cleanup_done is True + assert socket._hh_received_command is True + assert window.shown == 0 diff --git a/tests/test_launcher_launch_args.py b/tests/test_launcher_launch_args.py new file mode 100644 index 00000000..8e4e70a1 --- /dev/null +++ b/tests/test_launcher_launch_args.py @@ -0,0 +1,95 @@ +from types import SimpleNamespace + +from src.core import launcher as launcher_module + + +def test_launch_target_accepts_args_only_for_direct_targets(): + assert launcher_module.launch_target_accepts_args("C:/Games/ZZZ.exe") is True + assert launcher_module.launch_target_accepts_args("/Applications/ZZZ.app") is True + assert launcher_module.launch_target_accepts_args("C:/Games/ZZZ.lnk") is False + assert launcher_module.launch_target_accepts_args("C:/Games/ZZZ.url") is False + assert launcher_module.launch_target_accepts_args("steam://run/1234") is False + assert launcher_module.launch_target_accepts_args("microsoft-edge:") is False + assert launcher_module.launch_target_accepts_args("shell:AppsFolder\\Game") is False + assert launcher_module.launch_target_accepts_args("mailto:user@example.test") is False + assert launcher_module.launch_target_accepts_args("") is False + assert launcher_module.launch_target_accepts_args(None) is False + + +def test_launcher_passes_launch_args_as_windows_shell_execute_params(monkeypatch): + calls = [] + + class _FakeShell32: + def IsUserAnAdmin(self): + return False + + def ShellExecuteW(self, hwnd, verb, file, params, directory, show): + calls.append((hwnd, verb, file, params, directory, show)) + return 33 + + monkeypatch.setattr(launcher_module.os, "name", "nt") + monkeypatch.setattr( + launcher_module.ctypes, + "windll", + SimpleNamespace(shell32=_FakeShell32()), + raising=False, + ) + + assert launcher_module.Launcher(run_as_admin=False).launch_process( + "C:/Games/ZenlessZoneZero.exe", + args=" -use-d3d12 ", + ) is True + + assert calls == [ + (None, "open", "C:/Games/ZenlessZoneZero.exe", "-use-d3d12", None, 1) + ] + + +def test_launcher_passes_launch_args_as_posix_popen_list(monkeypatch): + calls = [] + + class _FakePopen: + def __init__(self, args): + calls.append(args) + + monkeypatch.setattr(launcher_module.os, "name", "posix") + monkeypatch.setattr(launcher_module.subprocess, "Popen", _FakePopen) + + assert launcher_module.Launcher().launch_process( + "/Applications/ZenlessZoneZero.app", + args='-use-d3d12 --profile "alpha test"', + ) is True + + assert calls == [["/Applications/ZenlessZoneZero.app", "-use-d3d12", "--profile", "alpha test"]] + + +def test_launcher_preserves_posix_command_string_without_extra_args(monkeypatch): + calls = [] + + class _FakePopen: + def __init__(self, args): + calls.append(args) + + monkeypatch.setattr(launcher_module.os, "name", "posix") + monkeypatch.setattr(launcher_module.subprocess, "Popen", _FakePopen) + + assert launcher_module.Launcher().launch_process("python -m homework_helper") is True + + assert calls == [["python", "-m", "homework_helper"]] + + +def test_launcher_keeps_existing_posix_target_path_with_spaces_when_args_are_added(monkeypatch, tmp_path): + calls = [] + app_path = tmp_path / "Zenless Zone Zero.app" + app_path.mkdir() + + class _FakePopen: + def __init__(self, args): + calls.append(args) + + monkeypatch.setattr(launcher_module.os, "name", "posix") + monkeypatch.setattr(launcher_module.subprocess, "Popen", _FakePopen) + + assert launcher_module.Launcher().launch_process(str(app_path), args="-use-d3d12") is True + + assert calls == [[str(app_path), "-use-d3d12"]] diff --git a/tests/test_pyside_runtime_and_presentation.py b/tests/test_pyside_runtime_and_presentation.py new file mode 100644 index 00000000..37563935 --- /dev/null +++ b/tests/test_pyside_runtime_and_presentation.py @@ -0,0 +1,437 @@ +import os +from types import SimpleNamespace + +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PySide6.QtCore import QEventLoop, QObject, QThread, QTimer, Signal, Slot +from PySide6.QtGui import QColor +from PySide6.QtWidgets import QApplication, QMainWindow, QPushButton, QToolButton, QVBoxLayout, QWidget +from shiboken6 import Shiboken + +from src.data.data_models import ManagedProcess +from src.gui.presentation import PresentationController, resolve_ui_renderer +from src.gui.qt_runtime import binding_diagnostics, is_qobject_valid, require_object_thread +from src.gui.sidebar.sidebar_widget import SidebarWidget +from src.gui.volume_panel import _MUTE_BTN_STYLE +from src.gui.widgets_style import ( + CapsuleProgressBar, + apply_modern_widgets_style, + apply_sidebar_widgets_style, + apply_widgets_palette, + widgets_theme_tokens, +) + + +def _qapp(): + return QApplication.instance() or QApplication([]) + + +def test_runtime_reports_only_pyside6_binding(): + diagnostics = binding_diagnostics("widgets") + assert diagnostics["ui_binding"] == "pyside6" + assert diagnostics["binding_version"].startswith("6.11.") + assert diagnostics["qt_version"].startswith("6.11.") + assert diagnostics["ui_variant"] == "newgui-2nd" + + +def test_qobject_validity_and_thread_guard_follow_shiboken_lifetime(): + _qapp() + owner = QObject() + assert is_qobject_valid(owner) + require_object_thread(owner, "test") + Shiboken.delete(owner) + assert not is_qobject_valid(owner) + with pytest.raises(RuntimeError, match="no longer valid"): + require_object_thread(owner, "test") + + +def test_renderer_selection_defaults_to_widgets_and_accepts_qml(monkeypatch): + monkeypatch.delenv("HH_UI_RENDERER", raising=False) + assert resolve_ui_renderer([]) == "widgets" + assert resolve_ui_renderer(["--ui-renderer=qml"]) == "qml" + monkeypatch.setenv("HH_UI_RENDERER", "qml") + assert resolve_ui_renderer([]) == "qml" + with pytest.raises(ValueError, match="지원하지 않는"): + resolve_ui_renderer(["--ui-renderer=web"]) + + +def test_modern_widgets_style_marks_surface_and_primary_action(): + _qapp() + window = QMainWindow() + central = QWidget(window) + central.setLayout(QVBoxLayout()) + window.setCentralWidget(central) + window.add_game_button = QPushButton("새 게임 추가", central) + central.layout().addWidget(window.add_game_button) + apply_modern_widgets_style(window, dark=False) + assert central.objectName() == "hhMainSurface" + assert window.add_game_button.property("hhRole") == "primaryAction" + assert "#f3f3f3" in window.styleSheet() + + +def test_widgets_theme_uses_neutral_accents_and_stable_button_padding(): + dark = widgets_theme_tokens(True) + light = widgets_theme_tokens(False) + assert dark["accent"] == "#2b2b2f" + assert light["accent"] == "#dedee2" + + _qapp() + window = QMainWindow() + central = QWidget(window) + central.setLayout(QVBoxLayout()) + window.setCentralWidget(central) + window.add_game_button = QPushButton("새 게임 추가", central) + central.layout().addWidget(window.add_game_button) + apply_modern_widgets_style(window, dark=True) + style = window.styleSheet() + assert "QPushButton:pressed" in style + assert "background: #3a3a3e" in style + assert "padding: 0px 8px" in style + assert "#6ea8fe" not in style + assert "#2563eb" not in style + + +def _button_background(button: QPushButton | QToolButton) -> QColor: + image = button.grab().toImage() + dpr = image.devicePixelRatio() + x = min(round(4 * dpr), image.width() - 1) + y = min(round(4 * dpr), image.height() - 1) + return image.pixelColor(x, y) + + +@pytest.mark.parametrize( + ("role_property", "role_value"), + [ + (None, None), + ("hhRole", "primaryAction"), + ("hhRole", "iconAction"), + ("hhState", "success"), + ("hhState", "danger"), + ], +) +def test_main_button_roles_render_distinct_pressed_feedback(role_property, role_value): + app = _qapp() + window = QMainWindow() + central = QWidget(window) + layout = QVBoxLayout(central) + window.setCentralWidget(central) + button = QPushButton("확인", central) + if role_property is not None: + button.setProperty(role_property, role_value) + layout.addWidget(button) + apply_widgets_palette(dark=True) + apply_modern_widgets_style(window, dark=True) + window.show() + app.processEvents() + + normal = _button_background(button) + button.setDown(True) + button.update() + app.processEvents() + pressed = _button_background(button) + button.setDown(False) + button.update() + app.processEvents() + + assert pressed != normal + assert _button_background(button) == normal + if role_value == "iconAction": + assert button.size().toTuple() == (30, 30) + assert button.size() == button.sizeHint() + window.close() + + +def test_checked_tool_button_keeps_pressed_feedback(): + app = _qapp() + window = QMainWindow() + central = QWidget(window) + layout = QVBoxLayout(central) + window.setCentralWidget(central) + button = QToolButton(central) + button.setCheckable(True) + button.setChecked(True) + layout.addWidget(button) + apply_widgets_palette(dark=True) + apply_modern_widgets_style(window, dark=True) + window.show() + app.processEvents() + + checked = _button_background(button) + button.setDown(True) + button.update() + app.processEvents() + + assert _button_background(button) != checked + window.close() + + +@pytest.mark.parametrize("checked", [False, True]) +def test_volume_popover_mute_button_renders_pressed_feedback(checked): + app = _qapp() + button = QPushButton() + button.setCheckable(True) + button.setChecked(checked) + button.setFixedSize(28, 28) + button.setStyleSheet(_MUTE_BTN_STYLE) + button.show() + app.processEvents() + + normal = _button_background(button) + button.setDown(True) + button.update() + app.processEvents() + + assert _button_background(button) != normal + button.close() + + +@pytest.mark.parametrize("role", ["danger", "primaryAction", "folderAction"]) +def test_sidebar_button_roles_render_distinct_pressed_feedback(role): + app = _qapp() + root = QWidget() + layout = QVBoxLayout(root) + button = QPushButton("동작", root) + button.setProperty("hhRole", role) + layout.addWidget(button) + apply_sidebar_widgets_style(root, dark=True) + root.show() + app.processEvents() + + normal = _button_background(button) + button.setDown(True) + button.update() + app.processEvents() + + assert _button_background(button) != normal + root.close() + + +def test_sidebar_mute_button_uses_blue_checked_state_and_unclipped_focus_border(): + app = _qapp() + root = QWidget() + layout = QVBoxLayout(root) + button = QPushButton(root) + button.setProperty("hhRole", "muteToggle") + button.setCheckable(True) + layout.addWidget(button) + apply_sidebar_widgets_style(root, dark=True) + root.show() + app.processEvents() + + button.setChecked(True) + button.clearFocus() + button.update() + app.processEvents() + checked = _button_background(button) + assert checked.blue() > checked.red() + assert checked.blue() > checked.green() + assert button.size().toTuple() == (22, 22) + assert button.size() == button.sizeHint() + + button.setDown(True) + button.update() + app.processEvents() + assert _button_background(button) != checked + button.setDown(False) + button.setFocus() + button.update() + app.processEvents() + focused = button.grab().toImage() + edge_points = ( + (focused.width() // 2, 0), + (focused.width() - 1, focused.height() // 2), + (focused.width() // 2, focused.height() - 1), + (0, focused.height() // 2), + ) + # Fractional DPI에서는 오른쪽/아래 1px이 focus 색과 배경의 안티앨리어싱 혼합색입니다. + assert all( + focused.pixelColor(x, y).lightness() > checked.lightness() + for x, y in edge_points + ) + root.close() + + +def test_sidebar_volume_row_does_not_mask_checked_mute_background(monkeypatch): + from src.gui.main_window import MainWindow + + monkeypatch.setattr(MainWindow, "INSTANCE", None) + app = _qapp() + process = ManagedProcess( + id="game", + name="Game", + monitoring_path="game.exe", + launch_path="game.exe", + default_muted=True, + ) + data_manager = SimpleNamespace( + global_settings=SimpleNamespace(sidebar_volume_section_enabled=True), + managed_processes=[process], + ) + sidebar = SidebarWidget(data_manager) + sidebar._refresh_volumes_list() + sidebar.show() + app.processEvents() + + mute_button = next( + button + for button in sidebar.findChildren(QPushButton) + if button.property("hhRole") == "muteToggle" + ) + checked = _button_background(mute_button) + assert mute_button.isChecked() + assert checked.blue() > checked.red() + assert checked.blue() > checked.green() + sidebar.close() + + +@pytest.mark.parametrize( + ("value", "filled_x", "track_x"), + [ + (0, None, 2), + (1, 2, 6), + (10, 2, 6), + (500, 25, 55), + (1000, 98, None), + ], +) +def test_capsule_progress_bar_keeps_round_minimum_fill(value, filled_x, track_x): + app = _qapp() + apply_widgets_palette(dark=True) + bar = CapsuleProgressBar() + bar.setRange(0, 1000) + bar.setProperty("hhBucket", "low") + bar.resize(100, 6) + bar.setValue(value) + bar.show() + app.processEvents() + image = bar.grab().toImage() + tokens = widgets_theme_tokens(True) + dpr = image.devicePixelRatio() + + def logical_pixel(x: int, y: int) -> QColor: + return image.pixelColor( + min(round(x * dpr), image.width() - 1), + min(round(y * dpr), image.height() - 1), + ) + + if filled_x is not None: + assert logical_pixel(filled_x, 3) == QColor(tokens["success"]) + if track_x is not None: + assert logical_pixel(track_x, 3) == QColor(tokens["surface"]) + bar.close() + + +def test_capsule_progress_bar_uses_dark_track_under_real_main_qss(): + app = _qapp() + window = QMainWindow() + central = QWidget(window) + layout = QVBoxLayout(central) + window.setCentralWidget(central) + bar = CapsuleProgressBar(central) + bar.setRange(0, 1000) + bar.setValue(500) + bar.setFixedWidth(100) + layout.addWidget(bar) + apply_widgets_palette(dark=True) + apply_modern_widgets_style(window, dark=True) + window.show() + app.processEvents() + + image = bar.grab().toImage() + dpr = image.devicePixelRatio() + track = image.pixelColor( + min(round(80 * dpr), image.width() - 1), + min(round(3 * dpr), image.height() - 1), + ) + expected = QColor(widgets_theme_tokens(True)["surface"]) + assert track == expected + assert track.lightness() < QColor(widgets_theme_tokens(True)["surface_raised"]).lightness() + window.close() + + +def test_slot_receiver_runs_in_its_qobject_thread(): + app = _qapp() + thread = QThread() + + class Emitter(QObject): + fired = Signal() + + @Slot() + def emit_from_worker(self): + self.fired.emit() + + class Receiver(QObject): + received = Signal() + + def __init__(self): + super().__init__() + self.observed = None + + @Slot() + def receive(self): + self.observed = QThread.currentThread() + self.received.emit() + + emitter = Emitter() + receiver = Receiver() + emitter.moveToThread(thread) + emitter.fired.connect(receiver.receive) + received_loop = QEventLoop() + receiver.received.connect(received_loop.quit) + thread.started.connect(emitter.emit_from_worker) + try: + thread.start() + if receiver.observed is None: + QTimer.singleShot(5_000, received_loop.quit) + received_loop.exec() + assert receiver.observed is not None + finally: + thread.quit() + assert thread.wait(1_000) + assert receiver.observed == receiver.thread() + + +class _RefreshTimer(QObject): + timeout = Signal() + + +class _FakeWindow(QObject): + request_table_refresh_signal = Signal() + + def __init__(self): + super().__init__() + self.ui_refresh_timer = _RefreshTimer(self) + self.data_manager = SimpleNamespace( + managed_processes=[], + global_settings=SimpleNamespace(), + ) + self.scheduler = SimpleNamespace(determine_process_visual_status=lambda *_args: "대기") + self.presentation = None + + def _calculate_progress_percentage(self, *_args): + return 0.0, "" + + def _is_effective_dark_theme(self): + return False + + def set_presentation_window(self, window, facade=None): + self.presentation = (window, facade) + + def hide(self): + return None + + +def test_qml_candidate_loads_as_independent_quick_window(): + app = _qapp() + owner = _FakeWindow() + controller = PresentationController(owner, "qml") + assert controller.engine is not None + assert controller.window is not owner + assert owner.presentation[0] is controller.window + controller.show() + app.processEvents() + assert controller.window.isVisible() + controller.window.hide() + controller.shutdown() diff --git a/tests/test_qt_binding_boundary.py b/tests/test_qt_binding_boundary.py new file mode 100644 index 00000000..4d6d0511 --- /dev/null +++ b/tests/test_qt_binding_boundary.py @@ -0,0 +1,28 @@ +from pathlib import Path + + +def test_product_runtime_contains_no_pyqt_or_sip_dependency(): + files = list(Path("src").rglob("*.py")) + [Path("homework_helper.pyw")] + forbidden = ("PyQt6", "pyqtSignal", "pyqtSlot", "from PySide6 import sip") + violations = [] + for path in files: + source = path.read_text(encoding="utf-8-sig") + for token in forbidden: + if token in source: + violations.append(f"{path}:{token}") + assert violations == [] + + +def test_pyinstaller_keeps_qml_candidate_opt_in_and_collects_network_module(): + spec = Path("homework_helper.spec").read_text(encoding="utf-8") + assert "HH_INCLUDE_QML" in spec + assert "qml_hiddenimports" in spec + assert "qml_excludes" in spec + for module in ( + "PySide6.QtWidgets", + "PySide6.QtNetwork", + "PySide6.QtQml", + "PySide6.QtQuick", + "PySide6.QtQuickControls2", + ): + assert module in spec diff --git a/tests/test_remote_macos_client_static.py b/tests/test_remote_macos_client_static.py index f928c608..5bc1218f 100644 --- a/tests/test_remote_macos_client_static.py +++ b/tests/test_remote_macos_client_static.py @@ -96,6 +96,8 @@ def test_macos_models_track_remote_agent_snake_case_contract(): 'monitoringPath = "monitoring_path"', 'launchPath = "launch_path"', 'preferredLaunchType = "preferred_launch_type"', + 'launchArgsEnabled = "launch_args_enabled"', + 'launchArgs = "launch_args"', 'userCycleHours = "user_cycle_hours"', 'schemaVersion = "schema_version"', 'baseValue = "base_value"', @@ -323,35 +325,38 @@ def test_macos_popover_first_ui_preserves_remote_capabilities_contract(): status_click_source = app.split("@objc func statusItemClicked", 1)[1].split("func clickStatusItemForUITest", 1)[0] show_popover_source = app.split("private func showPopoverFromStatusItem()", 1)[1].split("private func togglePopover", 1)[0] show_primary_source = app.split("static func showPrimaryInterface()", 1)[1].split("static func showUITestMainWindow", 1)[0] - assert "openSettingsWindow" not in status_click_source - assert "openSettingsWindow" not in show_popover_source - assert "openSettingsWindow" not in show_primary_source - assert "enum SettingsOpenSource" in app - assert "case popoverButton" in app - assert "case popoverShortcut" in app - assert "case uiTest" in app - settings_open_source = app.split("static func openSettingsWindow(source: SettingsOpenSource)", 1)[1].split("static func prepareSettingsWindow", 1)[0] - assert "guard source == .uiTest || shared?.popover.isShown == true else { return }" in settings_open_source - assert "beginExplicitSettingsOpen()" in settings_open_source + assert "showSettingsWindow" not in status_click_source + assert "showSettingsWindow" not in show_popover_source + assert "showSettingsWindow" not in show_primary_source + assert "private var settingsWindow: NSWindow?" in app + assert "private static var settingsOpener: (@MainActor () -> Void)?" in app + assert "private static var pendingSettingsOpen = false" in app + assert "static func showSettingsWindow()" in app + assert "static func installSettingsOpener" in app + assert "static func registerSettingsWindow(_ window: NSWindow)" in app + assert "private func presentSettingsWindow()" in app + assert "private func makeSettingsWindow() -> NSWindow" not in app + settings_open_source = app.split("private func presentSettingsWindow()", 1)[1].split("static func hideSettingsWindow", 1)[0] + assert "closePopoverForFocusLoss()" in settings_open_source assert "NSApp.setActivationPolicy(.accessory)" in settings_open_source - assert "focusExistingSettingsWindow()" in settings_open_source - assert "guard isExplicitSettingsOpenPending() else { return }" in settings_open_source - assert "guard NSApp.windows.contains(where:" not in settings_open_source + assert "if let settingsWindow" in settings_open_source + assert "settingsWindow.makeKeyAndOrderFront(nil)" in settings_open_source + assert "settingsWindow.orderFrontRegardless()" in settings_open_source + assert "guard let settingsOpener = Self.settingsOpener" in settings_open_source + assert "settingsOpener()" in settings_open_source + assert "Self.pendingSettingsOpen = true" in settings_open_source + assert "guard pendingSettingsOpen else { return }" in app + assert "pendingSettingsOpen = false" in app + assert "popover.isShown" not in settings_open_source + assert "NSHostingController(rootView: RemoteSettingsView" not in app assert "static let settingsWindowIdentifier" in app - assert "static let settingsWindowTitle" in app - assert "static func prepareSettingsWindow(_ window: NSWindow)" in app assert "static func hideSettingsWindow(_ window: NSWindow?)" in app - assert "restoreAccessoryIfNoVisibleUserWindows()" in app - assert "private static func focusExistingSettingsWindow() -> Bool" in app - assert "private static func settingsWindows() -> [NSWindow]" in app - assert "private static func isVisibleUserWindow(_ window: NSWindow) -> Bool" in app - assert "private static func beginExplicitSettingsOpen()" in app - assert "private static func isExplicitSettingsOpenPending() -> Bool" in app - assert "private static func clearExplicitSettingsOpen()" in app + assert "private static func settingsWindows() -> [NSWindow]" not in app + assert "explicitSettingsOpen" not in app assert "installPopoverKeyDownMonitor()" in app assert "removePopoverKeyDownMonitor()" in app assert "event.keyCode == 43 && event.modifierFlags.contains(.command)" in app - assert "openSettingsWindow(source: .popoverShortcut)" in app + assert "Self.showSettingsWindow()" in app assert "NSPopover" in app assert "RemoteMenuBarPopoverPanel" not in app assert "MenuBarPopoverView" in app @@ -365,7 +370,7 @@ def test_macos_popover_first_ui_preserves_remote_capabilities_contract(): assert "RemotePlaceholderWindowAccessor" in app assert "schedulePlaceholderHide()" in app assert "Window(RemoteAppDelegate.placeholderWindowTitle, id: RemoteAppDelegate.placeholderWindowIdentifier)" in app - scene_source = app.split("var body: some Scene", 1)[1].split("Settings {", 1)[0] + scene_source = app.split("var body: some Scene", 1)[1].split("struct GameIconView", 1)[0] assert "RemoteDashboardView(viewModel" not in scene_source assert "SidebarCommands()" not in scene_source assert "homeworkHelperRemoteToggleSidebar" not in scene_source @@ -373,19 +378,18 @@ def test_macos_popover_first_ui_preserves_remote_capabilities_contract(): assert "창 열기" not in app assert "창 숨기기" not in app assert ".keyboardShortcut(\"r\", modifiers: .command)" in app - assert ".keyboardShortcut(\",\", modifiers: .command)" not in app - assert 'Button("설정…")' not in app + assert ".keyboardShortcut(\",\", modifiers: .command)" in app + assert 'Button("설정…")' in app assert "CommandGroup(replacing: .appSettings)" in app - assert "RemoteAppDelegate.openSettingsWindow()" not in app - assert "RemoteAppDelegate.openSettingsWindow(source: .popoverButton)" in app + assert app.count("RemoteAppDelegate.showSettingsWindow()") >= 3 assert "SettingsLink" not in app assert "RemoteSettingsOpenBridge" in app assert "@Environment(\\.openSettings)" in app - assert "homeworkHelperRemoteOpenSettings" in app - assert "NotificationCenter.default.post(name: .homeworkHelperRemoteOpenSettings" in app + assert "RemoteAppDelegate.installSettingsOpener" in app assert "openSettings()" in app - assert 'Selector(("showSettingsWindow:"))' in app - assert 'Selector(("showPreferencesWindow:"))' in app + assert "homeworkHelperRemoteOpenSettings" not in app + assert 'Selector(("showSettingsWindow:"))' not in app + assert 'Selector(("showPreferencesWindow:"))' not in app assert "GlassEffectContainer" in app assert "RemoteAppKitLiquidGlassBackground" in liquid_glass @@ -500,7 +504,7 @@ def test_macos_popover_first_ui_preserves_remote_capabilities_contract(): assert ".menuBarHoverTint(disabled: disabled)" not in app.split("struct MenuBarMoonlightButton", 1)[1].split("struct PlaySummaryView", 1)[0] assert ".labelStyle(.iconOnly)" not in app - assert "Settings {" in app + assert "\n Settings {" in app assert "RemoteSettingsView" in app assert "RemoteSettingsTab" in app assert "TabView(selection: $selectedTab)" in app @@ -526,7 +530,8 @@ def test_macos_popover_first_ui_preserves_remote_capabilities_contract(): assert "static let contentWidth: CGFloat = 392" in app assert "static let maxWindowWidth: CGFloat = 480" in app assert "measured.width * 1.06" in app - assert "measured.height * 1.10" in app + assert "static let windowVerticalInset: CGFloat = 24" in app + assert "let paddedHeight = measured.height + RemoteSettingsLayout.windowVerticalInset" in app assert "SettingsActionGrid" in app assert ".toggleStyle(.switch)" in app assert 'SettingsToggleRow(title: "플레이 요약 표시", isOn: $viewModel.showPlaySummary)' in app @@ -542,14 +547,14 @@ def test_macos_popover_first_ui_preserves_remote_capabilities_contract(): assert "RemoteSettingsWindowAccessor(targetSize: targetSize)" in app assert "RemoteSettingsKeyboardShortcutBridge" in app assert "RemoteSettingsWindowDelegate" in window_accessor - assert "RemoteAppDelegate.prepareSettingsWindow(window)" in window_accessor settings_window_accessor_source = window_accessor.split("struct RemoteSettingsWindowAccessor", 1)[1].split("struct RemoteSettingsKeyboardShortcutBridge", 1)[0] assert "makeKeyAndOrderFront" not in settings_window_accessor_source assert "orderFrontRegardless" not in settings_window_accessor_source assert "NSApp.activate" not in settings_window_accessor_source - prepare_settings_source = app.split("static func prepareSettingsWindow(_ window: NSWindow)", 1)[1].split("static func hideSettingsWindow", 1)[0] - assert "guard isExplicitSettingsOpenPending() else { return }" in prepare_settings_source - assert "focusSettingsWindow(prepared)" in prepare_settings_source + assert "window.identifier = NSUserInterfaceItemIdentifier(RemoteAppDelegate.settingsWindowIdentifier)" in settings_window_accessor_source + assert "window.title =" not in settings_window_accessor_source + assert "window.isReleasedWhenClosed = false" in settings_window_accessor_source + assert "RemoteAppDelegate.registerSettingsWindow(window)" in settings_window_accessor_source assert "RemoteAppDelegate.hideSettingsWindow(sender)" in window_accessor assert "RemoteAppDelegate.hideSettingsWindow(NSApp.keyWindow)" in app settings_keyboard_source = window_accessor.split("struct RemoteSettingsKeyboardShortcutBridge", 1)[1] @@ -970,6 +975,9 @@ def test_macos_popover_first_ui_preserves_remote_capabilities_contract(): assert "trackBadgeDisplayText" in view_model assert "startLocalProgressTicker" in view_model assert "processWithLocalProgress" in view_model + progress_copy_source = view_model.split("private static func processWithLocalProgress", 1)[1].split("private static func locallyPlayedToday", 1)[0] + assert "launchArgsEnabled: process.launchArgsEnabled" in progress_copy_source + assert "launchArgs: process.launchArgs" in progress_copy_source assert "allowProjection: false" in view_model assert 'existing?.source == "server_tracked"' in view_model assert "projectedProgress(from:" in view_model diff --git a/tests/test_remote_onboarding_tools.py b/tests/test_remote_onboarding_tools.py index 8d9fac80..f8ece095 100644 --- a/tests/test_remote_onboarding_tools.py +++ b/tests/test_remote_onboarding_tools.py @@ -109,6 +109,26 @@ def __init__(self): assert kwargs['startupinfo'].wShowWindow == 0 +def test_tailscale_cli_output_is_decoded_as_utf8(): + import src.core.tailscale as tailscale + + captured = {} + + class Result: + returncode = 0 + stdout = '' + stderr = '' + + def runner(_args, **kwargs): + captured.update(kwargs) + return Result() + + tailscale._run_subprocess(['tailscale', 'status', '--json'], timeout_seconds=1, runner=runner) + + assert captured['encoding'] == 'utf-8' + assert captured['errors'] == 'replace' + + def test_windows_tailscale_executable_uses_installed_programfiles_path(monkeypatch): import src.core.tailscale as tailscale diff --git a/tests/test_remote_routes.py b/tests/test_remote_routes.py index 0896e80b..e937b835 100644 --- a/tests/test_remote_routes.py +++ b/tests/test_remote_routes.py @@ -22,9 +22,11 @@ class _FakeLauncher: def __init__(self): self.targets: list[str] = [] + self.launches: list[tuple[str, str | None]] = [] - def launch_process(self, target: str) -> bool: + def launch_process(self, target: str, args=None) -> bool: self.targets.append(target) + self.launches.append((target, args)) return True @@ -209,8 +211,10 @@ def test_remote_launch_uses_shortcut_preference_and_existing_launcher_logic_boun assert body["accepted_at"] assert body["refresh_after_ms"] == 750 assert launcher.targets == ["/Users/me/Desktop/Game.url"] + assert launcher.launches == [("/Users/me/Desktop/Game.url", None)] assert auditor.events[-1]["command"] == "process.launch.shortcut" assert auditor.events[-1]["accepted"] is True + assert auditor.events[-1]["metadata"] == {"mode": "shortcut", "launch_args_applied": False} def test_remote_stop_terminates_only_managed_process_boundary(): @@ -696,7 +700,79 @@ def test_remote_launch_can_request_direct_mode_without_mutating_process_preferen assert body["command_id"].startswith("process.launch.direct:") assert body["refresh_after_ms"] == 750 assert launcher.targets == ["/Applications/Game.app"] - assert auditor.events[-1]["metadata"] == {"mode": "direct"} + assert launcher.launches == [("/Applications/Game.app", None)] + assert auditor.events[-1]["metadata"] == {"mode": "direct", "launch_args_applied": False} + + +def test_remote_launch_direct_mode_applies_saved_process_launch_args(): + client, launcher, _opened_urls, auditor, _registry = _client_with_seed( + processes=[ + models.Process( + id="zzz", + name="Zenless Zone Zero", + monitoring_path="/Applications/ZenlessZoneZero.app", + launch_path="/Users/me/Desktop/ZenlessZoneZero.url", + preferred_launch_type="direct", + launch_args_enabled=True, + launch_args=" -use-d3d12 ", + ) + ] + ) + + response = client.post("/remote/processes/zzz/launch", json={}) + + assert response.status_code == 200 + body = response.json() + assert body["command"] == "process.launch.direct" + assert body["target"] == "/Applications/ZenlessZoneZero.app" + assert launcher.launches == [("/Applications/ZenlessZoneZero.app", "-use-d3d12")] + assert auditor.events[-1]["metadata"] == {"mode": "direct", "launch_args_applied": True} + + +def test_remote_launch_ignores_saved_args_for_shortcut_url_targets(): + client, launcher, _opened_urls, auditor, _registry = _client_with_seed( + processes=[ + models.Process( + id="game-a", + name="Game A", + monitoring_path="/Applications/Game.app", + launch_path="/Users/me/Desktop/Game.url", + preferred_launch_type="shortcut", + launch_args_enabled=True, + launch_args="-use-d3d12", + ) + ] + ) + + response = client.post("/remote/processes/game-a/launch", json={}) + + assert response.status_code == 200 + assert response.json()["command"] == "process.launch.shortcut" + assert launcher.launches == [("/Users/me/Desktop/Game.url", None)] + assert auditor.events[-1]["metadata"] == {"mode": "shortcut", "launch_args_applied": False} + + +def test_remote_launch_applies_saved_args_for_shortcut_mode_direct_executable_target(): + client, launcher, _opened_urls, auditor, _registry = _client_with_seed( + processes=[ + models.Process( + id="zzz", + name="Zenless Zone Zero", + monitoring_path="/Applications/ZenlessZoneZero.app", + launch_path="/Applications/ZenlessZoneZero.app", + preferred_launch_type="shortcut", + launch_args_enabled=True, + launch_args="-use-d3d12", + ) + ] + ) + + response = client.post("/remote/processes/zzz/launch", json={}) + + assert response.status_code == 200 + assert response.json()["command"] == "process.launch.shortcut" + assert launcher.launches == [("/Applications/ZenlessZoneZero.app", "-use-d3d12")] + assert auditor.events[-1]["metadata"] == {"mode": "shortcut", "launch_args_applied": True} def test_remote_launch_launcher_mode_uses_preset_launcher_pattern(tmp_path): @@ -728,7 +804,8 @@ def test_remote_launch_launcher_mode_uses_preset_launcher_pattern(tmp_path): assert body["command"] == "process.launch.launcher" assert body["target"] == str(launcher_path) assert launcher.targets == [str(launcher_path)] - assert auditor.events[-1]["metadata"] == {"mode": "launcher"} + assert launcher.launches == [(str(launcher_path), None)] + assert auditor.events[-1]["metadata"] == {"mode": "launcher", "launch_args_applied": False} def test_remote_shortcut_open_delegates_to_native_opener_and_records_command_result(): @@ -1270,12 +1347,25 @@ def test_power_controller_reports_client_managed_status_without_actions(): assert restart_response.status_code == 404 -def test_remote_power_setup_reports_host_readiness_and_registers_public_key(): +def test_remote_power_setup_reports_host_readiness_and_registers_public_key(monkeypatch, tmp_path): client, _launcher, _opened_urls, auditor, _registry = _client_with_seed() - authorized_keys = Path(os.environ["HOME"]) / ".ssh" / "authorized_keys" - if authorized_keys.exists(): - authorized_keys.unlink() + authorized_keys = tmp_path / ".ssh" / "authorized_keys" + monkeypatch.setattr( + remote_power_setup, + "_effective_authorized_keys_target", + lambda *, runner=None: { + "path": authorized_keys, + "scope": "user", + "user_authorized_keys_path": authorized_keys, + "admin_authorized_keys_path": tmp_path / "administrators_authorized_keys", + "sshd_config_path": None, + "current_user_is_admin": False, + "current_user_admin_message": "test", + "sshd_config_admin_match": False, + "administrators_authorized_keys_active": False, + }, + ) setup = client.get("/remote/power/setup") key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEZha2VLZXlGb3JUZXN0T25seU5vdFJlYWw= macbook" registered = client.post("/remote/power/ssh-key", json={"public_key": key, "label": "MacBook"}) @@ -1382,7 +1472,19 @@ def test_removed_remote_smartthings_probe_api_is_not_exposed(): assert not any(event["command"] == "power.smartthings.devices" for event in auditor.events) -def test_remote_logging_config_and_purge_revoked_devices(): +def test_remote_logging_config_and_purge_revoked_devices(monkeypatch, tmp_path): + from src.core import remote_debug_log, remote_local_store + + isolated_store = remote_local_store.RemoteLocalStore( + root=tmp_path / "remote", + legacy_root=tmp_path, + ) + monkeypatch.setattr(remote_local_store, "_DEFAULT_STORE", isolated_store) + monkeypatch.setattr( + remote_debug_log, + "CONFIG_PATH", + isolated_store.path("remote_debug_logging.json"), + ) client, _launcher, _opened_urls, auditor, _registry = _client_with_seed() start = client.post("/remote/pair/start") diff --git a/tests/test_runtime_logging.py b/tests/test_runtime_logging.py new file mode 100644 index 00000000..a6119370 --- /dev/null +++ b/tests/test_runtime_logging.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import logging +from pathlib import Path + +from src.gui.runtime_logging import configure_gui_logging, redact_sensitive_text + + +def _remove_gui_handlers() -> None: + root = logging.getLogger() + for handler in tuple(root.handlers): + if getattr(handler, "_homework_helper_gui_rotating_handler", False): + root.removeHandler(handler) + handler.close() + + +def test_redact_sensitive_text_covers_headers_queries_and_provider_tokens(): + text = ( + "Authorization: Bearer abc.def\nCookie=session=secret\n" + "url=https://example.test/?access_token=query-secret\n" + "token=generic-secret\nltoken_v2=hoyo-secret\npassword='pw'" + ) + + redacted = redact_sensitive_text(text) + + for secret in ("abc.def", "session=secret", "query-secret", "generic-secret", "hoyo-secret", "pw"): + assert secret not in redacted + assert redacted.count("[REDACTED]") >= 5 + + +def test_configure_gui_logging_is_idempotent_and_rotates_with_redaction(tmp_path: Path): + _remove_gui_handlers() + root = logging.getLogger() + original_level = root.level + try: + first = configure_gui_logging(tmp_path, max_bytes=220, backup_count=5) + second = configure_gui_logging(tmp_path, max_bytes=220, backup_count=5) + assert first == second + assert sum( + bool(getattr(handler, "_homework_helper_gui_rotating_handler", False)) + for handler in root.handlers + ) == 1 + + test_logger = logging.getLogger("tests.gui.runtime") + for index in range(20): + test_logger.info("entry=%s token=top-secret-%s", "x" * 40, index) + for handler in root.handlers: + if getattr(handler, "_homework_helper_gui_rotating_handler", False): + handler.flush() + + files = sorted(first.parent.glob("gui.log*")) + assert 2 <= len(files) <= 6 + combined = "\n".join(path.read_text(encoding="utf-8") for path in files) + assert "top-secret" not in combined + assert "token=[REDACTED]" in combined + finally: + _remove_gui_handlers() + root.setLevel(original_level) + + +def test_gui_logging_starts_only_after_server_only_branch(): + source = Path("homework_helper.pyw").read_text(encoding="utf-8") + + server_branch = source.index("if _wants_server_only_mode():") + logging_setup = source.index("gui_log_path = configure_gui_logging()") + schema_migration = source.index("# === 스키마 자동 마이그레이션 ===") + + assert server_branch < logging_setup < schema_migration