persian-clock-widget 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,541 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ ابزارهای سیستمی ویندوز:
4
+ - مدیریت مسیرهای پروژه ساختاریافته (assets/fonts, assets/icons, assets/sounds, data)
5
+ - پخش و توقف فایل‌های صوتی MP3 آلارم با استفاده از WinMM (mciSendStringW) بدون نیاز به کتابخانه جانبی
6
+ - مخفی کردن کامل ویجت از نوار وظیفه (Taskbar) و نمایش فقط در Hidden Icons (System Tray)
7
+ - مدیریت Z-Index و عبوردهی کلیک (Click-Through) در حالت قفل
8
+ - گرد کردن گوشه‌های پنجره در ویندوز 10 و 11 و بارگذاری فونت‌های فارسی باندل‌شده
9
+ """
10
+
11
+ import ctypes
12
+ from ctypes import wintypes
13
+ import os
14
+ import shutil
15
+ import subprocess
16
+ import sys
17
+ import threading
18
+
19
+ IS_WIN = sys.platform == "win32"
20
+
21
+ try:
22
+ import winreg
23
+ except ImportError:
24
+ winreg = None
25
+
26
+ if IS_WIN:
27
+ try:
28
+ import win32api
29
+ import win32con
30
+ import win32gui
31
+ HAS_WIN32 = True
32
+ except ImportError:
33
+ HAS_WIN32 = False
34
+ else:
35
+ HAS_WIN32 = False
36
+
37
+ _linux_audio_proc = None
38
+
39
+ # ثابت‌های Win32 API
40
+ GWL_EXSTYLE = -20
41
+ WS_EX_TOOLWINDOW = 0x00000080
42
+ WS_EX_APPWINDOW = 0x00040000
43
+ WS_EX_NOACTIVATE = 0x08000000
44
+ WS_EX_LAYERED = 0x00080000
45
+ WS_EX_TRANSPARENT = 0x00000020
46
+
47
+ HWND_BOTTOM = 1
48
+ HWND_TOPMOST = -1
49
+ HWND_NOTOPMOST = -2
50
+
51
+ SWP_NOSIZE = 0x0001
52
+ SWP_NOMOVE = 0x0002
53
+ SWP_NOACTIVATE = 0x0010
54
+ SWP_SHOWWINDOW = 0x0040
55
+ SWP_FRAMECHANGED = 0x0020
56
+
57
+ DWMWA_WINDOW_CORNER_PREFERENCE = 33
58
+ DWMWCP_DEFAULT = 0
59
+ DWMWCP_DONOTROUND = 1
60
+ DWMWCP_ROUND = 2
61
+
62
+ APP_REG_NAME = "PersianClockCalendarWidget"
63
+ DEFAULT_ALARM_SOUND = "heeeeyyyyy.mp3"
64
+
65
+
66
+ def get_project_root() -> str:
67
+ """
68
+ دریافت ریشه اصلی پروژه:
69
+ - در حالت فایل اجرایی (.exe): پوشه کنار فایل اجرایی (یا ریشه پروژه اگر داخل release/ باشد)
70
+ - در حالت سورس پایتون: پوشه مادرِ src/
71
+ """
72
+ if getattr(sys, "frozen", False):
73
+ exe_dir = os.path.dirname(os.path.abspath(sys.executable))
74
+ parent_dir = os.path.dirname(exe_dir)
75
+ if os.path.basename(exe_dir).lower() == "release" and os.path.isdir(os.path.join(parent_dir, "data")):
76
+ return parent_dir
77
+ return exe_dir
78
+ return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
79
+
80
+
81
+ def get_data_dir() -> str:
82
+ """دریافت مسیر پوشه data/ برای ذخیره تنظیمات، آلارم‌ها و مناسبت‌های شخصی"""
83
+ root = get_project_root()
84
+ data_dir = os.path.join(root, "data")
85
+ try:
86
+ os.makedirs(data_dir, exist_ok=True)
87
+ test_f = os.path.join(data_dir, ".write_test")
88
+ with open(test_f, "w", encoding="utf-8") as f:
89
+ f.write("ok")
90
+ os.remove(test_f)
91
+ return data_dir
92
+ except Exception:
93
+ fallback = os.path.join(os.path.expanduser("~"), ".local", "share", "PersianClockWidget", "data")
94
+ os.makedirs(fallback, exist_ok=True)
95
+ return fallback
96
+
97
+
98
+ def get_resource_path(relative_path: str) -> str:
99
+ """دریافت مسیر فایل‌های باندل شده در PyInstaller (_MEIPASS) یا در پوشه پروژه"""
100
+ if hasattr(sys, "_MEIPASS"):
101
+ p = os.path.join(sys._MEIPASS, relative_path)
102
+ if os.path.exists(p):
103
+ return p
104
+ return os.path.join(get_project_root(), relative_path)
105
+
106
+
107
+ def get_available_alarm_sounds() -> list[str]:
108
+ """
109
+ دریافت لیست فایل‌های صوتی آلارم موجود در assets/sounds
110
+ با قرار دادن heeeeyyyyy.mp3 به عنوان گزینه اول و پیش‌فرض
111
+ """
112
+ sounds_dir = get_resource_path(os.path.join("assets", "sounds"))
113
+ files = []
114
+ if os.path.isdir(sounds_dir):
115
+ for f in sorted(os.listdir(sounds_dir)):
116
+ if f.lower().endswith((".mp3", ".wav")):
117
+ files.append(f)
118
+ if DEFAULT_ALARM_SOUND in files:
119
+ files.remove(DEFAULT_ALARM_SOUND)
120
+ files.insert(0, DEFAULT_ALARM_SOUND)
121
+ elif not files:
122
+ files = [DEFAULT_ALARM_SOUND]
123
+ return files
124
+
125
+
126
+ def play_mp3_sound(sound_filename: str, repeat: bool = True) -> bool:
127
+ """پخش غیرمسدودکننده فایل MP3 (در ویندوز با WinMM و در لینوکس با mpv/ffplay/mpg123/cvlc)"""
128
+ global _linux_audio_proc
129
+ stop_mp3_sound()
130
+ sound_path = get_resource_path(os.path.join("assets", "sounds", sound_filename))
131
+ if not os.path.exists(sound_path):
132
+ # تلاش با صدای پیش‌فرض در صورت یافت نشدن فایل
133
+ sound_path = get_resource_path(os.path.join("assets", "sounds", DEFAULT_ALARM_SOUND))
134
+ if not os.path.exists(sound_path):
135
+ return False
136
+
137
+ if not IS_WIN:
138
+ candidates = [
139
+ ["mpv", "--no-video", "--really-quiet"] + (["--loop-file=inf"] if repeat else []) + [sound_path],
140
+ ["ffplay", "-nodisp", "-autoexit", "-loglevel", "quiet"] + (["-loop", "0"] if repeat else []) + [sound_path],
141
+ ["mpg123", "-q"] + (["--loop", "-1"] if repeat else []) + [sound_path],
142
+ ["cvlc", "--play-and-exit", "--quiet"] + (["--loop"] if repeat else []) + [sound_path],
143
+ ]
144
+ for cmd in candidates:
145
+ if shutil.which(cmd[0]):
146
+ try:
147
+ _linux_audio_proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
148
+ return True
149
+ except Exception:
150
+ continue
151
+ return False
152
+
153
+ try:
154
+ mci = ctypes.windll.winmm.mciSendStringW
155
+ mci(f'open "{sound_path}" type mpegvideo alias widget_alarm_audio', None, 0, 0)
156
+ play_cmd = "play widget_alarm_audio repeat" if repeat else "play widget_alarm_audio"
157
+ res = mci(play_cmd, None, 0, 0)
158
+ return res == 0
159
+ except Exception:
160
+ return False
161
+
162
+
163
+ def stop_mp3_sound():
164
+ """توقف کامل پخش صدای MP3 آلارم"""
165
+ global _linux_audio_proc
166
+ if not IS_WIN:
167
+ if _linux_audio_proc is not None:
168
+ try:
169
+ _linux_audio_proc.terminate()
170
+ except Exception:
171
+ pass
172
+ _linux_audio_proc = None
173
+ return
174
+ try:
175
+ mci = ctypes.windll.winmm.mciSendStringW
176
+ mci("stop widget_alarm_audio", None, 0, 0)
177
+ mci("close widget_alarm_audio", None, 0, 0)
178
+ except Exception:
179
+ pass
180
+
181
+
182
+ def enable_dpi_awareness():
183
+ """فعال‌سازی پشتیبانی از نمایشگرهای High-DPI برای وضوح کامل فونت‌ها"""
184
+ if not IS_WIN:
185
+ return
186
+ try:
187
+ ctypes.windll.shcore.SetProcessDpiAwareness(2)
188
+ except Exception:
189
+ try:
190
+ ctypes.windll.user32.SetProcessDPIAware()
191
+ except Exception:
192
+ pass
193
+
194
+
195
+ def register_bundled_fonts() -> list[str]:
196
+ """
197
+ بارگذاری فونت‌های فارسی باندل‌شده در assets/fonts:
198
+ - در ویندوز: با AddFontResourceExW در حافظه GDI
199
+ - در لینوکس: کپی خودکار در ~/.local/share/fonts و به‌روزرسانی fc-cache
200
+ """
201
+ loaded = []
202
+ fonts_dir = get_resource_path(os.path.join("assets", "fonts"))
203
+ if not os.path.isdir(fonts_dir):
204
+ return loaded
205
+
206
+ if not IS_WIN:
207
+ user_fonts = os.path.expanduser("~/.local/share/fonts/PersianClockWidget")
208
+ os.makedirs(user_fonts, exist_ok=True)
209
+ copied_new = False
210
+ for fname in os.listdir(fonts_dir):
211
+ if fname.lower().endswith((".ttf", ".otf")):
212
+ src_p = os.path.join(fonts_dir, fname)
213
+ dst_p = os.path.join(user_fonts, fname)
214
+ try:
215
+ if not os.path.exists(dst_p):
216
+ shutil.copy2(src_p, dst_p)
217
+ copied_new = True
218
+ loaded.append(dst_p)
219
+ except Exception:
220
+ pass
221
+ if copied_new and shutil.which("fc-cache"):
222
+ try:
223
+ subprocess.Popen(["fc-cache", "-f", user_fonts], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
224
+ except Exception:
225
+ pass
226
+ return loaded
227
+
228
+ FR_PRIVATE = 0x10
229
+ for fname in os.listdir(fonts_dir):
230
+ if fname.lower().endswith((".ttf", ".otf")):
231
+ fpath = os.path.join(fonts_dir, fname)
232
+ try:
233
+ res = ctypes.windll.gdi32.AddFontResourceExW(
234
+ ctypes.c_wchar_p(fpath),
235
+ FR_PRIVATE,
236
+ 0,
237
+ )
238
+ if res > 0:
239
+ loaded.append(fpath)
240
+ except Exception:
241
+ pass
242
+ return loaded
243
+
244
+
245
+ def get_hwnd(tk_win) -> int:
246
+ """دریافت هندل واقعی پنجره ویندوز (HWND) از پنجره Tkinter"""
247
+ tk_win.update_idletasks()
248
+ if not IS_WIN:
249
+ return tk_win.winfo_id()
250
+ hwnd = ctypes.windll.user32.GetParent(tk_win.winfo_id())
251
+ if not hwnd:
252
+ hwnd = tk_win.winfo_id()
253
+ return hwnd
254
+
255
+
256
+ def hide_from_taskbar(tk_win):
257
+ """مخفی کردن کامل پنجره از Taskbar و Alt+Tab"""
258
+ if not IS_WIN:
259
+ try:
260
+ tk_win.attributes("-type", "utility")
261
+ except Exception:
262
+ pass
263
+ return
264
+ try:
265
+ hwnd = get_hwnd(tk_win)
266
+ user32 = ctypes.windll.user32
267
+ ex_style = user32.GetWindowLongW(hwnd, GWL_EXSTYLE)
268
+ new_style = (ex_style | WS_EX_TOOLWINDOW) & ~WS_EX_APPWINDOW
269
+ if new_style != ex_style:
270
+ user32.SetWindowLongW(hwnd, GWL_EXSTYLE, new_style)
271
+ user32.SetWindowPos(
272
+ hwnd, 0, 0, 0, 0, 0,
273
+ SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_FRAMECHANGED
274
+ )
275
+ except Exception:
276
+ pass
277
+
278
+
279
+ def apply_z_order_and_clickthrough(tk_win, z_mode: str, click_through: bool = False):
280
+ """اعمال حالت Z-Index و قفل عبوردهی کلیک (Click-Through)"""
281
+ if not IS_WIN:
282
+ try:
283
+ is_topmost = (z_mode != "desktop")
284
+ tk_win.attributes("-topmost", is_topmost)
285
+ if not is_topmost:
286
+ tk_win.lower()
287
+ except Exception:
288
+ pass
289
+ return
290
+ try:
291
+ hwnd = get_hwnd(tk_win)
292
+ user32 = ctypes.windll.user32
293
+
294
+ is_topmost = (z_mode != "desktop")
295
+
296
+ ex_style = user32.GetWindowLongW(hwnd, GWL_EXSTYLE)
297
+ target_style = (ex_style | WS_EX_TOOLWINDOW | WS_EX_LAYERED) & ~WS_EX_APPWINDOW
298
+ if click_through:
299
+ target_style |= WS_EX_TRANSPARENT
300
+ else:
301
+ target_style &= ~WS_EX_TRANSPARENT
302
+
303
+ if target_style != ex_style:
304
+ user32.SetWindowLongW(hwnd, GWL_EXSTYLE, target_style)
305
+ user32.SetWindowPos(
306
+ hwnd, 0, 0, 0, 0, 0,
307
+ SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_FRAMECHANGED
308
+ )
309
+
310
+ if is_topmost:
311
+ tk_win.attributes("-topmost", True)
312
+ user32.SetWindowPos(
313
+ hwnd,
314
+ HWND_TOPMOST,
315
+ 0, 0, 0, 0,
316
+ SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_SHOWWINDOW,
317
+ )
318
+ else:
319
+ tk_win.attributes("-topmost", False)
320
+ user32.SetWindowPos(
321
+ hwnd,
322
+ HWND_BOTTOM,
323
+ 0, 0, 0, 0,
324
+ SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE,
325
+ )
326
+ except Exception:
327
+ pass
328
+
329
+
330
+ def apply_rounded_corners(tk_win, width: int, height: int, radius: int = 16, enabled: bool = True):
331
+ """اعمال گوشه‌های گرد به پنجره ویجت در ویندوز 11 و 10"""
332
+ if not IS_WIN:
333
+ return
334
+ try:
335
+ hwnd = get_hwnd(tk_win)
336
+ pref = ctypes.c_int(DWMWCP_ROUND if enabled else DWMWCP_DONOTROUND)
337
+ res = ctypes.windll.dwmapi.DwmSetWindowAttribute(
338
+ wintypes.HWND(hwnd),
339
+ ctypes.c_uint(DWMWA_WINDOW_CORNER_PREFERENCE),
340
+ ctypes.byref(pref),
341
+ ctypes.sizeof(pref),
342
+ )
343
+ if res != 0:
344
+ if enabled:
345
+ rgn = ctypes.windll.gdi32.CreateRoundRectRgn(0, 0, width + 1, height + 1, radius, radius)
346
+ ctypes.windll.user32.SetWindowRgn(hwnd, rgn, True)
347
+ else:
348
+ ctypes.windll.user32.SetWindowRgn(hwnd, 0, True)
349
+ except Exception:
350
+ pass
351
+
352
+
353
+ # ---------------------------------------------------------------------------
354
+ # مدیریت اجرای خودکار با شروع ویندوز (Startup)
355
+ # ---------------------------------------------------------------------------
356
+ def get_startup_command(script_path: str) -> str:
357
+ if getattr(sys, "frozen", False):
358
+ return f'"{os.path.abspath(sys.executable)}"'
359
+ python_exe = sys.executable
360
+ pythonw_exe = os.path.join(os.path.dirname(python_exe), "pythonw.exe")
361
+ if os.path.exists(pythonw_exe):
362
+ python_exe = pythonw_exe
363
+ return f'"{python_exe}" "{os.path.abspath(script_path)}"'
364
+
365
+
366
+ def _linux_autostart_path() -> str:
367
+ return os.path.expanduser("~/.config/autostart/persian-clock-widget.desktop")
368
+
369
+
370
+ def is_startup_enabled() -> bool:
371
+ if not IS_WIN:
372
+ return os.path.exists(_linux_autostart_path())
373
+ try:
374
+ key = winreg.OpenKey(
375
+ winreg.HKEY_CURRENT_USER,
376
+ r"Software\Microsoft\Windows\CurrentVersion\Run",
377
+ 0,
378
+ winreg.KEY_READ,
379
+ )
380
+ val, _ = winreg.QueryValueEx(key, APP_REG_NAME)
381
+ winreg.CloseKey(key)
382
+ return bool(val)
383
+ except Exception:
384
+ return False
385
+
386
+
387
+ def set_startup_enabled(enable: bool, script_path: str) -> bool:
388
+ if not IS_WIN:
389
+ desktop_path = _linux_autostart_path()
390
+ try:
391
+ if enable:
392
+ os.makedirs(os.path.dirname(desktop_path), exist_ok=True)
393
+ exec_cmd = get_startup_command(script_path)
394
+ icon_path = get_resource_path(os.path.join("assets", "icons", "app_icon.png"))
395
+ with open(desktop_path, "w", encoding="utf-8") as f:
396
+ f.write(
397
+ "[Desktop Entry]\n"
398
+ "Type=Application\n"
399
+ "Name=Persian Clock & Calendar\n"
400
+ f"Exec={exec_cmd}\n"
401
+ f"Icon={icon_path}\n"
402
+ "Terminal=false\n"
403
+ "X-GNOME-Autostart-enabled=true\n"
404
+ )
405
+ else:
406
+ if os.path.exists(desktop_path):
407
+ os.remove(desktop_path)
408
+ return True
409
+ except Exception:
410
+ return False
411
+ try:
412
+ key = winreg.OpenKey(
413
+ winreg.HKEY_CURRENT_USER,
414
+ r"Software\Microsoft\Windows\CurrentVersion\Run",
415
+ 0,
416
+ winreg.KEY_SET_VALUE,
417
+ )
418
+ if enable:
419
+ cmd = get_startup_command(script_path)
420
+ winreg.SetValueEx(key, APP_REG_NAME, 0, winreg.REG_SZ, cmd)
421
+ else:
422
+ try:
423
+ winreg.DeleteValue(key, APP_REG_NAME)
424
+ except FileNotFoundError:
425
+ pass
426
+ winreg.CloseKey(key)
427
+ return True
428
+ except Exception:
429
+ return False
430
+
431
+
432
+ # ---------------------------------------------------------------------------
433
+ # مدیریت آیکون کنار ساعت ویندوز (Hidden Icons / System Tray)
434
+ # ---------------------------------------------------------------------------
435
+ class SystemTrayIcon:
436
+ WM_TRAYICON = win32con.WM_USER + 20 if HAS_WIN32 else 0x0414
437
+
438
+ def __init__(self, tooltip: str, on_left_click, on_right_click):
439
+ self.tooltip = tooltip
440
+ self.on_left_click = on_left_click
441
+ self.on_right_click = on_right_click
442
+ self.hwnd = None
443
+ self._thread = None
444
+ self._running = False
445
+
446
+ def start(self):
447
+ if not HAS_WIN32 or self._running:
448
+ return
449
+ self._running = True
450
+ self._thread = threading.Thread(target=self._run, daemon=True)
451
+ self._thread.start()
452
+
453
+ def update_tooltip(self, new_tooltip: str):
454
+ self.tooltip = new_tooltip[:120]
455
+ if HAS_WIN32 and self.hwnd:
456
+ try:
457
+ nid = (
458
+ self.hwnd,
459
+ 0,
460
+ win32gui.NIF_TIP,
461
+ self.WM_TRAYICON,
462
+ 0,
463
+ self.tooltip,
464
+ )
465
+ win32gui.Shell_NotifyIcon(win32gui.NIM_MODIFY, nid)
466
+ except Exception:
467
+ pass
468
+
469
+ def stop(self):
470
+ self._running = False
471
+ if HAS_WIN32 and self.hwnd:
472
+ try:
473
+ win32gui.Shell_NotifyIcon(win32gui.NIM_DELETE, (self.hwnd, 0))
474
+ win32gui.PostMessage(self.hwnd, win32con.WM_CLOSE, 0, 0)
475
+ except Exception:
476
+ pass
477
+ self.hwnd = None
478
+
479
+ def _run(self):
480
+ try:
481
+ message_map = {
482
+ win32con.WM_DESTROY: self._on_destroy,
483
+ self.WM_TRAYICON: self._on_notify,
484
+ }
485
+ wc = win32gui.WNDCLASS()
486
+ hinst = wc.hInstance = win32api.GetModuleHandle(None)
487
+ wc.lpszClassName = "PersianClockWidgetTrayClass"
488
+ wc.lpfnWndProc = message_map
489
+ try:
490
+ class_atom = win32gui.RegisterClass(wc)
491
+ except Exception:
492
+ class_atom = wc.lpszClassName
493
+
494
+ self.hwnd = win32gui.CreateWindow(
495
+ class_atom,
496
+ "PersianClockWidgetTray",
497
+ 0,
498
+ 0, 0, 0, 0,
499
+ 0, 0, hinst, None,
500
+ )
501
+
502
+ hicon = None
503
+ ico_path = get_resource_path(os.path.join("assets", "icons", "app_icon.ico"))
504
+ if os.path.exists(ico_path):
505
+ try:
506
+ hicon = win32gui.LoadImage(
507
+ hinst,
508
+ ico_path,
509
+ win32con.IMAGE_ICON,
510
+ 0, 0,
511
+ win32con.LR_LOADFROMFILE | win32con.LR_DEFAULTSIZE,
512
+ )
513
+ except Exception:
514
+ hicon = None
515
+ if not hicon:
516
+ hicon = win32gui.LoadIcon(0, win32con.IDI_APPLICATION)
517
+
518
+ flags = win32gui.NIF_ICON | win32gui.NIF_MESSAGE | win32gui.NIF_TIP
519
+ nid = (self.hwnd, 0, flags, self.WM_TRAYICON, hicon, self.tooltip[:120])
520
+ win32gui.Shell_NotifyIcon(win32gui.NIM_ADD, nid)
521
+
522
+ win32gui.PumpMessages()
523
+ except Exception:
524
+ pass
525
+
526
+ def _on_notify(self, hwnd, msg, wparam, lparam):
527
+ if lparam == win32con.WM_LBUTTONUP or lparam == win32con.WM_LBUTTONDBLCLK:
528
+ if self.on_left_click:
529
+ self.on_left_click()
530
+ elif lparam == win32con.WM_RBUTTONUP:
531
+ if self.on_right_click:
532
+ self.on_right_click()
533
+ return True
534
+
535
+ def _on_destroy(self, hwnd, msg, wparam, lparam):
536
+ try:
537
+ win32gui.Shell_NotifyIcon(win32gui.NIM_DELETE, (self.hwnd, 0))
538
+ except Exception:
539
+ pass
540
+ win32gui.PostQuitMessage(0)
541
+ return True