Compare commits

...

3 Commits

Author SHA1 Message Date
alexei.dolgolyov 26b5f74c24 feat: improve installer with custom icon, launch-after-install, and running-instance detection
Lint & Test / test (push) Successful in 9s
- Use custom icon.ico for installer/uninstaller UI
- LaunchApp opens server then browser after install
- .onInit detects running instance and offers to stop it
- Use WMIC-based process kill targeting embedded Python path
- start-hidden.vbs prefers embedded Python over system Python
- Add pystray dependency to build script
- CLAUDE.md: note to consult CI/CD guide for build changes
2026-03-24 12:48:31 +03:00
alexei.dolgolyov 1f6e4f6d55 feat: add Launch option to installer finish page
Lint & Test / test (push) Successful in 9s
2026-03-23 14:05:57 +03:00
alexei.dolgolyov 6500d6f615 feat: add system tray icon with Show UI and Exit actions
Lint & Test / test (push) Successful in 9s
Adds pystray-based tray icon (green play button) that runs alongside
uvicorn. Double-click opens the web UI in the browser, Exit triggers
graceful shutdown. Disabled with --no-tray flag for headless/service mode.
2026-03-23 14:05:13 +03:00
8 changed files with 172 additions and 17 deletions
+2
View File
@@ -166,6 +166,8 @@ Uninstall preserves `config.yaml` (user data).
Reference: [gitea-python-ci-cd.md](https://git.dolgolyov-family.by/alexei.dolgolyov/claude-code-facts/src/branch/main/gitea-python-ci-cd.md)
**IMPORTANT:** When modifying CI/CD workflows, `installer.nsi`, or build scripts (`build-dist-*.sh`), always fetch and consult the guide above first to ensure changes stay in sync with established patterns.
### Before Pushing
Ensure CI will pass locally:
+1
View File
@@ -68,6 +68,7 @@ WIN_DEPS=(
"pycaw>=20230407"
"screen-brightness-control>=0.20.0"
"monitorcontrol>=3.0.0"
"pystray>=0.19.0"
)
# Visualizer dependencies
+35 -7
View File
@@ -18,10 +18,12 @@ InstallDir "$LOCALAPPDATA\${APPNAME}"
RequestExecutionLevel user
; --- UI ---
; To use a custom icon, convert icon.svg to icon.ico and uncomment:
; !define MUI_ICON "media_server\static\icons\icon.ico"
; !define MUI_UNICON "media_server\static\icons\icon.ico"
!define MUI_ICON "media_server\static\icons\icon.ico"
!define MUI_UNICON "media_server\static\icons\icon.ico"
!define MUI_ABORTWARNING
!define MUI_FINISHPAGE_RUN ""
!define MUI_FINISHPAGE_RUN_TEXT "Launch ${APPNAME}"
!define MUI_FINISHPAGE_RUN_FUNCTION LaunchApp
!insertmacro MUI_PAGE_WELCOME
!insertmacro MUI_PAGE_DIRECTORY
@@ -34,13 +36,38 @@ RequestExecutionLevel user
!insertmacro MUI_LANGUAGE "English"
; --- Functions ---
Function LaunchApp
ExecShell "open" "wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"'
; Give the server a moment to start, then open the UI in the default browser
Sleep 2000
ExecShell "open" "http://localhost:8765/"
FunctionEnd
Function .onInit
; Check if server is running by trying to open its Python executable exclusively
IfFileExists "$INSTDIR\python\python.exe" 0 done
ClearErrors
FileOpen $0 "$INSTDIR\python\python.exe" a
IfErrors locked
; File opened fine — server is not running
FileClose $0
Goto done
locked:
MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION \
"${APPNAME} is currently running.$\n$\nYes = Stop the server and continue$\nNo = Continue without stopping (may cause errors)$\nCancel = Abort installation" \
IDYES kill IDNO done
Abort
kill:
nsExec::ExecToLog 'wmic process where "ExecutablePath like $\'%Media Server%python%$\'" call terminate'
Sleep 2000
done:
FunctionEnd
; --- Sections ---
Section "!Core (required)" SecCore
SectionIn RO
; Stop running instance if any
nsExec::ExecToLog 'taskkill /F /IM python.exe /FI "WINDOWTITLE eq media_server*"'
SetOutPath "$INSTDIR"
; Copy entire distribution
@@ -109,7 +136,8 @@ SectionEnd
; --- Uninstaller ---
Section "Uninstall"
; Stop running instance
nsExec::ExecToLog 'taskkill /F /IM python.exe /FI "WINDOWTITLE eq media_server*"'
nsExec::ExecToLog 'wmic process where "ExecutablePath like $\'%Media Server%python%$\'" call terminate'
nsExec::ExecToLog 'taskkill /F /IM media-server.exe'
; Remove application files
RMDir /r "$INSTDIR\python"
+22 -6
View File
@@ -216,6 +216,11 @@ def main():
action="store_true",
help="Show the current API token and exit",
)
parser.add_argument(
"--no-tray",
action="store_true",
help="Disable system tray icon (for headless/service mode)",
)
args = parser.parse_args()
@@ -235,12 +240,23 @@ def main():
print("\nAuthentication is DISABLED (no tokens configured)")
return
uvicorn.run(
"media_server.main:app",
host=args.host,
port=args.port,
reload=False,
)
# Start system tray icon (unless disabled)
tray_icon = None
if not args.no_tray:
from .tray import start_tray
tray_icon = start_tray(args.host, args.port)
try:
uvicorn.run(
"media_server.main:app",
host=args.host,
port=args.port,
reload=False,
)
finally:
if tray_icon is not None:
tray_icon.stop()
if __name__ == "__main__":
Binary file not shown.

