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.
- message_flight/__init__.py +0 -0
- message_flight/autostart.py +41 -0
- message_flight/config.py +457 -0
- message_flight/demo_notifications.py +12 -0
- message_flight/flight_widget.py +470 -0
- message_flight/i18n.py +370 -0
- message_flight/notification_queue.py +72 -0
- message_flight/notification_worker.py +98 -0
- message_flight/plane_banner.py +359 -0
- message_flight/plane_presets/__init__.py +26 -0
- message_flight/plane_presets/airplane.py +103 -0
- message_flight/plane_presets/base.py +35 -0
- message_flight/plane_presets/bird.py +87 -0
- message_flight/plane_presets/rocket.py +94 -0
- message_flight/plane_presets/ufo.py +73 -0
- message_flight/preset_editor.py +345 -0
- message_flight/settings_dialog.py +266 -0
- message_flight/tray_app.py +280 -0
- message_flight/tts.py +298 -0
- message_flight/tts_manager.py +98 -0
- messageflight-0.2.4.dist-info/METADATA +94 -0
- messageflight-0.2.4.dist-info/RECORD +24 -0
- messageflight-0.2.4.dist-info/WHEEL +4 -0
- messageflight-0.2.4.dist-info/licenses/LICENSE +21 -0
|
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)
|
message_flight/config.py
ADDED
|
@@ -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
|
+
]
|