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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -77,6 +78,29 @@ To toggle the visibility of the status bar on a specific screen, use the followi
yasbc toggle-bar --screen <screen_name>
```

## 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
Expand Down
6 changes: 6 additions & 0 deletions src/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
31 changes: 31 additions & 0 deletions src/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -586,6 +598,24 @@ def parse_arguments(self):
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"""\
Expand All @@ -606,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

Expand Down
8 changes: 8 additions & 0 deletions src/core/utils/win32/bindings/gdi32.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
DWORD,
HANDLE,
HDC,
LPCWSTR,
LPVOID,
)

Expand All @@ -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,
Expand All @@ -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)
241 changes: 241 additions & 0 deletions src/core/utils/yasb_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
"""
YASB Launcher — Windows Service

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.

Usage (elevated prompt via 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 threading
import time

import servicemanager
import win32event
import win32service
import win32serviceutil
import win32ts

SERVICE_NAME = "YasbReborn"
SERVICE_DISPLAY = "YASB Reborn Launcher Service"
SERVICE_DESC = "This service is responsible for launching YASB Reborn at user login."

WTS_SESSION_LOGON = 5
TOKEN_ALL_ACCESS = 0xF01FF
SECURITY_IMPERSONATION = 2
TOKEN_PRIMARY = 1
STARTF_USESHOWWINDOW = 0x0001
SW_SHOW = 5
CREATE_UNICODE_ENVIRONMENT = 0x00000400
CREATE_NEW_CONSOLE = 0x00000010
EXPLORER_WAIT_TIMEOUT = 30
EXPLORER_POLL_INTERVAL = 0.25


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),
]


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 --service into the given user session."""
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"Session {session_id}: WTSQueryUserToken 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"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))

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,
"yasb.exe --service",
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"Session {session_id}: CreateProcessAsUserW failed ({ctypes.GetLastError()})")
return False

kernel32.CloseHandle(pi.hProcess)
kernel32.CloseHandle(pi.hThread)
return True


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()
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)
win32event.SetEvent(self._stop_event)

def SvcDoRun(self):
servicemanager.LogMsg(
servicemanager.EVENTLOG_INFORMATION_TYPE,
servicemanager.PYS_SERVICE_STARTED,
(self._svc_name_, ""),
)
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 _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 = sess["SessionId"]
if sid == 0 or sess["State"] != win32ts.WTSActive:
continue
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__":
if len(sys.argv) == 1:
servicemanager.Initialize()
servicemanager.PrepareToHostSingle(YasbRebornService)
servicemanager.StartServiceCtrlDispatcher()
else:
win32serviceutil.HandleCommandLine(YasbRebornService)
Loading