Skip to content

Commit c0ed8c8

Browse files
author
Ebrahim Nejati
committed
Enhance windows native icon handling
Updated favicon handling to require local .ico files in Windows native mode. Added a function to create unique Windows application IDs based on the title. Improved icon setting logic and added error logging for better stability. Cleaned up the codebase by removing old window icon tests. Updated documentation to match the new icon loading requirements.
1 parent e5dd6c7 commit c0ed8c8

5 files changed

Lines changed: 71 additions & 92 deletions

File tree

nicegui/native/native_mode.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import _thread
44
import multiprocessing as mp
55
import queue
6+
import re
67
import socket
78
import sys
89
import time
@@ -30,6 +31,17 @@
3031
optional_features.register('webview')
3132

3233

34+
WINDOWS_APP_ID_PREFIX = 'nicegui.'
35+
WINDOWS_APP_ID_MAX_LENGTH = 128
36+
37+
38+
def _create_windows_app_id(title: str) -> str:
39+
normalized_title = re.sub(r'[^0-9A-Za-z]+', '_', title).strip('_')
40+
suffix = normalized_title or 'app'
41+
max_suffix_length = WINDOWS_APP_ID_MAX_LENGTH - len(WINDOWS_APP_ID_PREFIX)
42+
return f'{WINDOWS_APP_ID_PREFIX}{suffix[:max_suffix_length]}'
43+
44+
3345
def _open_window(
3446
protocol: str, host: str, port: int, title: str, width: int, height: int, fullscreen: bool, frameless: bool,
3547
method_queue: mp.Queue, response_queue: mp.Queue, event_sender: Connection,
@@ -54,20 +66,18 @@ def _open_window(
5466
window.events.closed += closed.set
5567
_bind_pywebview_events(window, event_sender)
5668

57-
if sys.platform == 'win32' and favicon is not None and helpers.is_file(favicon):
58-
favicon_path = Path(favicon)
59-
69+
if sys.platform == 'win32' and favicon is not None:
6070
def on_window_shown() -> None:
6171
hwnd = window_icon.find_window_by_title(title)
6272
if not hwnd:
6373
log.warning('Could not find native window by title to set icon')
6474
return
65-
icon_path = str(favicon_path.resolve())
75+
icon_path = str(favicon)
6676
# Set window icon for title bar and Alt+Tab
6777
if not window_icon.set_window_icon_windows(hwnd, icon_path):
6878
log.warning('Could not set native window icon (unsupported format?)')
6979
# Set property store for taskbar icon
70-
app_id = f'nicegui.{title.replace(" ", "_")}'
80+
app_id = _create_windows_app_id(title)
7181
window_icon.set_window_property_store(hwnd, app_id, icon_path)
7282
window.events.shown += on_window_shown
7383

nicegui/native/window_icon.py

Lines changed: 51 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
import ctypes
44
import sys
55
from ctypes import wintypes
6-
from pathlib import Path
6+
7+
from ..logging import log
78

89
user32 = ctypes.windll.user32 if sys.platform == 'win32' else None # type: ignore[attr-defined]
910
shell32 = ctypes.windll.shell32 if sys.platform == 'win32' else None # type: ignore[attr-defined]
@@ -12,12 +13,12 @@
1213

1314
# COM GUIDs for IPropertyStore (defined unconditionally for mypy on non-Windows)
1415
class GUID(ctypes.Structure):
15-
_fields_ = [('Data1', ctypes.c_ulong), ('Data2', ctypes.c_ushort), # noqa: RUF012
16+
_fields_ = [('Data1', ctypes.c_ulong), ('Data2', ctypes.c_ushort),
1617
('Data3', ctypes.c_ushort), ('Data4', ctypes.c_ubyte * 8)]
1718

1819

1920
class PROPERTYKEY(ctypes.Structure):
20-
_fields_ = [('fmtid', GUID), ('pid', ctypes.c_ulong)] # noqa: RUF012
21+
_fields_ = [('fmtid', GUID), ('pid', ctypes.c_ulong)]
2122

2223

2324
# PKEY_AppUserModel_ID = {9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3}, 5
@@ -31,7 +32,7 @@ class PROPERTYKEY(ctypes.Structure):
3132

3233
def find_window_by_title(title: str) -> int | None:
3334
"""Find HWND by exact title. None on non-Windows or not found."""
34-
if sys.platform != 'win32' or user32 is None:
35+
if user32 is None:
3536
return None
3637
result: list[int] = []
3738

@@ -51,79 +52,83 @@ def enum_cb(hwnd: int, _: int) -> bool:
5152

5253
def set_window_icon_windows(hwnd: int, icon_path: str) -> bool:
5354
"""Set window icon via LoadImageW/WM_SETICON for title bar and Alt+Tab."""
54-
if sys.platform != 'win32' or user32 is None or not Path(icon_path).is_file():
55+
if user32 is None:
5556
return False
56-
path = str(Path(icon_path).resolve())
57-
hicon = user32.LoadImageW(None, path, IMAGE_ICON, 0, 0, LR_LOADFROMFILE)
57+
hicon = user32.LoadImageW(None, icon_path, IMAGE_ICON, 0, 0, LR_LOADFROMFILE)
5858
if not hicon:
5959
return False
60-
user32.SendMessageW(hwnd, WM_SETICON, ICON_SMALL, hicon)
61-
user32.SendMessageW(hwnd, WM_SETICON, ICON_BIG, hicon)
60+
old_small = user32.SendMessageW(hwnd, WM_SETICON, ICON_SMALL, hicon)
61+
old_big = user32.SendMessageW(hwnd, WM_SETICON, ICON_BIG, hicon)
62+
# Keep the new icon handle alive while the window exists.
63+
# Destroying it too early can cause Alt+Tab/taskbar to fall back to defaults.
64+
for old_icon in (old_small, old_big):
65+
if old_icon and old_icon != hicon:
66+
user32.DestroyIcon(old_icon)
6267
return True
6368

6469

6570
def set_window_property_store(hwnd: int, app_id: str, icon_path: str) -> bool:
6671
"""Set window properties via IPropertyStore for proper taskbar icon on Windows 7+."""
67-
if sys.platform != 'win32' or shell32 is None:
72+
if shell32 is None:
6873
return False
6974

70-
try:
71-
# Define PROPVARIANT structure
72-
class PROPVARIANT(ctypes.Structure):
73-
_fields_ = [ # noqa: RUF012
74-
('vt', ctypes.c_ushort),
75-
('wReserved1', ctypes.c_ushort),
76-
('wReserved2', ctypes.c_ushort),
77-
('wReserved3', ctypes.c_ushort),
78-
('pwszVal', ctypes.c_wchar_p),
79-
('padding', ctypes.c_ulonglong),
80-
]
81-
82-
VT_LPWSTR = 31
75+
class PROPVARIANT(ctypes.Structure):
76+
_fields_ = [
77+
('vt', ctypes.c_ushort),
78+
('wReserved1', ctypes.c_ushort),
79+
('wReserved2', ctypes.c_ushort),
80+
('wReserved3', ctypes.c_ushort),
81+
('pwszVal', ctypes.c_wchar_p),
82+
('padding', ctypes.c_ulonglong),
83+
]
84+
85+
VT_LPWSTR = 31
86+
pps = ctypes.c_void_p()
87+
release_fn = None
8388

89+
try:
8490
# Get IPropertyStore interface
8591
IID_IPropertyStore = GUID(0x886D8EEB, 0x8CF2, 0x4446,
8692
(0x8D, 0x02, 0xCD, 0xBA, 0x1D, 0xBD, 0xCF, 0x99))
8793

88-
# SHGetPropertyStoreForWindow
8994
SHGetPropertyStoreForWindow = shell32.SHGetPropertyStoreForWindow
9095
SHGetPropertyStoreForWindow.argtypes = [wintypes.HWND, ctypes.POINTER(GUID), ctypes.POINTER(ctypes.c_void_p)]
9196
SHGetPropertyStoreForWindow.restype = ctypes.HRESULT
9297

93-
pps = ctypes.c_void_p()
9498
hr = SHGetPropertyStoreForWindow(hwnd, ctypes.byref(IID_IPropertyStore), ctypes.byref(pps))
9599
if hr != 0 or not pps:
100+
log.warning('Could not get IPropertyStore for window (HRESULT=%s)', hr)
96101
return False
97102

98-
# Get vtable
99103
vtable = ctypes.cast(ctypes.cast(pps, ctypes.POINTER(ctypes.c_void_p))[0],
100104
ctypes.POINTER(ctypes.c_void_p * 10))[0]
105+
set_value = ctypes.WINFUNCTYPE(ctypes.HRESULT, ctypes.c_void_p,
106+
ctypes.POINTER(PROPERTYKEY), ctypes.POINTER(PROPVARIANT))(vtable[6])
107+
commit = ctypes.WINFUNCTYPE(ctypes.HRESULT, ctypes.c_void_p)(vtable[7])
108+
release_fn = ctypes.WINFUNCTYPE(ctypes.c_ulong, ctypes.c_void_p)(vtable[2])
101109

102-
# SetValue is at index 6 in IPropertyStore vtable
103-
SetValue = ctypes.WINFUNCTYPE(ctypes.HRESULT, ctypes.c_void_p,
104-
ctypes.POINTER(PROPERTYKEY), ctypes.POINTER(PROPVARIANT))(vtable[6])
105-
106-
# Commit is at index 7
107-
Commit = ctypes.WINFUNCTYPE(ctypes.HRESULT, ctypes.c_void_p)(vtable[7])
108-
109-
# Release is at index 2
110-
Release = ctypes.WINFUNCTYPE(ctypes.c_ulong, ctypes.c_void_p)(vtable[2])
111-
112-
# Set AppUserModelID (vt=VT_LPWSTR, reserved=0, pwszVal=app_id)
113110
pv_id = PROPVARIANT(VT_LPWSTR, 0, 0, 0, app_id, 0)
114-
SetValue(pps, ctypes.byref(PKEY_AppUserModel_ID), ctypes.byref(pv_id))
111+
hr = set_value(pps, ctypes.byref(PKEY_AppUserModel_ID), ctypes.byref(pv_id))
112+
if hr != 0:
113+
log.warning('Could not set AppUserModelID property (HRESULT=%s)', hr)
114+
return False
115115

116-
# Set RelaunchIconResource (format: "path,index")
117116
icon_resource = f'{icon_path},0'
118117
pv_icon = PROPVARIANT(VT_LPWSTR, 0, 0, 0, icon_resource, 0)
119-
SetValue(pps, ctypes.byref(PKEY_AppUserModel_RelaunchIconResource), ctypes.byref(pv_icon))
120-
121-
# Commit changes
122-
Commit(pps)
118+
hr = set_value(pps, ctypes.byref(PKEY_AppUserModel_RelaunchIconResource), ctypes.byref(pv_icon))
119+
if hr != 0:
120+
log.warning('Could not set RelaunchIconResource property (HRESULT=%s)', hr)
121+
return False
123122

124-
# Release
125-
Release(pps)
123+
hr = commit(pps)
124+
if hr != 0:
125+
log.warning('Could not commit property store changes (HRESULT=%s)', hr)
126+
return False
126127

127128
return True
128-
except Exception:
129+
except (ctypes.ArgumentError, OSError, TypeError, ValueError):
130+
log.exception('Error while setting native window property store values')
129131
return False
132+
finally:
133+
if pps and release_fn is not None:
134+
release_fn(pps)

nicegui/ui_run.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,8 @@ def run(root: Callable | None = None, *,
9191
:param port: use this port (default: 8080 in normal mode, and an automatically determined open port in native mode)
9292
:param title: page title (default: `'NiceGUI'`, can be overwritten per page)
9393
:param viewport: page meta viewport content (default: `'width=device-width, initial-scale=1'`, can be overwritten per page)
94-
:param favicon: relative filepath, absolute URL to a favicon (default: `None`, NiceGUI icon will be used) or emoji (e.g. `'🚀'`, works for most browsers)
94+
:param favicon: relative filepath, absolute URL to a favicon (default: `None`, NiceGUI icon will be used) or emoji (e.g. `'🚀'`, works for most browsers).
95+
On Windows native mode, only local file paths are used for the native window icon, and Win32 icon loading expects `.ico`.
9596
:param dark: whether to use Quasar's dark mode (default: `False`, use `None` for "auto" mode)
9697
:param language: language for Quasar elements (default: `'en-US'`)
9798
:param binding_refresh_interval: interval for updating active links (default: 0.1 seconds, bigger is more CPU friendly, *since version 3.4.0*: can be ``None`` to disable update loop)
@@ -240,7 +241,7 @@ def run_script() -> None:
240241
native_host = '127.0.0.1' if host == '0.0.0.0' else host
241242
if reload:
242243
shutdown_event = multiprocessing.Event()
243-
native_favicon = str(Path(favicon).resolve()) if favicon and helpers.is_file(favicon) else favicon
244+
native_favicon = str(Path(favicon).resolve()) if favicon and helpers.is_file(favicon) else None
244245
native_module.activate(protocol, native_host, port, title, width, height, fullscreen, frameless,
245246
shutdown_event, native_favicon)
246247
else:

tests/test_native_window_icon.py

Lines changed: 0 additions & 38 deletions
This file was deleted.

website/documentation/content/section_configuration_deployment.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@ def urls_demo():
4646
This is typically pre-installed on standard Windows installations,
4747
but may be missing on minimal or freshly installed systems.
4848
49-
On Windows, a file-path `favicon` is also used as the native window icon (taskbar, title bar); `.ico` recommended.
49+
On Windows, a file-path `favicon` is also used as the native window icon (taskbar, title bar).
50+
Native icon loading uses Win32 `LoadImageW(..., IMAGE_ICON, ...)`, so `.ico` is required (`.cur`/`.ani` are also supported by Win32 icon loading; `.png` is not).
5051
''', tab=lambda: ui.label('NiceGUI'))
5152
def native_mode_demo():
5253
from nicegui import app

0 commit comments

Comments
 (0)