After

Width:  |  Height:  |  Size: 208 B

+101
View File
@@ -0,0 +1,101 @@
"""System tray icon for Media Server."""
import io
import logging
import os
import signal
import threading
import webbrowser
from PIL import Image, ImageDraw
logger = logging.getLogger(__name__)
# pystray is optional — tray silently disabled when missing
try:
import pystray
except ImportError:
pystray = None
def _create_icon_image(size: int = 64) -> Image.Image:
"""Create a tray icon: green circle with white play triangle."""
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
# Green circle background
padding = 2
draw.ellipse(
[padding, padding, size - padding, size - padding],
fill=(29, 185, 84, 255),
)
# White play triangle
cx, cy = size // 2, size // 2
r = size * 0.28
triangle = [
(cx - r * 0.6, cy - r),
(cx - r * 0.6, cy + r),
(cx + r * 0.9, cy),
]
draw.polygon(triangle, fill=(255, 255, 255, 255))
return img
def _load_icon_image() -> Image.Image:
"""Load the SVG app icon, falling back to a generated image."""
try:
import cairosvg
svg_path = os.path.join(
os.path.dirname(__file__), "static", "icons", "icon.svg"
)
if os.path.exists(svg_path):
png_data = cairosvg.svg2png(url=svg_path, output_width=64, output_height=64)
return Image.open(io.BytesIO(png_data))
except Exception:
pass
return _create_icon_image()
def start_tray(host: str, port: int) -> "pystray.Icon | None":
"""Start system tray icon in a background thread.
Returns the Icon instance (call icon.stop() to remove), or None if
pystray is not installed.
"""
if pystray is None:
logger.info("pystray not installed — tray icon disabled")
return None
url = f"http://{'localhost' if host == '0.0.0.0' else host}:{port}"
def on_show_ui(_icon, _item):
webbrowser.open(url)
def on_exit(_icon, _item):
logger.info("Exit requested from tray")
_icon.stop()
# Signal the main process to shut down gracefully
os.kill(os.getpid(), signal.SIGINT)
menu = pystray.Menu(
pystray.MenuItem("Show UI", on_show_ui, default=True),
pystray.Menu.SEPARATOR,
pystray.MenuItem("Exit", on_exit),
)
icon = pystray.Icon(
name="media-server",
icon=_load_icon_image(),
title="Media Server",
menu=menu,
)
thread = threading.Thread(target=icon.run, daemon=True)
thread.start()
logger.info("System tray icon started")
return icon
+1
View File
@@ -42,6 +42,7 @@ windows = [
"pycaw>=20230407",
"screen-brightness-control>=0.20.0",
"monitorcontrol>=3.0.0",
"pystray>=0.19.0",
]
visualizer = [
"soundcard>=0.4.0",
+10 -4
View File
@@ -1,7 +1,13 @@
Set fso = CreateObject("Scripting.FileSystemObject")
Set WshShell = CreateObject("WScript.Shell")
' Get the directory of this script (scripts\), then go up to media-server root
scriptDir = CreateObject("Scripting.FileSystemObject").GetParentFolderName(WScript.ScriptFullName)
serverRoot = CreateObject("Scripting.FileSystemObject").GetParentFolderName(scriptDir)
scriptDir = fso.GetParentFolderName(WScript.ScriptFullName)
serverRoot = fso.GetParentFolderName(scriptDir)
WshShell.CurrentDirectory = serverRoot
' Run python completely hidden (0 = hidden, False = don't wait)
WshShell.Run "python -m media_server.main", 0, False
' Use embedded Python if present (installed distribution), otherwise system Python
embeddedPython = serverRoot & "\python\python.exe"
If fso.FileExists(embeddedPython) Then
WshShell.Run """" & embeddedPython & """ -m media_server.main", 0, False
Else
WshShell.Run "python -m media_server.main", 0, False
End If