messageflight 0.2.4__py3-none-any.whl

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.
File without changes
@@ -0,0 +1,41 @@
1
+ """Windows startup folder integration for auto-launch on boot."""
2
+ import json
3
+ import os
4
+ import subprocess
5
+ import sys
6
+
7
+
8
+ def _startup_folder():
9
+ return os.path.expandvars(r"%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup")
10
+
11
+
12
+ def _shortcut_path():
13
+ return os.path.join(_startup_folder(), "MessageFlight.lnk")
14
+
15
+
16
+ def _exe_path():
17
+ if getattr(sys, 'frozen', False):
18
+ return sys.executable
19
+ return os.path.abspath(sys.argv[0])
20
+
21
+
22
+ def is_auto_start_enabled():
23
+ return os.path.exists(_shortcut_path())
24
+
25
+
26
+ def set_auto_start(enabled: bool):
27
+ shortcut = _shortcut_path()
28
+ if enabled:
29
+ target = _exe_path()
30
+ working_dir = os.path.dirname(target)
31
+ ps_cmd = (
32
+ f'$ws = New-Object -ComObject WScript.Shell; '
33
+ f'$s = $ws.CreateShortcut({json.dumps(shortcut)}); '
34
+ f'$s.TargetPath = {json.dumps(target)}; '
35
+ f'$s.WorkingDirectory = {json.dumps(working_dir)}; '
36
+ f'$s.Save()'
37
+ )
38
+ subprocess.run(["powershell", "-Command", ps_cmd], check=True, capture_output=True)
39
+ else:
40
+ if os.path.exists(shortcut):
41
+ os.remove(shortcut)
@@ -0,0 +1,457 @@
1
+ """Persistent application config backed by QSettings.
2
+
3
+ Stores the user's selected color scheme and exposes three preset
4
+ schemes: ``default``, ``retro`` (green), and ``cyber`` (synthwave).
5
+
6
+ The file uses ``QSettings.Format.IniFormat`` for portability and
7
+ inspectability on Windows (the default backend is the registry, which
8
+ is harder for users to find and edit by hand).
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import contextlib
13
+ import datetime
14
+ import json
15
+ import sys
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+ from typing import Any, Optional
19
+
20
+ from PyQt6.QtCore import QSettings
21
+
22
+ from message_flight.i18n import detect_system_language, normalize_language
23
+
24
+ ORG = "MessageFlight"
25
+ APP = "MessageFlight"
26
+ SETTINGS_KEY = "color_scheme"
27
+ FLIGHT_KWARG_KEY = "flight_kwargs_json"
28
+ FLIGHT_MODE_KEY = "flight_mode"
29
+ MINIMAX_SUBSCRIPTION_KEY = "minimax_subscription_key"
30
+ LANGUAGE_KEY = "language"
31
+ PLANE_PRESET_KEY = "plane_preset_key"
32
+ PLANE_PRESET_PARAMS_JSON_KEY = "plane_preset_params_json"
33
+
34
+ # Do-Not-Disturb configuration.
35
+ DND_ENABLED_KEY = "dnd_enabled"
36
+ DND_SCHEDULE_ENABLED_KEY = "dnd_schedule_enabled"
37
+ DND_SCHEDULE_START_KEY = "dnd_schedule_start"
38
+ DND_SCHEDULE_END_KEY = "dnd_schedule_end"
39
+
40
+ TTS_PROVIDER_KEY = "tts_provider"
41
+ DEFAULT_TTS_PROVIDER = "sapi"
42
+ VALID_TTS_PROVIDERS = ("sapi", "minimax")
43
+
44
+ # User-facing flight paths. ``horizontal`` is the classic left-to-right
45
+ # sweep; ``vertical_pong`` enters from the top and bounces off the top
46
+ # and bottom edges while drifting right.
47
+ VALID_FLY_PATHS: tuple[str, ...] = (
48
+ "horizontal",
49
+ "vertical_pong",
50
+ "zigzag_top_down",
51
+ "zigzag_bottom_up",
52
+ "around",
53
+ )
54
+
55
+ THEMES: dict[str, dict[str, str]] = {
56
+ "default": {
57
+ "plane_color": "#FF69B4",
58
+ "wing_color": "#FF1493",
59
+ "accent_color": "#FFFFFF",
60
+ "decor_color": "#FF69B4",
61
+ "banner_color": "#FFB6C1",
62
+ "text_color": "#FFFFFF",
63
+ "thruster_outer_color": "#FFA500",
64
+ "thruster_middle_color": "#FF4500",
65
+ "thruster_inner_color": "#FFFF00",
66
+ },
67
+ "retro": {
68
+ "plane_color": "#4F7942",
69
+ "wing_color": "#2E4A2E",
70
+ "accent_color": "#FFFFFF",
71
+ "decor_color": "#4F7942",
72
+ "banner_color": "#90EE90",
73
+ "text_color": "#FFFFFF",
74
+ "thruster_outer_color": "#FFD700",
75
+ "thruster_middle_color": "#FF8C00",
76
+ "thruster_inner_color": "#FFEB3B",
77
+ },
78
+ "cyber": {
79
+ "plane_color": "#00FFFF",
80
+ "wing_color": "#FF00FF",
81
+ "accent_color": "#FFFFFF",
82
+ "decor_color": "#00FFFF",
83
+ "banner_color": "#0A0A2A",
84
+ "text_color": "#00FF00",
85
+ "thruster_outer_color": "#FF00FF",
86
+ "thruster_middle_color": "#9D00FF",
87
+ "thruster_inner_color": "#FFFFFF",
88
+ },
89
+ }
90
+
91
+ DEFAULT_THEME = "default"
92
+
93
+
94
+ @dataclass
95
+ class FlightModeConfig:
96
+ """A set of flight-behavior parameters.
97
+
98
+ A flight mode preset lets the user switch the in-flight tuning
99
+ knobs in one click. The ``flight_kwargs`` dict is forwarded as
100
+ keyword arguments to :class:`FlightWidget`.
101
+ """
102
+
103
+ flight_kwargs: dict[str, Any]
104
+
105
+
106
+ # The 7 flight-behavior kwargs the 3 presets expose to FlightWidget.
107
+ # Any key outside this set is rejected by ``validate_flight_kwargs``.
108
+ VALID_FLIGHT_KWARG_KEYS: tuple[str, ...] = (
109
+ "fly_bounce",
110
+ "fly_loop_count",
111
+ "fly_path",
112
+ "fly_duration_ms",
113
+ "float_duration_ms",
114
+ "vertical_jitter",
115
+ "notification_interval_ms",
116
+ )
117
+
118
+ # The 3 named flight modes. Each is a plain dict of flight-behavior kwargs.
119
+ # Values copied verbatim from the design spec.
120
+ FLIGHT_MODES: dict[str, dict[str, Any]] = {
121
+ "低调": {
122
+ "fly_bounce": False,
123
+ "fly_loop_count": 1,
124
+ "fly_path": "horizontal",
125
+ "fly_duration_ms": 12000,
126
+ "float_duration_ms": 1500,
127
+ "vertical_jitter": 30,
128
+ "notification_interval_ms": 8000,
129
+ },
130
+ "标准": {
131
+ "fly_bounce": False,
132
+ "fly_loop_count": -1,
133
+ "fly_path": "horizontal",
134
+ "fly_duration_ms": 8000,
135
+ "float_duration_ms": 1500,
136
+ "vertical_jitter": 100,
137
+ "notification_interval_ms": 5000,
138
+ },
139
+ "胡闹": {
140
+ "fly_bounce": True,
141
+ "fly_loop_count": -1,
142
+ "fly_path": "horizontal",
143
+ "fly_duration_ms": 7000,
144
+ "float_duration_ms": 1500,
145
+ "vertical_jitter": 200,
146
+ "notification_interval_ms": 2000,
147
+ },
148
+ }
149
+
150
+ # User-facing order for the 3 mode buttons in the settings dialog.
151
+ FLIGHT_MODE_NAMES: tuple[str, ...] = ("低调", "标准", "胡闹")
152
+
153
+ DEFAULT_FLIGHT_MODE = "标准"
154
+
155
+
156
+ def validate_flight_kwargs(kwargs: dict[str, Any]) -> None:
157
+ """Reject unknown keys or wrong-typed values in a flight_kwargs dict.
158
+
159
+ Raises :class:`ValueError` with a clear message if a key is not one
160
+ of :data:`VALID_FLIGHT_KWARG_KEYS` or if a value has an unsupported
161
+ Python type. ``bool`` is accepted as a valid substitute for ``int``
162
+ only when the value is one of the few well-known bool-typed fields
163
+ (``fly_bounce``); otherwise ints/floats must be numeric and strings
164
+ must be ``str``.
165
+ """
166
+ if not isinstance(kwargs, dict):
167
+ raise ValueError(f"flight_kwargs must be a dict, got {type(kwargs).__name__}")
168
+ for key, value in kwargs.items():
169
+ if key not in VALID_FLIGHT_KWARG_KEYS:
170
+ raise ValueError(
171
+ f"Unknown flight_kwarg {key!r}; valid keys: {VALID_FLIGHT_KWARG_KEYS}"
172
+ )
173
+ if key == "fly_bounce":
174
+ if not isinstance(value, bool):
175
+ raise ValueError(f"fly_bounce must be bool, got {type(value).__name__}")
176
+ elif key == "fly_path":
177
+ if not isinstance(value, str):
178
+ raise ValueError(f"fly_path must be str, got {type(value).__name__}")
179
+ elif key in ("fly_loop_count", "fly_duration_ms", "float_duration_ms",
180
+ "vertical_jitter", "notification_interval_ms") and \
181
+ (isinstance(value, bool) or not isinstance(value, (int, float))):
182
+ raise ValueError(f"{key} must be int, got {type(value).__name__}")
183
+
184
+
185
+ @dataclass
186
+ class AppConfig:
187
+ """Resolved application configuration consumed by the UI layer."""
188
+
189
+ theme_name: str = DEFAULT_THEME
190
+ colors: dict[str, str] = field(default_factory=dict)
191
+ flight_mode: str = DEFAULT_FLIGHT_MODE
192
+ flight_kwargs: dict[str, Any] = field(
193
+ default_factory=lambda: dict(FLIGHT_MODES[DEFAULT_FLIGHT_MODE])
194
+ )
195
+ tts_provider: str = DEFAULT_TTS_PROVIDER
196
+ minimax_subscription_key: str = ""
197
+ language: str = field(default_factory=detect_system_language)
198
+ plane_preset_key: str = "airplane"
199
+ plane_preset_params_json: str = ""
200
+ # Do-Not-Disturb
201
+ dnd_enabled: bool = False
202
+ dnd_schedule_enabled: bool = False
203
+ dnd_schedule_start: str = "22:00"
204
+ dnd_schedule_end: str = "08:00"
205
+
206
+
207
+ def _parse_hhmm(text: str) -> Optional[int]:
208
+ """Parse ``"HH:MM"`` to minutes-since-midnight. Returns ``None`` on bad input."""
209
+ try:
210
+ parts = str(text).strip().split(":")
211
+ if len(parts) != 2:
212
+ return None
213
+ h, m = int(parts[0]), int(parts[1])
214
+ if not (0 <= h <= 23 and 0 <= m <= 59):
215
+ return None
216
+ return h * 60 + m
217
+ except (ValueError, AttributeError):
218
+ return None
219
+
220
+
221
+ def is_dnd_active(
222
+ cfg: AppConfig,
223
+ now: Optional[datetime.time] = None,
224
+ ) -> bool:
225
+ """Return True if Do-Not-Disturb should suppress incoming real notifications.
226
+
227
+ Two independent triggers:
228
+ - ``cfg.dnd_enabled`` (manual toggle)
229
+ - ``cfg.dnd_schedule_enabled`` plus the current time falling inside
230
+ the configured ``[start, end)`` window. Midnight-crossing windows
231
+ (e.g. ``22:00`` to ``08:00``) are supported: the window is treated
232
+ as wrapping past midnight.
233
+ """
234
+ if cfg.dnd_enabled:
235
+ return True
236
+ if cfg.dnd_schedule_enabled:
237
+ start = _parse_hhmm(cfg.dnd_schedule_start)
238
+ end = _parse_hhmm(cfg.dnd_schedule_end)
239
+ if start is None or end is None:
240
+ return False
241
+ current = now or datetime.datetime.now().time()
242
+ current_minutes = current.hour * 60 + current.minute
243
+ if start == end:
244
+ return False # zero-length window: never match
245
+ if start < end:
246
+ return start <= current_minutes < end
247
+ # Wraps midnight: e.g. 22:00 → 08:00 means current_minutes >= start OR < end
248
+ return current_minutes >= start or current_minutes < end
249
+ return False
250
+
251
+
252
+ def _new_settings() -> QSettings:
253
+ """Build a QSettings instance using IniFormat stored in ~/.config/messageflight."""
254
+ import os
255
+ from pathlib import Path
256
+ config_dir = os.environ.get("MESSAGEFLIGHT_CONFIG_DIR")
257
+ if config_dir is None:
258
+ config_dir = str(Path.home() / ".config" / "messageflight")
259
+ Path(config_dir).mkdir(parents=True, exist_ok=True)
260
+ QSettings.setPath(QSettings.Format.IniFormat, QSettings.Scope.UserScope, config_dir)
261
+ _ensure_example_config(Path(config_dir))
262
+ return QSettings(QSettings.Format.IniFormat, QSettings.Scope.UserScope, ORG, APP)
263
+
264
+
265
+ def _ensure_example_config(config_dir: Path) -> None:
266
+ """Create a config.example.ini if it doesn't already exist."""
267
+ example_path = config_dir / "config.example.ini"
268
+ if example_path.exists():
269
+ return
270
+ example_content = '''; MessageFlight 配置文件示例
271
+ ; 文件位置: ~/.config/messageflight/MessageFlight.ini
272
+ ; 警告: 此文件仅为示例,实际配置由程序自动管理。
273
+ ; 手动修改前请先退出程序。
274
+
275
+ [MessageFlight]
276
+ ; 配色主题: default(默认粉) | retro(复古绿) | cyber(未来赛博)
277
+ color_scheme=default
278
+
279
+ ; 9 个颜色值 (hex 格式)
280
+ plane_color=#FF69B4
281
+ wing_color=#FF1493
282
+ accent_color=#FFFFFF
283
+ decor_color=#FF69B4
284
+ banner_color=#FFB6C1
285
+ text_color=#FFFFFF
286
+ thruster_outer_color=#FFA500
287
+ thruster_middle_color=#FF4500
288
+ thruster_inner_color=#FFFF00
289
+
290
+ ; 飞行模式: 低调 | 标准 | 胡闹
291
+ flight_mode=标准
292
+
293
+ ; 飞行参数字典 (JSON 格式)
294
+ ; fly_bounce: 是否弹跳 (true/false)
295
+ ; fly_loop_count: 循环次数 (-1=无限)
296
+ ; fly_path: 飞行路径 (horizontal | vertical_pong)
297
+ ; fly_duration_ms: 飞行时长 (毫秒)
298
+ ; float_duration_ms: 悬浮时长 (毫秒)
299
+ ; vertical_jitter: 垂直抖动幅度
300
+ ; notification_interval_ms: 通知间隔 (毫秒)
301
+ flight_kwargs_json={"fly_bounce": false, "fly_loop_count": -1, "fly_path": "horizontal", "fly_duration_ms": 8000, "float_duration_ms": 1500, "vertical_jitter": 100, "notification_interval_ms": 5000}
302
+
303
+ ; TTS 引擎: sapi(本地语音) | minimax(在线语音)
304
+ tts_provider=sapi
305
+
306
+ ; MiniMax Token Plan 订阅 Key
307
+ ; 获取方式: https://platform.minimaxi.com/subscribe/token-plan
308
+ ; 注意: 这是订阅 Key,不是按量计费的 API Key
309
+ minimax_subscription_key=your-subscription-key-here
310
+ '''
311
+ with contextlib.suppress(OSError):
312
+ example_path.write_text(example_content, encoding="utf-8")
313
+
314
+
315
+ def load_config() -> AppConfig:
316
+ """Read the persisted config, falling back to defaults on any failure.
317
+
318
+ If the INI file is missing, corrupt, or only partially populated,
319
+ missing keys are silently replaced with the active theme's defaults
320
+ so the app still has a fully populated color dict to hand to the
321
+ widget.
322
+ """
323
+ try:
324
+ settings = _new_settings()
325
+ except Exception as e:
326
+ print(f"load_config: failed to open QSettings ({e!r}); using defaults", file=sys.stderr)
327
+ return _default_config()
328
+
329
+ try:
330
+ theme_name = str(settings.value(SETTINGS_KEY, DEFAULT_THEME))
331
+ if theme_name not in THEMES:
332
+ theme_name = DEFAULT_THEME
333
+ base = THEMES[theme_name]
334
+ colors = {key: str(settings.value(key, base[key])) for key in base}
335
+
336
+ # Flight mode + flight_kwargs (Task 06)
337
+ flight_mode = str(settings.value(FLIGHT_MODE_KEY, DEFAULT_FLIGHT_MODE))
338
+ if flight_mode not in FLIGHT_MODES:
339
+ flight_mode = DEFAULT_FLIGHT_MODE
340
+ default_kwargs = FLIGHT_MODES[flight_mode]
341
+ raw_kwargs_json = settings.value(FLIGHT_KWARG_KEY, None)
342
+ if raw_kwargs_json is None or str(raw_kwargs_json) == "":
343
+ flight_kwargs: dict[str, Any] = dict(default_kwargs)
344
+ else:
345
+ try:
346
+ parsed = json.loads(str(raw_kwargs_json))
347
+ if not isinstance(parsed, dict):
348
+ raise ValueError("flight_kwargs_json must encode a JSON object")
349
+ validate_flight_kwargs(parsed)
350
+ flight_kwargs = dict(parsed)
351
+ except (ValueError, json.JSONDecodeError) as e:
352
+ print(
353
+ f"load_config: bad flight_kwargs_json ({e!r}); falling back to {flight_mode!r}",
354
+ file=sys.stderr,
355
+ )
356
+ flight_kwargs = dict(default_kwargs)
357
+ tts_provider = str(settings.value(TTS_PROVIDER_KEY, DEFAULT_TTS_PROVIDER))
358
+ if tts_provider not in VALID_TTS_PROVIDERS:
359
+ tts_provider = DEFAULT_TTS_PROVIDER
360
+ minimax_subscription_key = str(settings.value(MINIMAX_SUBSCRIPTION_KEY, ""))
361
+ language = normalize_language(str(settings.value(LANGUAGE_KEY, detect_system_language())))
362
+ plane_preset_key = str(settings.value(PLANE_PRESET_KEY, "airplane"))
363
+ plane_preset_params_json = str(settings.value(PLANE_PRESET_PARAMS_JSON_KEY, ""))
364
+ # DND fields
365
+ dnd_enabled = _parse_bool(settings.value(DND_ENABLED_KEY, False))
366
+ dnd_schedule_enabled = _parse_bool(settings.value(DND_SCHEDULE_ENABLED_KEY, False))
367
+ dnd_schedule_start = str(settings.value(DND_SCHEDULE_START_KEY, "22:00"))
368
+ dnd_schedule_end = str(settings.value(DND_SCHEDULE_END_KEY, "08:00"))
369
+ except Exception as e:
370
+ print(f"load_config: failed to read keys ({e!r}); using defaults", file=sys.stderr)
371
+ return _default_config()
372
+
373
+ return AppConfig(
374
+ theme_name=theme_name,
375
+ colors=colors,
376
+ flight_mode=flight_mode,
377
+ flight_kwargs=flight_kwargs,
378
+ tts_provider=tts_provider,
379
+ minimax_subscription_key=minimax_subscription_key,
380
+ language=language,
381
+ plane_preset_key=plane_preset_key,
382
+ plane_preset_params_json=plane_preset_params_json,
383
+ dnd_enabled=dnd_enabled,
384
+ dnd_schedule_enabled=dnd_schedule_enabled,
385
+ dnd_schedule_start=dnd_schedule_start,
386
+ dnd_schedule_end=dnd_schedule_end,
387
+ )
388
+
389
+
390
+ def save_config(cfg: AppConfig) -> None:
391
+ """Persist the given config to disk. Errors are logged, not raised."""
392
+ try:
393
+ # Validate the flight_kwargs before persisting; on failure, fall
394
+ # back to the active mode's default kwargs so a corrupt save
395
+ # does not poison the next load.
396
+ try:
397
+ validate_flight_kwargs(cfg.flight_kwargs)
398
+ flight_kwargs_to_save = dict(cfg.flight_kwargs)
399
+ except ValueError as e:
400
+ print(f"save_config: invalid flight_kwargs ({e!r}); using mode defaults", file=sys.stderr)
401
+ mode = FLIGHT_MODES.get(cfg.flight_mode, FLIGHT_MODES[DEFAULT_FLIGHT_MODE])
402
+ flight_kwargs_to_save = dict(mode)
403
+
404
+ settings = _new_settings()
405
+ settings.setValue(SETTINGS_KEY, cfg.theme_name)
406
+ for key, value in cfg.colors.items():
407
+ settings.setValue(key, value)
408
+ settings.setValue(FLIGHT_MODE_KEY, cfg.flight_mode)
409
+ settings.setValue(FLIGHT_KWARG_KEY, json.dumps(flight_kwargs_to_save))
410
+ settings.setValue(TTS_PROVIDER_KEY, cfg.tts_provider)
411
+ settings.setValue(MINIMAX_SUBSCRIPTION_KEY, cfg.minimax_subscription_key)
412
+ settings.setValue(LANGUAGE_KEY, normalize_language(cfg.language))
413
+ settings.setValue(PLANE_PRESET_KEY, cfg.plane_preset_key)
414
+ settings.setValue(PLANE_PRESET_PARAMS_JSON_KEY, cfg.plane_preset_params_json)
415
+ settings.setValue(DND_ENABLED_KEY, cfg.dnd_enabled)
416
+ settings.setValue(DND_SCHEDULE_ENABLED_KEY, cfg.dnd_schedule_enabled)
417
+ settings.setValue(DND_SCHEDULE_START_KEY, cfg.dnd_schedule_start)
418
+ settings.setValue(DND_SCHEDULE_END_KEY, cfg.dnd_schedule_end)
419
+ settings.sync()
420
+ except Exception as e:
421
+ print(f"save_config: failed to persist ({e!r})", file=sys.stderr)
422
+
423
+
424
+ def _parse_bool(value: Any) -> bool:
425
+ """Coerce QSettings-returned value into a bool.
426
+
427
+ QSettings on some platforms returns the literal string ``"true"`` /
428
+ ``"false"`` for boolean values, so we accept both Python bools and
429
+ the lowercased string forms.
430
+ """
431
+ if isinstance(value, bool):
432
+ return value
433
+ if isinstance(value, str):
434
+ return value.strip().lower() in ("true", "1", "yes", "on")
435
+ if isinstance(value, (int, float)):
436
+ return bool(value)
437
+ return False
438
+
439
+
440
+ def _default_config() -> AppConfig:
441
+ """Return a fully-populated default AppConfig (used on read failure)."""
442
+ mode = FLIGHT_MODES[DEFAULT_FLIGHT_MODE]
443
+ return AppConfig(
444
+ theme_name=DEFAULT_THEME,
445
+ colors=dict(THEMES[DEFAULT_THEME]),
446
+ flight_mode=DEFAULT_FLIGHT_MODE,
447
+ flight_kwargs=dict(mode),
448
+ tts_provider=DEFAULT_TTS_PROVIDER,
449
+ minimax_subscription_key="",
450
+ language=detect_system_language(),
451
+ plane_preset_key="airplane",
452
+ plane_preset_params_json="",
453
+ dnd_enabled=False,
454
+ dnd_schedule_enabled=False,
455
+ dnd_schedule_start="22:00",
456
+ dnd_schedule_end="08:00",
457
+ )
@@ -0,0 +1,12 @@
1
+ """Demo notification pool used when winsdk is unavailable."""
2
+
3
+ NOTIFICATIONS = [
4
+ "Meeting with Andrew in 5 min",
5
+ "You have a new message from Mom",
6
+ "Lunch time!",
7
+ "Stand-up meeting starts now",
8
+ "Don't forget to drink water",
9
+ "Code review requested by Tom",
10
+ "Daily report due in 30 min",
11
+ "New email: Project Update",
12
+ ]