From 150dc0ee41e362b7a77fc59e262dcace778a3c84 Mon Sep 17 00:00:00 2001 From: AmN <16545063+amnweb@users.noreply.github.com> Date: Sun, 1 Mar 2026 16:33:27 +0100 Subject: [PATCH 1/2] feat(service): add YASB launcher service management commands - Introduced a new `service` command to manage the YASB launcher service. - Added sub-commands for installing, starting, stopping, and removing the service. - Implemented a new service script to launch YASB at user login. - Updated CLI documentation to reflect new service management features. --- docs/CLI.md | 24 +++ src/build.py | 6 + src/cli.py | 41 +++++ src/core/utils/yasb_service.py | 273 +++++++++++++++++++++++++++++++++ 4 files changed, 344 insertions(+) create mode 100644 src/core/utils/yasb_service.py diff --git a/docs/CLI.md b/docs/CLI.md index 405f27eb8..b947ee742 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -19,6 +19,7 @@ The YASB CLI is a command line interface that allows you to interact with the YA - `set-channel` - Set the update channel (stable, dev). - `log` - Show the status bar logs in the terminal. - `reset` - Restore default config files and clear cache +- `service` - Manage the YASB launcher service (install, start, stop, remove). - `help` - Show the help message. ## Options @@ -77,6 +78,29 @@ To toggle the visibility of the status bar on a specific screen, use the followi yasbc toggle-bar --screen ``` +## Service +The YASB launcher service runs as a Windows service and can automatically start YASB on boot without requiring a logged-in user session or Task Scheduler. + +> **Note:** +> All `service` sub-commands require administrator privileges. Run your terminal as administrator. + +To install and start the service: +```bash +yasbc service install +``` +To start the service manually (after it has been installed): +```bash +yasbc service start +``` +To stop the service: +```bash +yasbc service stop +``` +To remove (uninstall) the service: +```bash +yasbc service remove +``` + ## Switch Update Channel To switch the update channel to dev, use the following command: ```bash diff --git a/src/build.py b/src/build.py index 84955b6c2..1b21a8b6c 100644 --- a/src/build.py +++ b/src/build.py @@ -113,6 +113,12 @@ copyright=f"Copyright (C) {datetime.datetime.now().year} AmN", target_name="yasbc", ), + Executable( + "core/utils/yasb_service.py", + base="Console", + copyright=f"Copyright (C) {datetime.datetime.now().year} AmN", + target_name="yasb_service", + ), ] setup( diff --git a/src/cli.py b/src/cli.py index 52dc7a769..ae9644cec 100644 --- a/src/cli.py +++ b/src/cli.py @@ -45,6 +45,7 @@ INSTALLATION_PATH = os.path.abspath(os.path.join(__file__, "../../..")) EXE_PATH = os.path.join(INSTALLATION_PATH, "yasb.exe") +SERVICE_EXE_PATH = os.path.join(INSTALLATION_PATH, "yasb_service.exe") AUTOSTART_FILE = EXE_PATH if os.path.exists(EXE_PATH) else None CLI_SERVER_PIPE_NAME = r"\\.\pipe\yasb_pipe_cli" @@ -346,6 +347,17 @@ def parse_arguments(self): add_help=False, ) + service_parser = subparsers.add_parser( + "service", + help="Manage the YASB launcher service", + prog="yasbc service", + ) + service_parser.add_argument( + "action", + choices=["install", "start", "stop", "remove"], + help="install: register the service and enable auto-start | start: start now | stop: stop | remove: uninstall", + ) + subparsers.add_parser( "help", help="Show help message", @@ -558,6 +570,8 @@ def parse_arguments(self): print(f"Failed to delete {fpath}: {e}") # Clear all files in app_data_folder if it exists + import tempfile + from core.utils.utilities import app_data_path app_data_folder = app_data_path() @@ -573,9 +587,35 @@ def parse_arguments(self): except Exception as e: print(f"Failed to delete {child}: {e}") + icons_cache = Path(tempfile.gettempdir()) / "yasb_quick_launch_icons" + if icons_cache.exists() and icons_cache.is_dir(): + try: + shutil.rmtree(icons_cache) + print(f"Deleted folder {icons_cache}") + except Exception as e: + print(f"Failed to delete {icons_cache}: {e}") + print("Reset complete.") sys.exit(0) + elif args.command == "service": + if not self.task_handler.is_admin(): + print("This command requires administrator privileges. Please run as administrator.") + sys.exit(1) + if not os.path.exists(SERVICE_EXE_PATH): + print(f"yasb_service.exe not found at {SERVICE_EXE_PATH}") + sys.exit(1) + if args.action == "install": + result = subprocess.run([SERVICE_EXE_PATH, "--startup", "auto", "install"]) + if result.returncode == 0: + subprocess.run([SERVICE_EXE_PATH, "start"]) + elif args.action == "remove": + subprocess.run([SERVICE_EXE_PATH, "stop"]) + subprocess.run([SERVICE_EXE_PATH, "remove"]) + else: + subprocess.run([SERVICE_EXE_PATH, args.action]) + sys.exit(0) + elif args.command == "help" or args.help: print( textwrap.dedent(f"""\ @@ -596,6 +636,7 @@ def parse_arguments(self): set-channel Switch release channels (stable, dev) update Update the application log Tail yasb process logs (cancel with Ctrl-C) + service Manage the YASB launcher service (install, start, stop, remove) reset Restore default config files and clear cache help Print this message diff --git a/src/core/utils/yasb_service.py b/src/core/utils/yasb_service.py new file mode 100644 index 000000000..6421aea14 --- /dev/null +++ b/src/core/utils/yasb_service.py @@ -0,0 +1,273 @@ +""" +YASB Launcher — Windows Service + +A lightweight Windows service that launches YASB immediately when a user +logs in, before the normal startup delay kicks in. It waits for the desktop +shell to be fully ready, then starts yasb.exe directly into the user's session. + + +To set it up, run it from an elevated prompt using the yasbc.exe: + + yasbc install # install and set to auto-start + yasbc start # start immediately without rebooting + yasbc stop # stop the service + yasbc remove # stop and remove the service + +""" + +import ctypes +import ctypes.wintypes +import sys +import time + +import servicemanager +import win32event +import win32service +import win32serviceutil +import win32ts + +# Service metadata + +SERVICE_NAME = "YasbReborn" +SERVICE_DISPLAY = "YASB Reborn Launcher Service" +SERVICE_DESC = "This service is responsible for launching YASB Reborn at user login." + +EXPLORER_IDLE_TIMEOUT_SECONDS = 60 +POLL_INTERVAL_MS = 100 + +TOKEN_ALL_ACCESS = 0xF01FF +SECURITY_IMPERSONATION = 2 +TOKEN_PRIMARY = 1 +STARTF_USESHOWWINDOW = 0x0001 +SW_SHOW = 5 +CREATE_UNICODE_ENVIRONMENT = 0x00000400 +CREATE_NEW_CONSOLE = 0x00000010 +TH32CS_SNAPPROCESS = 0x00000002 +PROCESS_QUERY_LIMITED = 0x1000 +PROCESS_SYNCHRONIZE = 0x00100000 + + +class STARTUPINFOW(ctypes.Structure): + _fields_ = [ + ("cb", ctypes.wintypes.DWORD), + ("lpReserved", ctypes.wintypes.LPWSTR), + ("lpDesktop", ctypes.wintypes.LPWSTR), + ("lpTitle", ctypes.wintypes.LPWSTR), + ("dwX", ctypes.wintypes.DWORD), + ("dwY", ctypes.wintypes.DWORD), + ("dwXSize", ctypes.wintypes.DWORD), + ("dwYSize", ctypes.wintypes.DWORD), + ("dwXCountChars", ctypes.wintypes.DWORD), + ("dwYCountChars", ctypes.wintypes.DWORD), + ("dwFillAttribute", ctypes.wintypes.DWORD), + ("dwFlags", ctypes.wintypes.DWORD), + ("wShowWindow", ctypes.wintypes.WORD), + ("cbReserved2", ctypes.wintypes.WORD), + ("lpReserved2", ctypes.wintypes.LPBYTE), + ("hStdInput", ctypes.wintypes.HANDLE), + ("hStdOutput", ctypes.wintypes.HANDLE), + ("hStdError", ctypes.wintypes.HANDLE), + ] + + +class PROCESS_INFORMATION(ctypes.Structure): + _fields_ = [ + ("hProcess", ctypes.wintypes.HANDLE), + ("hThread", ctypes.wintypes.HANDLE), + ("dwProcessId", ctypes.wintypes.DWORD), + ("dwThreadId", ctypes.wintypes.DWORD), + ] + + +class PROCESSENTRY32W(ctypes.Structure): + _fields_ = [ + ("dwSize", ctypes.wintypes.DWORD), + ("cntUsage", ctypes.wintypes.DWORD), + ("th32ProcessID", ctypes.wintypes.DWORD), + ("th32DefaultHeapID", ctypes.POINTER(ctypes.c_ulong)), + ("th32ModuleID", ctypes.wintypes.DWORD), + ("cntThreads", ctypes.wintypes.DWORD), + ("th32ParentProcessID", ctypes.wintypes.DWORD), + ("pcPriClassBase", ctypes.c_long), + ("dwFlags", ctypes.wintypes.DWORD), + ("szExeFile", ctypes.c_wchar * 260), + ] + + +def _log_error(msg: str) -> None: + servicemanager.LogErrorMsg(f"[YasbLauncher] {msg}") + + +def _launch_in_session(session_id: int) -> bool: + """Launch yasb.exe into the session identified by session_id.""" + kernel32 = ctypes.windll.kernel32 + advapi32 = ctypes.windll.advapi32 + userenv = ctypes.windll.userenv + wtsapi32 = ctypes.windll.wtsapi32 + + h_token = ctypes.wintypes.HANDLE() + if not wtsapi32.WTSQueryUserToken(session_id, ctypes.byref(h_token)): + _log_error(f"WTSQueryUserToken(session={session_id}) failed: {ctypes.GetLastError()}") + return False + + h_primary = ctypes.wintypes.HANDLE() + ok = advapi32.DuplicateTokenEx( + h_token, + TOKEN_ALL_ACCESS, + None, + SECURITY_IMPERSONATION, + TOKEN_PRIMARY, + ctypes.byref(h_primary), + ) + kernel32.CloseHandle(h_token) + if not ok: + _log_error(f"DuplicateTokenEx failed: {ctypes.GetLastError()}") + return False + + lp_env = ctypes.c_void_p() + has_env = bool(userenv.CreateEnvironmentBlock(ctypes.byref(lp_env), h_primary, False)) + if not has_env: + _log_error(f"CreateEnvironmentBlock failed: {ctypes.GetLastError()} — using NULL env") + + si = STARTUPINFOW() + si.cb = ctypes.sizeof(si) + si.lpDesktop = "winsta0\\default" + si.dwFlags = STARTF_USESHOWWINDOW + si.wShowWindow = SW_SHOW + + pi = PROCESS_INFORMATION() + flags = CREATE_NEW_CONSOLE | (CREATE_UNICODE_ENVIRONMENT if has_env else 0) + + ok = advapi32.CreateProcessAsUserW( + h_primary, + None, # lpApplicationName — resolved via PATH + "yasb.exe", # lpCommandLine + None, + None, + False, + flags, + lp_env if has_env else None, + None, + ctypes.byref(si), + ctypes.byref(pi), + ) + + if has_env: + userenv.DestroyEnvironmentBlock(lp_env) + kernel32.CloseHandle(h_primary) + + if not ok: + _log_error(f"CreateProcessAsUserW failed: {ctypes.GetLastError()}") + return False + + kernel32.CloseHandle(pi.hProcess) + kernel32.CloseHandle(pi.hThread) + return True + + +def _wait_for_explorer_idle(session_id: int) -> bool: + """Wait until explorer.exe in the given session is fully idle (shell ready, fonts loaded).""" + kernel32 = ctypes.windll.kernel32 + deadline = time.time() + EXPLORER_IDLE_TIMEOUT_SECONDS + + while time.time() < deadline: + snap = kernel32.CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) + if snap == ctypes.wintypes.HANDLE(-1).value: + time.sleep(0.1) + continue + + entry = PROCESSENTRY32W() + entry.dwSize = ctypes.sizeof(entry) + explorer_pid = None + + try: + if kernel32.Process32FirstW(snap, ctypes.byref(entry)): + while True: + if entry.szExeFile.lower() == "explorer.exe": + sid_out = ctypes.wintypes.DWORD() + if kernel32.ProcessIdToSessionId(entry.th32ProcessID, ctypes.byref(sid_out)): + if sid_out.value == session_id: + explorer_pid = entry.th32ProcessID + break + if not kernel32.Process32NextW(snap, ctypes.byref(entry)): + break + finally: + kernel32.CloseHandle(snap) + + if explorer_pid: + h = kernel32.OpenProcess(PROCESS_QUERY_LIMITED | PROCESS_SYNCHRONIZE, False, explorer_pid) + if h: + try: + remaining_ms = max(1, int((deadline - time.time()) * 1000)) + ctypes.windll.user32.WaitForInputIdle(h, remaining_ms) + finally: + kernel32.CloseHandle(h) + return True + + time.sleep(0.1) + + return False + + +class YasbRebornService(win32serviceutil.ServiceFramework): + _svc_name_ = SERVICE_NAME + _svc_display_name_ = SERVICE_DISPLAY + _svc_description_ = SERVICE_DESC + + def __init__(self, args): + super().__init__(args) + self._stop_event = win32event.CreateEvent(None, 0, 0, None) + self._launched: set[int] = set() + + def SvcStop(self): + self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING) + win32event.SetEvent(self._stop_event) + + def SvcDoRun(self): + servicemanager.LogMsg( + servicemanager.EVENTLOG_INFORMATION_TYPE, + servicemanager.PYS_SERVICE_STARTED, + (self._svc_name_, ""), + ) + self._main_loop() + servicemanager.LogMsg( + servicemanager.EVENTLOG_INFORMATION_TYPE, + servicemanager.PYS_SERVICE_STOPPED, + (self._svc_name_, ""), + ) + + def _main_loop(self): + while True: + rc = win32event.WaitForSingleObject(self._stop_event, POLL_INTERVAL_MS) + if rc == win32event.WAIT_OBJECT_0: + break + self._check_sessions() + + def _check_sessions(self): + try: + sessions = win32ts.WTSEnumerateSessions(win32ts.WTS_CURRENT_SERVER_HANDLE) + except Exception as exc: + _log_error(f"WTSEnumerateSessions failed: {exc}") + return + + for sess in sessions: + sid: int = sess["SessionId"] + state: int = sess["State"] + + if sid == 0 or sid in self._launched: + continue + if state != win32ts.WTSActive: + continue + + self._launched.add(sid) + _wait_for_explorer_idle(sid) + _launch_in_session(sid) + + +if __name__ == "__main__": + if len(sys.argv) == 1: + servicemanager.Initialize() + servicemanager.PrepareToHostSingle(YasbRebornService) + servicemanager.StartServiceCtrlDispatcher() + else: + win32serviceutil.HandleCommandLine(YasbRebornService) From 4b2c379fa2028ad28a7466ddc1aeb815a771df61 Mon Sep 17 00:00:00 2001 From: AmN <16545063+amnweb@users.noreply.github.com> Date: Tue, 3 Mar 2026 03:05:04 +0100 Subject: [PATCH 2/2] feat(service): enhance font registration during service launch - Added functionality to proactively register user-installed fonts when launched with the --service flag. - Updated the YASB service to wait for explorer.exe before launching, ensuring the desktop is ready. - Introduced new logging for session management and error handling. --- src/core/utils/win32/bindings/gdi32.py | 8 ++ src/core/utils/yasb_service.py | 168 ++++++++++--------------- src/env_loader.py | 14 +++ 3 files changed, 90 insertions(+), 100 deletions(-) diff --git a/src/core/utils/win32/bindings/gdi32.py b/src/core/utils/win32/bindings/gdi32.py index c62ec4019..afcf7e7ee 100644 --- a/src/core/utils/win32/bindings/gdi32.py +++ b/src/core/utils/win32/bindings/gdi32.py @@ -10,6 +10,7 @@ DWORD, HANDLE, HDC, + LPCWSTR, LPVOID, ) @@ -30,6 +31,9 @@ gdi32.DeleteObject.argtypes = [HANDLE] gdi32.DeleteObject.restype = BOOL +gdi32.AddFontResourceW.argtypes = [LPCWSTR] +gdi32.AddFontResourceW.restype = c_int + def GetDIBits( hdc: int, @@ -49,3 +53,7 @@ def DeleteObject(hObject: int) -> bool: def GetObject(hgdiobj: int, cbBuffer: int, lpvObject: CArgObject) -> int: return gdi32.GetObjectW(hgdiobj, cbBuffer, lpvObject) + + +def AddFontResource(font_path: str) -> int: + return gdi32.AddFontResourceW(font_path) diff --git a/src/core/utils/yasb_service.py b/src/core/utils/yasb_service.py index 6421aea14..d779c6293 100644 --- a/src/core/utils/yasb_service.py +++ b/src/core/utils/yasb_service.py @@ -1,12 +1,14 @@ """ YASB Launcher — Windows Service -A lightweight Windows service that launches YASB immediately when a user -logs in, before the normal startup delay kicks in. It waits for the desktop -shell to be fully ready, then starts yasb.exe directly into the user's session. +A lightweight Windows service that launches YASB when a user logs in. +When a WTS_SESSION_LOGON event arrives the service launches yasb.exe +with the --service flag directly into the user's session. The --service +flag tells yasb.exe to register user-installed fonts via GDI before the +GUI starts. The service waits for explorer.exe to appear in the target +session before launching, ensuring the desktop and network are ready. - -To set it up, run it from an elevated prompt using the yasbc.exe: +Usage (elevated prompt via yasbc.exe): yasbc install # install and set to auto-start yasbc start # start immediately without rebooting @@ -18,6 +20,7 @@ import ctypes import ctypes.wintypes import sys +import threading import time import servicemanager @@ -26,15 +29,11 @@ import win32serviceutil import win32ts -# Service metadata - SERVICE_NAME = "YasbReborn" SERVICE_DISPLAY = "YASB Reborn Launcher Service" SERVICE_DESC = "This service is responsible for launching YASB Reborn at user login." -EXPLORER_IDLE_TIMEOUT_SECONDS = 60 -POLL_INTERVAL_MS = 100 - +WTS_SESSION_LOGON = 5 TOKEN_ALL_ACCESS = 0xF01FF SECURITY_IMPERSONATION = 2 TOKEN_PRIMARY = 1 @@ -42,9 +41,8 @@ SW_SHOW = 5 CREATE_UNICODE_ENVIRONMENT = 0x00000400 CREATE_NEW_CONSOLE = 0x00000010 -TH32CS_SNAPPROCESS = 0x00000002 -PROCESS_QUERY_LIMITED = 0x1000 -PROCESS_SYNCHRONIZE = 0x00100000 +EXPLORER_WAIT_TIMEOUT = 30 +EXPLORER_POLL_INTERVAL = 0.25 class STARTUPINFOW(ctypes.Structure): @@ -79,27 +77,34 @@ class PROCESS_INFORMATION(ctypes.Structure): ] -class PROCESSENTRY32W(ctypes.Structure): - _fields_ = [ - ("dwSize", ctypes.wintypes.DWORD), - ("cntUsage", ctypes.wintypes.DWORD), - ("th32ProcessID", ctypes.wintypes.DWORD), - ("th32DefaultHeapID", ctypes.POINTER(ctypes.c_ulong)), - ("th32ModuleID", ctypes.wintypes.DWORD), - ("cntThreads", ctypes.wintypes.DWORD), - ("th32ParentProcessID", ctypes.wintypes.DWORD), - ("pcPriClassBase", ctypes.c_long), - ("dwFlags", ctypes.wintypes.DWORD), - ("szExeFile", ctypes.c_wchar * 260), - ] - - def _log_error(msg: str) -> None: servicemanager.LogErrorMsg(f"[YasbLauncher] {msg}") +def _log_warning(msg: str) -> None: + servicemanager.LogWarningMsg(f"[YasbLauncher] {msg}") + + +def _wait_for_explorer(session_id: int) -> bool: + """Wait until explorer.exe is running in the target session. + + WTSEnumerateProcesses returns tuples: (SessionId, ProcessId, ProcessName, UserSid) + """ + deadline = time.monotonic() + EXPLORER_WAIT_TIMEOUT + while time.monotonic() < deadline: + try: + for sid, _pid, name, _usersid in win32ts.WTSEnumerateProcesses(win32ts.WTS_CURRENT_SERVER_HANDLE): + if sid == session_id and name.lower() == "explorer.exe": + return True + except Exception: + pass + time.sleep(EXPLORER_POLL_INTERVAL) + _log_warning(f"Session {session_id}: explorer.exe not found after {EXPLORER_WAIT_TIMEOUT}s") + return False + + def _launch_in_session(session_id: int) -> bool: - """Launch yasb.exe into the session identified by session_id.""" + """Launch yasb.exe --service into the given user session.""" kernel32 = ctypes.windll.kernel32 advapi32 = ctypes.windll.advapi32 userenv = ctypes.windll.userenv @@ -107,7 +112,7 @@ def _launch_in_session(session_id: int) -> bool: h_token = ctypes.wintypes.HANDLE() if not wtsapi32.WTSQueryUserToken(session_id, ctypes.byref(h_token)): - _log_error(f"WTSQueryUserToken(session={session_id}) failed: {ctypes.GetLastError()}") + _log_error(f"Session {session_id}: WTSQueryUserToken failed ({ctypes.GetLastError()})") return False h_primary = ctypes.wintypes.HANDLE() @@ -121,13 +126,11 @@ def _launch_in_session(session_id: int) -> bool: ) kernel32.CloseHandle(h_token) if not ok: - _log_error(f"DuplicateTokenEx failed: {ctypes.GetLastError()}") + _log_error(f"Session {session_id}: DuplicateTokenEx failed ({ctypes.GetLastError()})") return False lp_env = ctypes.c_void_p() has_env = bool(userenv.CreateEnvironmentBlock(ctypes.byref(lp_env), h_primary, False)) - if not has_env: - _log_error(f"CreateEnvironmentBlock failed: {ctypes.GetLastError()} — using NULL env") si = STARTUPINFOW() si.cb = ctypes.sizeof(si) @@ -140,8 +143,8 @@ def _launch_in_session(session_id: int) -> bool: ok = advapi32.CreateProcessAsUserW( h_primary, - None, # lpApplicationName — resolved via PATH - "yasb.exe", # lpCommandLine + None, + "yasb.exe --service", None, None, False, @@ -157,7 +160,7 @@ def _launch_in_session(session_id: int) -> bool: kernel32.CloseHandle(h_primary) if not ok: - _log_error(f"CreateProcessAsUserW failed: {ctypes.GetLastError()}") + _log_error(f"Session {session_id}: CreateProcessAsUserW failed ({ctypes.GetLastError()})") return False kernel32.CloseHandle(pi.hProcess) @@ -165,50 +168,6 @@ def _launch_in_session(session_id: int) -> bool: return True -def _wait_for_explorer_idle(session_id: int) -> bool: - """Wait until explorer.exe in the given session is fully idle (shell ready, fonts loaded).""" - kernel32 = ctypes.windll.kernel32 - deadline = time.time() + EXPLORER_IDLE_TIMEOUT_SECONDS - - while time.time() < deadline: - snap = kernel32.CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) - if snap == ctypes.wintypes.HANDLE(-1).value: - time.sleep(0.1) - continue - - entry = PROCESSENTRY32W() - entry.dwSize = ctypes.sizeof(entry) - explorer_pid = None - - try: - if kernel32.Process32FirstW(snap, ctypes.byref(entry)): - while True: - if entry.szExeFile.lower() == "explorer.exe": - sid_out = ctypes.wintypes.DWORD() - if kernel32.ProcessIdToSessionId(entry.th32ProcessID, ctypes.byref(sid_out)): - if sid_out.value == session_id: - explorer_pid = entry.th32ProcessID - break - if not kernel32.Process32NextW(snap, ctypes.byref(entry)): - break - finally: - kernel32.CloseHandle(snap) - - if explorer_pid: - h = kernel32.OpenProcess(PROCESS_QUERY_LIMITED | PROCESS_SYNCHRONIZE, False, explorer_pid) - if h: - try: - remaining_ms = max(1, int((deadline - time.time()) * 1000)) - ctypes.windll.user32.WaitForInputIdle(h, remaining_ms) - finally: - kernel32.CloseHandle(h) - return True - - time.sleep(0.1) - - return False - - class YasbRebornService(win32serviceutil.ServiceFramework): _svc_name_ = SERVICE_NAME _svc_display_name_ = SERVICE_DISPLAY @@ -218,6 +177,19 @@ def __init__(self, args): super().__init__(args) self._stop_event = win32event.CreateEvent(None, 0, 0, None) self._launched: set[int] = set() + self._lock = threading.Lock() + + def GetAcceptedControls(self): + return super().GetAcceptedControls() | win32service.SERVICE_ACCEPT_SESSIONCHANGE + + def SvcOtherEx(self, control, event_type, data): + if control == win32service.SERVICE_CONTROL_SESSIONCHANGE and event_type == WTS_SESSION_LOGON: + session_id: int = data[0] if isinstance(data, tuple) else data + with self._lock: + if session_id in self._launched: + return + self._launched.add(session_id) + threading.Thread(target=self._launch_yasb, args=(session_id,), daemon=True).start() def SvcStop(self): self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING) @@ -229,39 +201,35 @@ def SvcDoRun(self): servicemanager.PYS_SERVICE_STARTED, (self._svc_name_, ""), ) - self._main_loop() + self._launch_active_sessions() + win32event.WaitForSingleObject(self._stop_event, win32event.INFINITE) servicemanager.LogMsg( servicemanager.EVENTLOG_INFORMATION_TYPE, servicemanager.PYS_SERVICE_STOPPED, (self._svc_name_, ""), ) - def _main_loop(self): - while True: - rc = win32event.WaitForSingleObject(self._stop_event, POLL_INTERVAL_MS) - if rc == win32event.WAIT_OBJECT_0: - break - self._check_sessions() - - def _check_sessions(self): + def _launch_active_sessions(self): + """Launch YASB for any user sessions already active at service start.""" try: sessions = win32ts.WTSEnumerateSessions(win32ts.WTS_CURRENT_SERVER_HANDLE) except Exception as exc: _log_error(f"WTSEnumerateSessions failed: {exc}") return - for sess in sessions: - sid: int = sess["SessionId"] - state: int = sess["State"] - - if sid == 0 or sid in self._launched: + sid = sess["SessionId"] + if sid == 0 or sess["State"] != win32ts.WTSActive: continue - if state != win32ts.WTSActive: - continue - - self._launched.add(sid) - _wait_for_explorer_idle(sid) - _launch_in_session(sid) + with self._lock: + if sid in self._launched: + continue + self._launched.add(sid) + threading.Thread(target=self._launch_yasb, args=(sid,), daemon=True).start() + + def _launch_yasb(self, session_id: int): + _wait_for_explorer(session_id) + if not _launch_in_session(session_id): + _log_error(f"Failed to launch YASB in session {session_id}") if __name__ == "__main__": diff --git a/src/env_loader.py b/src/env_loader.py index 8973821c6..bc38d01f3 100644 --- a/src/env_loader.py +++ b/src/env_loader.py @@ -1,5 +1,6 @@ import logging import os +import sys from dotenv import load_dotenv @@ -26,7 +27,20 @@ def load_env(): def set_font_engine(): """ Set the font engine for the application based on the YASB_FONT_ENGINE environment variable. + When launched with --service, proactively register user-installed fonts before configuring the Qt font engine. """ + if "--service" in sys.argv: + font_dir = os.path.join(os.environ.get("LOCALAPPDATA", ""), "Microsoft", "Windows", "Fonts") + if os.path.isdir(font_dir): + from core.utils.win32.bindings.gdi32 import AddFontResource + from core.utils.win32.bindings.user32 import SendNotifyMessage + + fonts = [f for f in os.listdir(font_dir) if f.lower().endswith((".ttf", ".otf", ".ttc"))] + for f in fonts: + AddFontResource(os.path.join(font_dir, f)) + if fonts: + SendNotifyMessage(0xFFFF, 0x001D, 0, 0) + font_engine = os.getenv("YASB_FONT_ENGINE") if font_engine == "native": os.environ["QT_QPA_PLATFORM"] = "windows:fontengine=native"