Skip to content

Commit ef3855b

Browse files
committed
Add minimize to tray (only Windows)
1 parent b3c5286 commit ef3855b

File tree

1 file changed

+289
-0
lines changed

1 file changed

+289
-0
lines changed

src/main.py

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import subprocess
1919
import sys
2020
import textwrap
21+
import threading
2122
import time
2223
import traceback
2324

@@ -29,12 +30,296 @@
2930
from urllib.request import urlopen, Request
3031

3132
if sys.platform == "win32":
33+
import ctypes
34+
import ctypes.wintypes
3235
import winreg
3336

3437
__version__ = "2.1"
3538

3639
os.system("")
3740

41+
if sys.platform == "win32":
42+
# WinAPI constants
43+
WM_USER = 0x0400
44+
WM_TRAYICON = WM_USER + 1
45+
WM_LBUTTONDBLCLK = 0x0203
46+
WM_RBUTTONUP = 0x0205
47+
WM_COMMAND = 0x0111
48+
WM_DESTROY = 0x0002
49+
WM_SYSCOMMAND = 0x0112
50+
SC_MINIMIZE = 0xF020
51+
NIM_ADD = 0x00000000
52+
NIM_DELETE = 0x00000002
53+
NIF_MESSAGE = 0x00000001
54+
NIF_ICON = 0x00000002
55+
NIF_TIP = 0x00000004
56+
SW_HIDE = 0
57+
SW_RESTORE = 9
58+
MF_STRING = 0x00000000
59+
MF_SEPARATOR = 0x00000800
60+
TPM_LEFTALIGN = 0x0000
61+
GWL_WNDPROC = -4
62+
IDI_APPLICATION = ctypes.cast(32512, ctypes.wintypes.LPCWSTR)
63+
64+
ID_TRAY_SHOW = 1001
65+
ID_TRAY_EXIT = 1002
66+
67+
ctypes.windll.user32.DefWindowProcW.restype = ctypes.c_long
68+
ctypes.windll.user32.DefWindowProcW.argtypes = [
69+
ctypes.wintypes.HWND,
70+
ctypes.wintypes.UINT,
71+
ctypes.wintypes.WPARAM,
72+
ctypes.wintypes.LPARAM,
73+
]
74+
ctypes.windll.user32.CallWindowProcW.restype = ctypes.c_long
75+
ctypes.windll.user32.CallWindowProcW.argtypes = [
76+
ctypes.c_void_p,
77+
ctypes.wintypes.HWND,
78+
ctypes.wintypes.UINT,
79+
ctypes.wintypes.WPARAM,
80+
ctypes.wintypes.LPARAM,
81+
]
82+
ctypes.windll.user32.SetWindowLongPtrW.restype = ctypes.c_void_p
83+
ctypes.windll.user32.SetWindowLongPtrW.argtypes = [
84+
ctypes.wintypes.HWND,
85+
ctypes.c_int,
86+
ctypes.c_void_p,
87+
]
88+
ctypes.windll.user32.GetWindowLongPtrW.restype = ctypes.c_void_p
89+
ctypes.windll.user32.GetWindowLongPtrW.argtypes = [
90+
ctypes.wintypes.HWND,
91+
ctypes.c_int,
92+
]
93+
ctypes.windll.user32.AppendMenuW.restype = ctypes.wintypes.BOOL
94+
ctypes.windll.user32.AppendMenuW.argtypes = [
95+
ctypes.wintypes.HMENU,
96+
ctypes.wintypes.UINT,
97+
ctypes.c_ulong,
98+
ctypes.wintypes.LPCWSTR,
99+
]
100+
101+
WNDPROCTYPE = ctypes.WINFUNCTYPE(
102+
ctypes.c_long,
103+
ctypes.wintypes.HWND,
104+
ctypes.wintypes.UINT,
105+
ctypes.wintypes.WPARAM,
106+
ctypes.wintypes.LPARAM,
107+
)
108+
109+
class NOTIFYICONDATA(ctypes.Structure):
110+
_fields_ = [
111+
("cbSize", ctypes.wintypes.DWORD),
112+
("hWnd", ctypes.wintypes.HWND),
113+
("uID", ctypes.wintypes.UINT),
114+
("uFlags", ctypes.wintypes.UINT),
115+
("uCallbackMessage", ctypes.wintypes.UINT),
116+
("hIcon", ctypes.wintypes.HICON),
117+
("szTip", ctypes.c_wchar * 128),
118+
("dwState", ctypes.wintypes.DWORD),
119+
("dwStateMask", ctypes.wintypes.DWORD),
120+
("szInfo", ctypes.c_wchar * 256),
121+
("uVersion", ctypes.wintypes.UINT),
122+
("szInfoTitle", ctypes.c_wchar * 64),
123+
("dwInfoFlags", ctypes.wintypes.DWORD),
124+
]
125+
126+
class WNDCLASS(ctypes.Structure):
127+
_fields_ = [
128+
("style", ctypes.wintypes.UINT),
129+
("lpfnWndProc", WNDPROCTYPE),
130+
("cbClsExtra", ctypes.c_int),
131+
("cbWndExtra", ctypes.c_int),
132+
("hInstance", ctypes.wintypes.HINSTANCE),
133+
("hIcon", ctypes.wintypes.HICON),
134+
("hCursor", ctypes.wintypes.HANDLE),
135+
("hbrBackground", ctypes.wintypes.HBRUSH),
136+
("lpszMenuName", ctypes.wintypes.LPCWSTR),
137+
("lpszClassName", ctypes.wintypes.LPCWSTR),
138+
]
139+
140+
class WindowsTrayIcon:
141+
"""Implements a Windows tray icon"""
142+
143+
_CLASS_NAME = "NoDPITrayWnd"
144+
145+
def __init__(self, tooltip: str = "NoDPI"):
146+
self.tooltip = tooltip
147+
self.hwnd: Optional[int] = None
148+
self.nid: Optional[NOTIFYICONDATA] = None
149+
self._console_hwnd = ctypes.windll.kernel32.GetConsoleWindow()
150+
self._thread: Optional[threading.Thread] = None
151+
self._wnd_proc_ref = None
152+
self._orig_console_proc = None
153+
self._hooked_console_proc_ref = None
154+
155+
def start(self) -> None:
156+
157+
self._thread = threading.Thread(
158+
target=self._message_loop, daemon=True, name="TrayThread"
159+
)
160+
self._thread.start()
161+
time.sleep(0.3)
162+
self._install_minimize_hook()
163+
164+
def hide_to_tray(self) -> None:
165+
ctypes.windll.user32.ShowWindow(self._console_hwnd, SW_HIDE)
166+
167+
def show_from_tray(self) -> None:
168+
ctypes.windll.user32.ShowWindow(self._console_hwnd, SW_RESTORE)
169+
ctypes.windll.user32.SetForegroundWindow(self._console_hwnd)
170+
171+
def _message_loop(self) -> None:
172+
self._create_tray_window()
173+
self._add_tray_icon()
174+
175+
msg = ctypes.wintypes.MSG()
176+
while ctypes.windll.user32.GetMessageW(ctypes.byref(msg), None, 0, 0) != 0:
177+
ctypes.windll.user32.TranslateMessage(ctypes.byref(msg))
178+
ctypes.windll.user32.DispatchMessageW(ctypes.byref(msg))
179+
180+
self._remove_tray_icon()
181+
182+
def _create_tray_window(self) -> None:
183+
hinstance = ctypes.windll.kernel32.GetModuleHandleW(None)
184+
185+
def _wnd_proc(hwnd, msg, wparam, lparam):
186+
if msg == WM_TRAYICON:
187+
if lparam == WM_LBUTTONDBLCLK:
188+
self.show_from_tray()
189+
elif lparam == WM_RBUTTONUP:
190+
self._show_context_menu(hwnd)
191+
elif msg == WM_COMMAND:
192+
cmd = wparam & 0xFFFF
193+
if cmd == ID_TRAY_SHOW:
194+
self.show_from_tray()
195+
elif cmd == ID_TRAY_EXIT:
196+
self._remove_tray_icon()
197+
ctypes.windll.user32.PostQuitMessage(0)
198+
os._exit(0)
199+
elif msg == WM_DESTROY:
200+
ctypes.windll.user32.PostQuitMessage(0)
201+
return 0
202+
return ctypes.windll.user32.DefWindowProcW(hwnd, msg, wparam, lparam)
203+
204+
self._wnd_proc_ref = WNDPROCTYPE(_wnd_proc)
205+
206+
wc = WNDCLASS()
207+
wc.lpfnWndProc = self._wnd_proc_ref
208+
wc.hInstance = hinstance
209+
wc.lpszClassName = self._CLASS_NAME
210+
211+
ctypes.windll.user32.RegisterClassW(ctypes.byref(wc))
212+
213+
self.hwnd = ctypes.windll.user32.CreateWindowExW(
214+
0, self._CLASS_NAME, "NoDPI Tray",
215+
0, 0, 0, 0, 0,
216+
0, 0, hinstance, None,
217+
)
218+
219+
def _add_tray_icon(self) -> None:
220+
hicon = ctypes.windll.user32.LoadIconW(None, IDI_APPLICATION)
221+
222+
nid = NOTIFYICONDATA()
223+
nid.cbSize = ctypes.sizeof(NOTIFYICONDATA)
224+
nid.hWnd = self.hwnd
225+
nid.uID = 1
226+
nid.uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP
227+
nid.uCallbackMessage = WM_TRAYICON
228+
nid.hIcon = hicon
229+
nid.szTip = self.tooltip
230+
self.nid = nid
231+
232+
ctypes.windll.shell32.Shell_NotifyIconW(NIM_ADD, ctypes.byref(nid))
233+
234+
def _remove_tray_icon(self) -> None:
235+
if self.nid:
236+
ctypes.windll.shell32.Shell_NotifyIconW(
237+
NIM_DELETE, ctypes.byref(self.nid)
238+
)
239+
self.nid = None
240+
241+
def _show_context_menu(self, hwnd: int) -> None:
242+
hmenu = ctypes.windll.user32.CreatePopupMenu()
243+
244+
ctypes.windll.user32.AppendMenuW(
245+
hmenu, MF_STRING, ID_TRAY_SHOW,
246+
ctypes.c_wchar_p("Show")
247+
)
248+
ctypes.windll.user32.AppendMenuW(
249+
hmenu, MF_SEPARATOR, 0,
250+
ctypes.c_wchar_p(None)
251+
)
252+
ctypes.windll.user32.AppendMenuW(
253+
hmenu, MF_STRING, ID_TRAY_EXIT,
254+
ctypes.c_wchar_p("Exit")
255+
)
256+
257+
pt = ctypes.wintypes.POINT()
258+
ctypes.windll.user32.GetCursorPos(ctypes.byref(pt))
259+
ctypes.windll.user32.SetForegroundWindow(hwnd)
260+
ctypes.windll.user32.TrackPopupMenu(
261+
hmenu, TPM_LEFTALIGN, pt.x, pt.y, 0, hwnd, None
262+
)
263+
ctypes.windll.user32.PostMessageW(hwnd, 0, 0, 0)
264+
ctypes.windll.user32.DestroyMenu(hmenu)
265+
266+
def _install_minimize_hook(self) -> None:
267+
268+
if not self._console_hwnd:
269+
return
270+
271+
orig = ctypes.windll.user32.GetWindowLongPtrW(
272+
self._console_hwnd, GWL_WNDPROC
273+
)
274+
275+
if orig is None or orig == 0:
276+
self._start_minimize_polling()
277+
return
278+
279+
def _hooked(hwnd, msg, wparam, lparam):
280+
if msg == WM_SYSCOMMAND and (wparam & 0xFFF0) == SC_MINIMIZE:
281+
self.hide_to_tray()
282+
return 0
283+
return ctypes.windll.user32.CallWindowProcW(
284+
self._orig_console_proc, hwnd, msg, wparam, lparam
285+
)
286+
287+
self._hooked_console_proc_ref = WNDPROCTYPE(_hooked)
288+
self._orig_console_proc = orig
289+
290+
ctypes.windll.user32.SetWindowLongPtrW(
291+
self._console_hwnd, GWL_WNDPROC, self._hooked_console_proc_ref
292+
)
293+
294+
def _start_minimize_polling(self) -> None:
295+
296+
SW_SHOWMINIMIZED = 2
297+
298+
class WINDOWPLACEMENT(ctypes.Structure):
299+
_fields_ = [
300+
("length", ctypes.wintypes.UINT),
301+
("flags", ctypes.wintypes.UINT),
302+
("showCmd", ctypes.wintypes.UINT),
303+
("ptMinPosition", ctypes.wintypes.POINT),
304+
("ptMaxPosition", ctypes.wintypes.POINT),
305+
("rcNormalPosition", ctypes.wintypes.RECT),
306+
]
307+
308+
def _poll():
309+
wp = WINDOWPLACEMENT()
310+
wp.length = ctypes.sizeof(WINDOWPLACEMENT)
311+
hwnd = self._console_hwnd
312+
while True:
313+
time.sleep(0.2)
314+
ctypes.windll.user32.GetWindowPlacement(
315+
hwnd, ctypes.byref(wp))
316+
if wp.showCmd == SW_SHOWMINIMIZED:
317+
self.hide_to_tray()
318+
319+
t = threading.Thread(target=_poll, daemon=True,
320+
name="TrayPollThread")
321+
t.start()
322+
38323

39324
class ConnectionInfo:
40325
"""Class to store connection information"""
@@ -1456,6 +1741,10 @@ async def run(cls):
14561741
logger.set_error_counter_callback(
14571742
statistics.increment_error_connections)
14581743

1744+
if sys.platform == "win32" and not config.quiet:
1745+
tray = WindowsTrayIcon(tooltip=f"NoDPI v{__version__}")
1746+
tray.start()
1747+
14591748
proxy = ProxyServer(config, blacklist_manager, statistics, logger)
14601749

14611750
try:

0 commit comments

Comments
 (0)