echoact 0.1.0__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.
Files changed (80) hide show
  1. echoact/__init__.py +3 -0
  2. echoact/__main__.py +117 -0
  3. echoact/app.py +315 -0
  4. echoact/audio/__init__.py +0 -0
  5. echoact/audio/devices.py +192 -0
  6. echoact/audio/player.py +611 -0
  7. echoact/audio/wav.py +854 -0
  8. echoact/config/__init__.py +0 -0
  9. echoact/config/budget.py +370 -0
  10. echoact/config/settings.py +1244 -0
  11. echoact/db/__init__.py +0 -0
  12. echoact/db/backup.py +2429 -0
  13. echoact/db/migrations.py +434 -0
  14. echoact/db/schema.sql +214 -0
  15. echoact/db/store.py +2062 -0
  16. echoact/diagnostics.py +902 -0
  17. echoact/domain.py +487 -0
  18. echoact/engine/__init__.py +0 -0
  19. echoact/engine/container.py +843 -0
  20. echoact/engine/protocol.py +241 -0
  21. echoact/engine/runtime.py +324 -0
  22. echoact/engine/supervisor.py +961 -0
  23. echoact/engine/worker.py +659 -0
  24. echoact/errors.py +281 -0
  25. echoact/instance.py +172 -0
  26. echoact/jobs/__init__.py +0 -0
  27. echoact/jobs/engine.py +776 -0
  28. echoact/jobs/request.py +300 -0
  29. echoact/mcp/__init__.py +0 -0
  30. echoact/mcp/__main__.py +50 -0
  31. echoact/mcp/client.py +202 -0
  32. echoact/mcp/config.py +112 -0
  33. echoact/mcp/server.py +340 -0
  34. echoact/models/__init__.py +0 -0
  35. echoact/models/catalog.py +273 -0
  36. echoact/models/manifest.py +278 -0
  37. echoact/models/registry.py +1551 -0
  38. echoact/paths.py +93 -0
  39. echoact/policy.py +189 -0
  40. echoact/security/__init__.py +0 -0
  41. echoact/security/credentials.py +930 -0
  42. echoact/security/ratelimit.py +534 -0
  43. echoact/service/__init__.py +20 -0
  44. echoact/service/app.py +182 -0
  45. echoact/service/deps.py +563 -0
  46. echoact/service/errors.py +241 -0
  47. echoact/service/routes.py +1125 -0
  48. echoact/service/schemas.py +509 -0
  49. echoact/service/server.py +270 -0
  50. echoact/text/__init__.py +0 -0
  51. echoact/text/language.py +44 -0
  52. echoact/text/loader.py +577 -0
  53. echoact/text/normalize.py +924 -0
  54. echoact/text/segment.py +499 -0
  55. echoact/text/sniff.py +1202 -0
  56. echoact/ui/__init__.py +0 -0
  57. echoact/ui/bridge.py +50 -0
  58. echoact/ui/controls.py +360 -0
  59. echoact/ui/credential_dialog.py +131 -0
  60. echoact/ui/fonts.py +94 -0
  61. echoact/ui/i18n.py +260 -0
  62. echoact/ui/icons.py +440 -0
  63. echoact/ui/library.py +1642 -0
  64. echoact/ui/licence.py +162 -0
  65. echoact/ui/main_window.py +1202 -0
  66. echoact/ui/mcp_setup.py +494 -0
  67. echoact/ui/models_view.py +1142 -0
  68. echoact/ui/notifications.py +202 -0
  69. echoact/ui/reading.py +494 -0
  70. echoact/ui/settings_view.py +2258 -0
  71. echoact/ui/status_view.py +1193 -0
  72. echoact/ui/theme.py +579 -0
  73. echoact/util/__init__.py +0 -0
  74. echoact/util/ids.py +62 -0
  75. echoact/util/logging.py +127 -0
  76. echoact-0.1.0.dist-info/METADATA +162 -0
  77. echoact-0.1.0.dist-info/RECORD +80 -0
  78. echoact-0.1.0.dist-info/WHEEL +4 -0
  79. echoact-0.1.0.dist-info/entry_points.txt +3 -0
  80. echoact-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,2258 @@
1
+ """The settings screen: everything the owner can change, in one scrolling page.
2
+
3
+ The screen is a stack of panels rather than a tab strip. N-30 requires the
4
+ main features to stay reachable at 1280x720 with 100-200% OS scaling, and a
5
+ tab strip solves that by hiding five sixths of the screen; a single column
6
+ that scrolls keeps everything reachable at any scale and needs no second
7
+ navigation model for the keyboard to walk.
8
+
9
+ It is a *view*. It holds no ``Application``, writes no file, and starts no
10
+ job: every edit leaves as a signal carrying the new value, and the window
11
+ turns that into ``Application.update_settings`` or into the one call that
12
+ belongs to it (issuing a credential, restarting the service, deleting a
13
+ scope). That split is what lets F-78 be honest -- the screen can show a
14
+ value the running job is *not* using, because it never applied it itself --
15
+ and it is what keeps the destructive paths in one place where the
16
+ confirmation and the doing sit next to each other.
17
+
18
+ Two seams exist for testing and for honesty about modality:
19
+ :attr:`SettingsView.confirm` and :attr:`SettingsView.ask_text`. Both default
20
+ to Qt dialogs; a caller may replace them. Nothing else in this module opens
21
+ a dialog, so every confirmation F-76 and F-66 require passes through one
22
+ function.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import os
28
+ import time
29
+ import weakref
30
+ from collections.abc import Callable, Iterable, Sequence
31
+ from dataclasses import dataclass
32
+ from enum import StrEnum
33
+ from pathlib import Path
34
+ from typing import TYPE_CHECKING, Any, Final
35
+
36
+ import psutil
37
+ from PySide6.QtCore import Qt, Signal
38
+ from PySide6.QtWidgets import (
39
+ QCheckBox,
40
+ QComboBox,
41
+ QDoubleSpinBox,
42
+ QFrame,
43
+ QGridLayout,
44
+ QHBoxLayout,
45
+ QInputDialog,
46
+ QLabel,
47
+ QLineEdit,
48
+ QListWidget,
49
+ QListWidgetItem,
50
+ QMessageBox,
51
+ QPushButton,
52
+ QScrollArea,
53
+ QSizePolicy,
54
+ QSlider,
55
+ QSpinBox,
56
+ QTreeWidget,
57
+ QTreeWidgetItem,
58
+ QVBoxLayout,
59
+ QWidget,
60
+ )
61
+
62
+ from .. import __version__
63
+ from ..audio import devices as audio_devices
64
+ from ..audio.devices import OutputDevice
65
+ from ..config.budget import default_memory_bytes, memory_ceiling_bytes
66
+ from ..config.settings import (
67
+ DisplayLanguage,
68
+ ResourcePolicyView,
69
+ Settings,
70
+ VoicePreset,
71
+ )
72
+ from ..domain import Budget, Capability
73
+ from ..errors import EchoActError
74
+ from ..models.catalog import MANIFEST
75
+ from ..paths import log_dir, model_cache_dir, temp_dir
76
+ from ..policy import (
77
+ API_PREFIX,
78
+ CPU_PERCENT_DEFAULT,
79
+ CPU_PERCENT_MAX,
80
+ CPU_PERCENT_MIN,
81
+ CREDENTIAL_DAYS_MAX,
82
+ CREDENTIAL_DAYS_MIN,
83
+ GB,
84
+ GIB,
85
+ MEMORY_FLOOR_BYTES,
86
+ REST_HOST,
87
+ RETENTION_DEFAULT_BYTES,
88
+ RETENTION_MAX_BYTES,
89
+ RETENTION_MIN_BYTES,
90
+ VOICE_PRESET_MAX,
91
+ )
92
+ from ..security.credentials import Credential, CredentialStatus
93
+ from . import icons
94
+ from .i18n import add_korean, bytes_size, memory_size, on_change, tr
95
+ from .theme import METRICS, Palette
96
+
97
+ if TYPE_CHECKING: # pragma: no cover - imports for types only
98
+ from ..db.store import Store
99
+ from ..models.registry import ModelRegistry
100
+
101
+ #: The lowest port a standard user account can bind, not a product policy.
102
+ #: N-17 pins the address to loopback, so only the port is the owner's to pick.
103
+ MIN_USER_PORT: Final = 1024
104
+ MAX_PORT: Final = 65535
105
+
106
+ #: The author credit shown at the top of the screen. It is the same in
107
+ #: every language, so it is deliberately absent from the translation table.
108
+ AUTHOR: Final = "Huijae Lee (ZeroAct)"
109
+ AUTHOR_GITHUB_URL: Final = "https://github.com/ZeroAct"
110
+ AUTHOR_GITHUB_LABEL: Final = "github.com/ZeroAct"
111
+
112
+ #: F-75's change history. There is no changelog file in the repository yet,
113
+ #: so the history lives beside the screen that shows it rather than being
114
+ #: invented at display time; a release that adds one should read it instead.
115
+ CHANGE_HISTORY: Final[tuple[tuple[str, str, tuple[str, ...]], ...]] = (
116
+ (
117
+ "0.1.0",
118
+ "2026-09-11",
119
+ (
120
+ "Korean and English speech generation that runs entirely on this computer.",
121
+ "Local REST and MCP integrations, reachable from this computer only.",
122
+ "Documents, history, and audio kept under a retention limit you set.",
123
+ ),
124
+ ),
125
+ )
126
+
127
+
128
+ add_korean(
129
+ {
130
+ # -- resources -----------------------------------------------------
131
+ "Resources": "자원",
132
+ "How much of this computer EchoAct may use while it generates speech.":
133
+ "음성을 생성하는 동안 에코액트가 사용할 수 있는 이 컴퓨터의 자원입니다.",
134
+ "Processor share": "프로세서 사용률",
135
+ "10-70% of this computer's total processor capacity. The default is 20%.":
136
+ "이 컴퓨터 전체 프로세서 용량의 10~70%입니다. 기본값은 20%입니다.",
137
+ "Memory limit": "메모리 한도",
138
+ "Use the amount recommended for this computer ({size})":
139
+ "이 컴퓨터에 권장되는 값 사용 ({size})",
140
+ "At most {ceiling} on this computer: 32 GiB, or half of installed memory, "
141
+ "whichever is smaller.":
142
+ "이 컴퓨터에서는 최대 {ceiling}입니다. 32GiB와 설치된 메모리의 절반 중 작은 값입니다.",
143
+ "This computer has less memory than EchoAct needs to load a model ({floor}).":
144
+ "이 컴퓨터의 메모리가 모델을 불러오는 데 필요한 값({floor})보다 적습니다.",
145
+ "Configured: {cpu}% processor, {memory} memory":
146
+ "설정값: 프로세서 {cpu}%, 메모리 {memory}",
147
+ "Applied to the job running now: {cpu}% processor, {memory} memory":
148
+ "지금 실행 중인 작업에 적용된 값: 프로세서 {cpu}%, 메모리 {memory}",
149
+ "No job is running. These values apply to the next job.":
150
+ "실행 중인 작업이 없습니다. 이 값은 다음 작업에 적용됩니다.",
151
+ "The applied memory can be lower than the configured value when the computer "
152
+ "has little free memory.":
153
+ "컴퓨터의 여유 메모리가 적으면 적용되는 메모리가 설정값보다 낮을 수 있습니다.",
154
+ "This change applies to the next job.": "이 변경은 다음 작업부터 적용됩니다.",
155
+ "Apply to the next job": "다음 작업에 적용",
156
+ "Cancel the job and apply now": "작업을 취소하고 지금 적용",
157
+ "Cancel the running job?": "실행 중인 작업을 취소할까요?",
158
+ "Applying the new limits now cancels the job that is running. Audio already "
159
+ "generated stays available; the rest is not generated.":
160
+ "새 한도를 지금 적용하면 실행 중인 작업이 취소됩니다. 이미 생성된 오디오는 그대로 "
161
+ "남고 나머지는 생성되지 않습니다.",
162
+ # -- audio ---------------------------------------------------------
163
+ "Audio": "오디오",
164
+ "Output device": "출력 장치",
165
+ "System default output": "시스템 기본 출력",
166
+ "{name} (not connected)": "{name} (연결되지 않음)",
167
+ "The audio system could not be listed: {reason}":
168
+ "오디오 장치 목록을 읽지 못했습니다: {reason}",
169
+ "Look for devices again": "장치 다시 찾기",
170
+ "Volume": "음량",
171
+ "Mute": "음소거",
172
+ "Volume changes what you hear now. It does not change the saved WAV file.":
173
+ "음량은 지금 들리는 소리만 바꿉니다. 저장된 WAV 파일은 바뀌지 않습니다.",
174
+ # -- reading and display -------------------------------------------
175
+ "Reading and display": "읽기와 표시",
176
+ "Display language": "표시 언어",
177
+ "Changing the display language does not change your documents, saved jobs, "
178
+ "or what the local service answers.":
179
+ "표시 언어를 바꿔도 문서, 저장된 작업, 로컬 서비스의 응답은 바뀌지 않습니다.",
180
+ # -- integrations ---------------------------------------------------
181
+ "Integrations": "연동",
182
+ "Local REST service": "로컬 REST 서비스",
183
+ "Listening on {host}:{port} — this computer only. Every request needs a "
184
+ "credential.":
185
+ "{host}:{port}에서 대기 중이며 이 컴퓨터에서만 접근할 수 있습니다. 모든 요청에는 "
186
+ "자격 증명이 필요합니다.",
187
+ "MCP works through this service, so turning it off also makes MCP unavailable.":
188
+ "MCP는 이 서비스를 통해 동작하므로, 이 서비스를 끄면 MCP도 사용할 수 없습니다.",
189
+ "Turn the local service off?": "로컬 서비스를 끌까요?",
190
+ "MCP is a client of this service, so turning it off makes MCP unavailable "
191
+ "too. Generation, playback, and the library keep working.":
192
+ "MCP는 이 서비스의 클라이언트이므로 서비스를 끄면 MCP도 사용할 수 없습니다. 생성, "
193
+ "재생, 라이브러리는 그대로 사용할 수 있습니다.",
194
+ "Turn off": "끄기",
195
+ "Port": "포트",
196
+ "Restart the service on this port": "이 포트로 서비스 다시 시작",
197
+ "Port {port} is in use by another program. EchoAct does not close it and "
198
+ "does not pick another port on its own.":
199
+ "포트 {port}는 다른 프로그램이 사용 중입니다. 에코액트는 그 프로그램을 종료하지 "
200
+ "않으며 다른 포트를 임의로 선택하지도 않습니다.",
201
+ "Use port {port} instead": "대신 포트 {port} 사용",
202
+ "Use a different port": "다른 포트 사용",
203
+ "MCP server": "MCP 서버",
204
+ "Off by default. It exposes nothing the REST service does not already expose.":
205
+ "기본값은 꺼짐입니다. REST 서비스가 제공하지 않는 기능은 제공하지 않습니다.",
206
+ "New credentials expire after": "새 자격 증명 유효 기간",
207
+ "days": "일",
208
+ "Clients connect to {url} with their credential. Nothing else on the network "
209
+ "can reach it.":
210
+ "클라이언트는 자격 증명을 사용해 {url}에 연결합니다. 네트워크의 다른 어떤 것도 "
211
+ "접근할 수 없습니다.",
212
+ "Started by your MCP client over stdio, using its own REST credential.":
213
+ "MCP 클라이언트가 stdio로 실행하며, 자체 REST 자격 증명을 사용합니다.",
214
+ "Permissions for the next credential": "다음에 발급할 자격 증명의 권한",
215
+ "Permissions for {name}": "{name}의 권한",
216
+ "Clients": "클라이언트",
217
+ "Name": "이름",
218
+ "Permissions": "권한",
219
+ "Last access": "마지막 접근",
220
+ "Expires": "만료",
221
+ "never": "없음",
222
+ "Owner (every permission)": "소유자 (모든 권한)",
223
+ "Generate": "생성",
224
+ "Read results": "결과 읽기",
225
+ "Read history": "기록 읽기",
226
+ "Revoked": "해지됨",
227
+ "Expired": "만료됨",
228
+ "{date} (in {days} days)": "{date} ({days}일 남음)",
229
+ "No clients yet.": "아직 클라이언트가 없습니다.",
230
+ "Issue a credential": "자격 증명 발급",
231
+ "Reissue": "재발급",
232
+ "Revoke": "해지",
233
+ "A credential for {name} expires in {days} days. Reissue it before then.":
234
+ "{name}의 자격 증명이 {days}일 후 만료됩니다. 그 전에 재발급하세요.",
235
+ "Name this client": "클라이언트 이름",
236
+ "What is connecting? For example, an editor or a script.":
237
+ "무엇이 연결되나요? 예를 들어 편집기나 스크립트입니다.",
238
+ "Copy this credential now. It is shown once.": "이 자격 증명을 지금 복사하세요. 한 번만 표시됩니다.",
239
+ "EchoAct keeps only a verifier, from which the credential cannot be "
240
+ "recovered. If it is lost, reissue it.":
241
+ "에코액트는 검증값만 보관하며, 여기에서 자격 증명을 복원할 수 없습니다. 잃어버리면 "
242
+ "재발급하세요.",
243
+ "Copied": "복사됨",
244
+ "Revoke this credential?": "이 자격 증명을 해지할까요?",
245
+ "{name} loses access immediately, including to results it already "
246
+ "generated, and its running job is canceled.":
247
+ "{name}은(는) 이미 생성한 결과를 포함해 즉시 접근할 수 없게 되며, 실행 중인 작업은 "
248
+ "취소됩니다.",
249
+ "Reissue this credential?": "이 자격 증명을 재발급할까요?",
250
+ "The current credential for {name} stops working at once. The new one is "
251
+ "shown once, here.":
252
+ "{name}의 현재 자격 증명은 즉시 사용할 수 없게 됩니다. 새 자격 증명은 여기에 한 번만 "
253
+ "표시됩니다.",
254
+ # -- storage ---------------------------------------------------------
255
+ "Storage": "저장 공간",
256
+ "Model cache": "모델 캐시",
257
+ "Documents and history": "문서와 기록",
258
+ "Temporary data": "임시 데이터",
259
+ "Logs": "로그",
260
+ "Retention limit": "보관 한도",
261
+ "1-100 GB for documents, history, and audio together. Lowering it below "
262
+ "what is stored keeps that data and refuses new retention.":
263
+ "문서, 기록, 오디오를 합쳐 1~100GB입니다. 이미 저장된 양보다 낮추면 기존 데이터는 "
264
+ "유지되고 새로운 보관만 거부됩니다.",
265
+ "{used} of {limit} used": "{limit} 중 {used} 사용",
266
+ "Clean up expired data": "만료된 데이터 정리",
267
+ "Data in use by playback, generation, or a backup is left alone.":
268
+ "재생, 생성, 백업이 사용 중인 데이터는 건드리지 않습니다.",
269
+ "Removed {n} items, freeing {size}.": "{n}개 항목을 지워 {size}를 확보했습니다.",
270
+ "Removed 1 item, freeing {size}.": "항목 1개를 지워 {size}를 확보했습니다.",
271
+ "Nothing had expired.": "만료된 항목이 없습니다.",
272
+ "Reset voice and display settings": "음성과 표시 설정 초기화",
273
+ "Voice, tempo, style, display language, and playback options return to "
274
+ "their defaults. Documents, history, and audio are untouched.":
275
+ "음성, 속도, 스타일, 표시 언어, 재생 옵션이 기본값으로 돌아갑니다. 문서, 기록, "
276
+ "오디오는 그대로 유지됩니다.",
277
+ "Revoke integration permissions": "연동 권한 해지",
278
+ "Every client credential stops working at once and their running jobs are "
279
+ "canceled. A new owner credential is issued on the next launch.":
280
+ "모든 클라이언트 자격 증명이 즉시 무효화되고 실행 중인 작업은 취소됩니다. 새 소유자 "
281
+ "자격 증명은 다음 실행 시 발급됩니다.",
282
+ "Delete retained data": "보관된 데이터 삭제",
283
+ "Saved documents, job history, and generated audio are deleted from this "
284
+ "app and cannot be recovered. Files you exported yourself and your own "
285
+ "backups are not touched.":
286
+ "저장된 문서, 작업 기록, 생성된 오디오가 이 앱에서 삭제되며 복구할 수 없습니다. "
287
+ "사용자가 직접 내보낸 파일과 백업은 삭제되지 않습니다.",
288
+ "Delete the model cache": "모델 캐시 삭제",
289
+ "The downloaded model files are deleted, about {size}. Generation needs "
290
+ "them downloaded again before it can run, which needs a network.":
291
+ "내려받은 모델 파일 약 {size}가 삭제됩니다. 다시 생성하려면 네트워크를 통해 다시 "
292
+ "내려받아야 합니다.",
293
+ "Delete": "삭제",
294
+ "Reset": "초기화",
295
+ "Revoke all": "모두 해지",
296
+ "This cannot be undone.": "이 작업은 되돌릴 수 없습니다.",
297
+ "These items could not be deleted: {items}": "다음 항목은 삭제하지 못했습니다: {items}",
298
+ # -- presets -----------------------------------------------------------
299
+ "Voice presets": "음성 사전 설정",
300
+ "{n} of {max} saved": "{max}개 중 {n}개 저장됨",
301
+ "No presets yet.": "아직 사전 설정이 없습니다.",
302
+ "{name} — this model is not available": "{name} — 이 모델을 사용할 수 없습니다",
303
+ "{name} — this voice is not available": "{name} — 이 음성을 사용할 수 없습니다",
304
+ "The limit of {max} presets has been reached.": "사전 설정 {max}개 한도에 도달했습니다.",
305
+ "Apply": "적용",
306
+ "Rename": "이름 변경",
307
+ "Save the current voice settings as": "현재 음성 설정을 다음 이름으로 저장",
308
+ "A name for this preset": "사전 설정 이름",
309
+ "Replace the preset {name}?": "사전 설정 {name}을(를) 바꿀까요?",
310
+ "A preset with this name already exists. Saving replaces its settings.":
311
+ "같은 이름의 사전 설정이 이미 있습니다. 저장하면 설정이 바뀝니다.",
312
+ "A preset with this name already exists. Renaming replaces its settings.":
313
+ "같은 이름의 사전 설정이 이미 있습니다. 이름을 바꾸면 그 설정이 바뀝니다.",
314
+ "Replace": "바꾸기",
315
+ "Delete the preset {name}?": "사전 설정 {name}을(를) 삭제할까요?",
316
+ "New name": "새 이름",
317
+ # -- about ---------------------------------------------------------------
318
+ "About": "정보",
319
+ "Installed version {version}": "설치된 버전 {version}",
320
+ "Check for a released version": "출시된 버전 확인",
321
+ "Nothing is downloaded, installed, or restarted without your consent. "
322
+ "Checking sends only a version query.":
323
+ "사용자의 동의 없이는 아무것도 내려받거나 설치하거나 다시 시작하지 않습니다. 확인 "
324
+ "시에는 버전 조회만 보냅니다.",
325
+ "Version {version} has been released.": "버전 {version}이(가) 출시되었습니다.",
326
+ "This is the newest released version.": "현재 최신 출시 버전입니다.",
327
+ "The released version could not be checked: {reason}":
328
+ "출시된 버전을 확인하지 못했습니다: {reason}",
329
+ "Change history": "변경 내역",
330
+ "Korean and English speech generation that runs entirely on this computer.":
331
+ "이 컴퓨터에서만 동작하는 한국어와 영어 음성 생성.",
332
+ "Local REST and MCP integrations, reachable from this computer only.":
333
+ "이 컴퓨터에서만 접근할 수 있는 로컬 REST 및 MCP 연동.",
334
+ "Documents, history, and audio kept under a retention limit you set.":
335
+ "사용자가 정한 보관 한도 안에서 유지되는 문서, 기록, 오디오.",
336
+ # -- shared ----------------------------------------------------------------
337
+ "Settings": "설정",
338
+ "Everything EchoAct remembers between launches.":
339
+ "에코액트가 실행 사이에 기억하는 모든 설정입니다.",
340
+ "Refresh": "새로 고침",
341
+ "Done": "완료",
342
+ }
343
+ )
344
+
345
+
346
+ class ResetScope(StrEnum):
347
+ """F-76's four scopes, offered separately and never as one button."""
348
+
349
+ VOICE_AND_DISPLAY = "voice_and_display"
350
+ INTEGRATIONS = "integrations"
351
+ RETAINED_DATA = "retained_data"
352
+ MODEL_CACHE = "model_cache"
353
+
354
+
355
+ @dataclass(frozen=True, slots=True)
356
+ class Machine:
357
+ """The two machine facts F-20 and F-21 are expressed against.
358
+
359
+ Passed in rather than read at every repaint so that a test can describe
360
+ an 8 GiB laptop and a 128 GiB workstation without owning either.
361
+ """
362
+
363
+ total_ram_bytes: int
364
+ logical_cpus: int
365
+
366
+ @classmethod
367
+ def detect(cls) -> Machine:
368
+ return cls(
369
+ total_ram_bytes=int(psutil.virtual_memory().total),
370
+ logical_cpus=int(psutil.cpu_count(logical=True) or 1),
371
+ )
372
+
373
+
374
+ @dataclass(frozen=True, slots=True)
375
+ class StorageSizes:
376
+ """F-73's five figures, each measured apart from the others.
377
+
378
+ They are not summed into a single "app data" number anywhere: F-73 asks
379
+ for them separately because the answers to "why is this large" and "what
380
+ may I delete" differ per line -- the model cache is re-downloadable, logs
381
+ expire on their own, and retained data is the only one the retention
382
+ limit governs.
383
+ """
384
+
385
+ model_cache_bytes: int
386
+ documents_bytes: int
387
+ audio_bytes: int
388
+ temp_bytes: int
389
+ log_bytes: int
390
+ retained_used_bytes: int
391
+ retention_limit_bytes: int
392
+
393
+
394
+ def directory_bytes(root: Path) -> int:
395
+ """Bytes under a directory, ignoring what cannot be read.
396
+
397
+ A settings screen that raises because a log file vanished mid-walk would
398
+ be worse than one that under-reports by that file's size, so every
399
+ ``OSError`` here is skipped rather than propagated.
400
+ """
401
+ total = 0
402
+ stack = [root]
403
+ while stack:
404
+ current = stack.pop()
405
+ try:
406
+ entries = list(os.scandir(current))
407
+ except OSError:
408
+ continue
409
+ for entry in entries:
410
+ try:
411
+ if entry.is_dir(follow_symlinks=False):
412
+ stack.append(Path(entry.path))
413
+ elif entry.is_file(follow_symlinks=False):
414
+ total += entry.stat(follow_symlinks=False).st_size
415
+ except OSError:
416
+ continue
417
+ return total
418
+
419
+
420
+ def storage_snapshot(
421
+ store: Store | None = None, registry: ModelRegistry | None = None
422
+ ) -> StorageSizes:
423
+ """Measure F-73's five figures now.
424
+
425
+ Called by the window, not by the view: it walks directories, and the
426
+ view must not decide when the main thread does that. The database half
427
+ comes from ``Store.storage_usage`` rather than from a second walk of the
428
+ audio tree, because that is the number the retention limit is enforced
429
+ against and two disagreeing figures would be worse than one.
430
+ """
431
+ if store is None:
432
+ documents = audio = used = 0
433
+ limit = RETENTION_DEFAULT_BYTES
434
+ else:
435
+ usage = store.storage_usage()
436
+ documents = usage.document_bytes + usage.job_text_bytes
437
+ audio = usage.audio_bytes
438
+ used = usage.total_bytes
439
+ limit = usage.limit_bytes
440
+ cache = registry.total_disk_usage() if registry is not None else directory_bytes(
441
+ model_cache_dir()
442
+ )
443
+ return StorageSizes(
444
+ model_cache_bytes=cache,
445
+ documents_bytes=documents,
446
+ audio_bytes=audio,
447
+ temp_bytes=directory_bytes(temp_dir()),
448
+ log_bytes=directory_bytes(log_dir()),
449
+ retained_used_bytes=used,
450
+ retention_limit_bytes=limit,
451
+ )
452
+
453
+
454
+ _CAPABILITY_TEXT: Final[dict[Capability, str]] = {
455
+ Capability.OWNER: "Owner (every permission)",
456
+ Capability.GENERATE: "Generate",
457
+ Capability.READ_RESULTS: "Read results",
458
+ Capability.READ_HISTORY: "Read history",
459
+ }
460
+
461
+
462
+ def _default_confirm(parent: QWidget, title: str, body: str, accept: str) -> bool:
463
+ """The one modal path in this module.
464
+
465
+ The accepting button is never the default: F-76's deletions and F-78's
466
+ cancellation are acts, and a dialog whose Enter key performs them turns
467
+ an act into a reflex.
468
+ """
469
+ box = QMessageBox(parent)
470
+ box.setIcon(QMessageBox.Icon.Warning)
471
+ box.setWindowTitle(title)
472
+ box.setText(title)
473
+ box.setInformativeText(body)
474
+ go = box.addButton(accept, QMessageBox.ButtonRole.AcceptRole)
475
+ keep = box.addButton(tr("Cancel"), QMessageBox.ButtonRole.RejectRole)
476
+ box.setDefaultButton(keep)
477
+ box.exec()
478
+ return box.clickedButton() is go
479
+
480
+
481
+ def _default_ask_text(parent: QWidget, title: str, prompt: str, value: str) -> str | None:
482
+ text, ok = QInputDialog.getText(parent, title, prompt, QLineEdit.EchoMode.Normal, value)
483
+ return text if ok else None
484
+
485
+
486
+ def _date(at: float | None) -> str:
487
+ if at is None:
488
+ return ""
489
+ return time.strftime("%Y-%m-%d", time.localtime(at))
490
+
491
+
492
+ def _date_time(at: float | None) -> str:
493
+ if at is None:
494
+ return ""
495
+ return time.strftime("%Y-%m-%d %H:%M", time.localtime(at))
496
+
497
+
498
+ class SettingsView(QScrollArea):
499
+ """F-20, F-21, F-24, F-46, F-66, F-67, F-71, F-73, F-75, F-76, F-78, F-79,
500
+ F-83, F-86, N-30 and Section 4.1's operational defaults, on one page.
501
+
502
+ Every signal carries the new value in the type the settings field holds,
503
+ so the window's slot is ``update_settings(field=value)`` and no widget
504
+ state has to be read back out of here.
505
+ """
506
+
507
+ # -- resources (F-20, F-21, F-78) -------------------------------------
508
+ cpu_changed = Signal(int)
509
+ memory_changed = Signal(object) # int bytes, or None for F-21's default
510
+ apply_now_requested = Signal()
511
+
512
+ # -- audio (F-67) -------------------------------------------------------
513
+ output_device_changed = Signal(object) # OutputDevice.key, or None
514
+ devices_refresh_requested = Signal()
515
+ volume_changed = Signal(float)
516
+ muted_changed = Signal(bool)
517
+
518
+ # -- reading and display (F-83, F-30, F-86) -----------------------------
519
+ autoplay_changed = Signal(bool)
520
+ follow_changed = Signal(bool)
521
+ #: A ``DisplayLanguage``, not its bare ``str`` value. ``Settings``
522
+ #: holds the enum and ``Settings.with_`` is ``dataclasses.replace``,
523
+ #: which coerces nothing: a ``"ko"`` stored through it turns the field
524
+ #: into a plain string, and every ``.value`` read on it afterwards --
525
+ #: ``Settings.to_dict`` when saving, and ``_render_languages`` here --
526
+ #: raises. F-86 would then neither persist nor retranslate.
527
+ display_language_changed = Signal(object)
528
+
529
+ # -- integrations (F-46, F-71, F-79) ------------------------------------
530
+ rest_enabled_changed = Signal(bool)
531
+ rest_port_changed = Signal(int)
532
+ mcp_enabled_changed = Signal(bool)
533
+ credential_days_changed = Signal(int)
534
+ credential_issue_requested = Signal(str, object, int) # name, capabilities, days
535
+ credential_capabilities_changed = Signal(str, object) # ref, capabilities
536
+ credential_reissue_requested = Signal(str) # ref
537
+ credential_revoke_requested = Signal(str) # ref
538
+
539
+ # -- storage (F-73, F-76) ------------------------------------------------
540
+ #: Bytes, as ``object``: Section 4.1's 100 GB maximum does not fit the
541
+ #: 32-bit C++ ``int`` a ``Signal(int)`` marshals through, and Qt truncates
542
+ #: rather than refusing -- the owner would set 100 GB and be given 1.4 GB.
543
+ retention_changed = Signal(object)
544
+ cleanup_requested = Signal()
545
+ storage_refresh_requested = Signal()
546
+ reset_requested = Signal(str) # a ResetScope value
547
+
548
+ # -- presets (F-66) --------------------------------------------------------
549
+ preset_apply_requested = Signal(str)
550
+ preset_save_requested = Signal(str)
551
+ preset_rename_requested = Signal(str, str)
552
+ preset_delete_requested = Signal(str)
553
+
554
+ # -- about (F-75) ------------------------------------------------------------
555
+ version_check_requested = Signal()
556
+
557
+ def __init__(
558
+ self,
559
+ palette: Palette,
560
+ settings: Settings,
561
+ *,
562
+ machine: Machine | None = None,
563
+ device_lister: Callable[[], Sequence[OutputDevice]] | None = None,
564
+ parent: QWidget | None = None,
565
+ ) -> None:
566
+ super().__init__(parent)
567
+ self._palette = palette
568
+ self._settings = settings
569
+ self._machine = machine or Machine.detect()
570
+ self._device_lister = device_lister or audio_devices.list_output_devices
571
+
572
+ self.confirm: Callable[[str, str, str], bool] = (
573
+ lambda title, body, accept: _default_confirm(self, title, body, accept)
574
+ )
575
+ self.ask_text: Callable[[str, str, str], str | None] = (
576
+ lambda title, prompt, value: _default_ask_text(self, title, prompt, value)
577
+ )
578
+
579
+ self._loading = False
580
+ self._translatables: list[tuple[QWidget, str, str, dict[str, Any]]] = []
581
+ self._applied: Budget | None = None
582
+ self._job_running = False
583
+ self._pending_resource_change = False
584
+ self._devices: list[OutputDevice] = []
585
+ self._device_error = ""
586
+ self._credentials: list[Credential] = []
587
+ # F-61 authorises the three separately, so the set a new credential
588
+ # gets is a choice made before issuing rather than a constant. The
589
+ # two an integration needs to be useful are on by default; reading
590
+ # the owner's retained history is not.
591
+ self._pending_capabilities = frozenset(
592
+ {Capability.GENERATE, Capability.READ_RESULTS}
593
+ )
594
+ self._sizes: StorageSizes | None = None
595
+ self._presets: tuple[VoicePreset, ...] = settings.presets
596
+ self._conflict_port: int | None = None
597
+ # The port the service is on, as far as this screen has been told.
598
+ # It is not ``self.port.value()``: that is the port the owner has
599
+ # *typed*, and F-79 binds nothing until they press restart.
600
+ self._service_port = settings.rest_port
601
+ self._service_port_before: int | None = None
602
+ self._unavailable: set[str] = set()
603
+
604
+ self.setObjectName("Root")
605
+ self.setWidgetResizable(True)
606
+ self.setFrameShape(QScrollArea.Shape.NoFrame)
607
+ # Horizontal scrolling is left on rather than forced off: if a future
608
+ # panel does outgrow 1280x720 at 200% scaling, N-30 prefers a
609
+ # scrollbar to a clipped control, and the test that would then fail
610
+ # says so in one line.
611
+ self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
612
+
613
+ self.content = QWidget()
614
+ self.content.setObjectName("Root")
615
+ column = QVBoxLayout(self.content)
616
+ m = METRICS
617
+ column.setContentsMargins(m.pad_wide, m.pad_wide, m.pad_wide, m.pad_wide)
618
+ column.setSpacing(m.gap_wide)
619
+
620
+ title = QLabel()
621
+ title.setProperty("role", "title")
622
+ self._text(title, "Settings")
623
+ column.addWidget(title)
624
+ column.addWidget(
625
+ self._label("Everything EchoAct remembers between launches.", "secondary")
626
+ )
627
+ column.addWidget(self._credit_line())
628
+ column.addWidget(self._build_resources())
629
+ column.addWidget(self._build_audio())
630
+ column.addWidget(self._build_display())
631
+ column.addWidget(self._build_integrations())
632
+ column.addWidget(self._build_storage())
633
+ column.addWidget(self._build_presets())
634
+ column.addWidget(self._build_about())
635
+ column.addStretch(1)
636
+ self.setWidget(self.content)
637
+
638
+ self.apply(settings)
639
+ self.refresh_devices()
640
+ _follow_language(self)
641
+
642
+ # ==================================================================
643
+ # Small builders
644
+ # ==================================================================
645
+
646
+ def _panel(self, title: str) -> tuple[QFrame, QVBoxLayout]:
647
+ panel = QFrame()
648
+ panel.setObjectName("Panel")
649
+ panel.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum)
650
+ box = QVBoxLayout(panel)
651
+ m = METRICS
652
+ box.setContentsMargins(m.pad, m.pad, m.pad, m.pad)
653
+ box.setSpacing(m.gap)
654
+ heading = QLabel()
655
+ heading.setProperty("role", "section")
656
+ self._text(heading, title)
657
+ box.addWidget(heading)
658
+ return panel, box
659
+
660
+ def _text(self, widget: QWidget, source: str, *, setter: str = "setText", **fmt: Any) -> None:
661
+ """Set a translated string and remember how to set it again.
662
+
663
+ F-86 switches the language in place, so every literal the screen
664
+ shows is recorded here with the arguments it was formatted with.
665
+ Re-rendering from the recorded source is what keeps the switch from
666
+ needing the screen to be rebuilt -- rebuilding would lose the
667
+ scroll position and the keyboard focus the user was at.
668
+ """
669
+ self._translatables.append((widget, setter, source, fmt))
670
+ getattr(widget, setter)(tr(source).format(**fmt) if fmt else tr(source))
671
+
672
+ def _label(self, source: str, role: str | None = None, **fmt: Any) -> QLabel:
673
+ lb = QLabel()
674
+ if role:
675
+ lb.setProperty("role", role)
676
+ lb.setWordWrap(True)
677
+ self._text(lb, source, **fmt)
678
+ return lb
679
+
680
+ def _credit_line(self) -> QLabel:
681
+ """The author credit at the top of the screen.
682
+
683
+ Deliberately not recorded through :meth:`_text`: the name and the
684
+ handle are the same in every language, so recording them would only
685
+ give the translator a string they cannot improve. The link opens in
686
+ the browser rather than anywhere in-app -- EchoAct has no page to
687
+ show it in, and opening externally is what makes the one network
688
+ request it causes the user's own.
689
+ """
690
+ lb = QLabel()
691
+ lb.setProperty("role", "muted")
692
+ lb.setWordWrap(True)
693
+ lb.setTextFormat(Qt.TextFormat.RichText)
694
+ lb.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
695
+ lb.setOpenExternalLinks(True)
696
+ lb.setText(
697
+ f'{AUTHOR} · <a href="{AUTHOR_GITHUB_URL}" '
698
+ f'style="color:{self._palette.accent};">{AUTHOR_GITHUB_LABEL}</a>'
699
+ )
700
+ lb.setAccessibleName(f"{AUTHOR} — {AUTHOR_GITHUB_URL}")
701
+ return lb
702
+
703
+ def _value_label(self, role: str | None = "secondary") -> QLabel:
704
+ lb = QLabel()
705
+ if role:
706
+ lb.setProperty("role", role)
707
+ lb.setWordWrap(True)
708
+ return lb
709
+
710
+ def _field(
711
+ self, box: QVBoxLayout, name: str, widget: QWidget, note: str | None = None
712
+ ) -> None:
713
+ """A caption, a control, and an optional explanation, in that order.
714
+
715
+ The caption is a separate label rather than a placeholder or a
716
+ tooltip because N-30 needs the control to carry an accessible name
717
+ that a screen reader announces without hovering.
718
+ """
719
+ caption = self._label(name, "secondary")
720
+ box.addWidget(caption)
721
+ self._text(widget, name, setter="setAccessibleName")
722
+ box.addWidget(widget)
723
+ if note:
724
+ box.addWidget(self._label(note, "muted"))
725
+
726
+ def _bullet(self, source: str) -> QWidget:
727
+ """A list item whose mark is not part of the translated string.
728
+
729
+ Prefixing the text with the bullet would make "· Local REST and MCP
730
+ integrations" the catalogue key, which no translation would ever
731
+ match; keeping the mark in its own label also gives the wrapped
732
+ lines a hanging indent.
733
+ """
734
+ row = QWidget()
735
+ line = QHBoxLayout(row)
736
+ line.setContentsMargins(0, 0, 0, 0)
737
+ line.setSpacing(METRICS.gap)
738
+ mark = QLabel("·")
739
+ mark.setProperty("role", "muted")
740
+ mark.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft)
741
+ line.addWidget(mark)
742
+ line.addWidget(self._label(source, "muted"), 1)
743
+ return row
744
+
745
+ def _button(self, source: str, variant: str | None = None) -> QPushButton:
746
+ b = QPushButton()
747
+ if variant:
748
+ b.setProperty("variant", variant)
749
+ self._text(b, source)
750
+ self._text(b, source, setter="setAccessibleName")
751
+ b.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
752
+ return b
753
+
754
+ def _check(self, source: str, **fmt: Any) -> QCheckBox:
755
+ c = QCheckBox()
756
+ self._text(c, source, **fmt)
757
+ self._text(c, source, setter="setAccessibleName", **fmt)
758
+ return c
759
+
760
+ # ==================================================================
761
+ # Resources (F-20, F-21, F-78)
762
+ # ==================================================================
763
+
764
+ def _build_resources(self) -> QFrame:
765
+ panel, box = self._panel("Resources")
766
+ box.addWidget(
767
+ self._label(
768
+ "How much of this computer EchoAct may use while it generates speech.",
769
+ "secondary",
770
+ )
771
+ )
772
+
773
+ self.cpu = QSpinBox()
774
+ self.cpu.setRange(CPU_PERCENT_MIN, CPU_PERCENT_MAX)
775
+ self.cpu.setSingleStep(5)
776
+ self.cpu.setSuffix(" %")
777
+ self.cpu.setValue(CPU_PERCENT_DEFAULT)
778
+ self._field(
779
+ box,
780
+ "Processor share",
781
+ self.cpu,
782
+ "10-70% of this computer's total processor capacity. The default is 20%.",
783
+ )
784
+
785
+ ceiling = memory_ceiling_bytes(self._machine.total_ram_bytes)
786
+ recommended = default_memory_bytes(self._machine.total_ram_bytes)
787
+ box.addWidget(self._label("Memory limit", "secondary"))
788
+ self.memory_auto = self._check(
789
+ "Use the amount recommended for this computer ({size})",
790
+ size=memory_size(recommended),
791
+ )
792
+ self.memory = QDoubleSpinBox()
793
+ self.memory.setDecimals(1)
794
+ self.memory.setSingleStep(0.5)
795
+ self.memory.setSuffix(" GiB")
796
+ # F-23's floor is the smallest configuration that can load a model at
797
+ # all, so it is the bottom of the range rather than a value the user
798
+ # may pick and then be refused for picking. A machine whose F-21
799
+ # ceiling falls under that floor gets the ceiling as both ends and a
800
+ # plain statement, which is more honest than an empty range.
801
+ low = min(MEMORY_FLOOR_BYTES, ceiling)
802
+ self.memory.setRange(low / GIB, max(low, ceiling) / GIB)
803
+ box.addWidget(self.memory_auto)
804
+ self._text(self.memory, "Memory limit", setter="setAccessibleName")
805
+ box.addWidget(self.memory)
806
+ box.addWidget(
807
+ self._label(
808
+ "At most {ceiling} on this computer: 32 GiB, or half of installed memory, "
809
+ "whichever is smaller.",
810
+ "muted",
811
+ ceiling=memory_size(ceiling),
812
+ )
813
+ )
814
+ if ceiling < MEMORY_FLOOR_BYTES:
815
+ box.addWidget(
816
+ self._label(
817
+ "This computer has less memory than EchoAct needs to load a model "
818
+ "({floor}).",
819
+ "warn",
820
+ floor=memory_size(MEMORY_FLOOR_BYTES),
821
+ )
822
+ )
823
+
824
+ box.addWidget(_separator())
825
+ self.resources_configured = self._value_label()
826
+ self.resources_applied = self._value_label()
827
+ box.addWidget(self.resources_configured)
828
+ box.addWidget(self.resources_applied)
829
+ box.addWidget(
830
+ self._label(
831
+ "The applied memory can be lower than the configured value when the "
832
+ "computer has little free memory.",
833
+ "muted",
834
+ )
835
+ )
836
+
837
+ self.apply_row = QWidget()
838
+ row = QVBoxLayout(self.apply_row)
839
+ row.setContentsMargins(0, 0, 0, 0)
840
+ row.setSpacing(METRICS.gap_tight)
841
+ row.addWidget(self._label("This change applies to the next job.", "warn"))
842
+ buttons = QHBoxLayout()
843
+ buttons.setSpacing(METRICS.gap)
844
+ self.apply_next = self._button("Apply to the next job", "quiet")
845
+ self.apply_cancel = self._button("Cancel the job and apply now", "danger")
846
+ buttons.addWidget(self.apply_next)
847
+ buttons.addWidget(self.apply_cancel)
848
+ buttons.addStretch(1)
849
+ row.addLayout(buttons)
850
+ self.apply_row.setVisible(False)
851
+ box.addWidget(self.apply_row)
852
+
853
+ self.cpu.valueChanged.connect(self._on_cpu)
854
+ self.memory.valueChanged.connect(self._on_memory)
855
+ self.memory_auto.toggled.connect(self._on_memory_auto)
856
+ self.apply_next.clicked.connect(self._dismiss_apply_row)
857
+ self.apply_cancel.clicked.connect(self._on_apply_now)
858
+ return panel
859
+
860
+ def _on_cpu(self, value: int) -> None:
861
+ if self._loading:
862
+ return
863
+ self._note_resource_change()
864
+ self.cpu_changed.emit(int(value))
865
+
866
+ def _on_memory(self, value: float) -> None:
867
+ if self._loading or self.memory_auto.isChecked():
868
+ return
869
+ self._note_resource_change()
870
+ self.memory_changed.emit(int(round(value * GIB)))
871
+
872
+ def _on_memory_auto(self, checked: bool) -> None:
873
+ self.memory.setEnabled(not checked)
874
+ if self._loading:
875
+ return
876
+ self._note_resource_change()
877
+ if checked:
878
+ self.memory_changed.emit(None)
879
+ else:
880
+ self.memory_changed.emit(int(round(self.memory.value() * GIB)))
881
+
882
+ def _note_resource_change(self) -> None:
883
+ """F-78: a change during a job is a choice between two outcomes.
884
+
885
+ The change itself has already been saved by the time this runs --
886
+ the signal reaches ``update_settings``, which F-78 says applies to
887
+ the *next* job -- so this offers the other outcome rather than
888
+ holding the change hostage to a decision the user may not want to
889
+ make now.
890
+
891
+ The two lines above the offer are re-rendered first: the window does
892
+ not hand the saved settings back, so the "Configured:" line would
893
+ otherwise keep the value the screen was opened with while the control
894
+ beside it shows the new one.
895
+ """
896
+ self._render_resources()
897
+ if not self._job_running:
898
+ return
899
+ self._pending_resource_change = True
900
+ self.apply_row.setVisible(True)
901
+
902
+ def _dismiss_apply_row(self) -> None:
903
+ self._pending_resource_change = False
904
+ self.apply_row.setVisible(False)
905
+
906
+ def _on_apply_now(self) -> None:
907
+ if not self.confirm(
908
+ tr("Cancel the running job?"),
909
+ tr(
910
+ "Applying the new limits now cancels the job that is running. Audio "
911
+ "already generated stays available; the rest is not generated."
912
+ ),
913
+ tr("Cancel the job and apply now"),
914
+ ):
915
+ return
916
+ self._dismiss_apply_row()
917
+ self.apply_now_requested.emit()
918
+
919
+ def set_resource_state(self, applied: Budget | None, *, job_running: bool) -> None:
920
+ """F-78's two numbers: what is set, and what the job is running under.
921
+
922
+ ``applied`` is the ``Budget`` the running job recorded, not a fresh
923
+ resolution of the current settings -- resolving again would show the
924
+ user a number no job has ever used and call it "applied".
925
+ """
926
+ self._applied = applied
927
+ self._job_running = job_running
928
+ if not job_running:
929
+ self._pending_resource_change = False
930
+ self.apply_row.setVisible(job_running and self._pending_resource_change)
931
+ self._render_resources()
932
+
933
+ def _configured_memory_bytes(self) -> int | None:
934
+ """The memory limit the screen is offering, in bytes, or ``None`` for
935
+ F-21's per-machine default.
936
+
937
+ Read from the control rather than from ``self._settings`` for the same
938
+ reason as :meth:`_configured_view`, but with one deference to the
939
+ store: the box shows one decimal, so a saved 3.33 GiB reads back as
940
+ 3.3. While the box is still showing the saved figure, the saved
941
+ figure *is* the configured one -- taking the rounded one instead would
942
+ report a difference from the running job's budget that nobody made.
943
+ """
944
+ if self.memory_auto.isChecked():
945
+ return None
946
+ saved = self._settings.memory_bytes
947
+ if saved is not None and round(saved / GIB, self.memory.decimals()) == self.memory.value():
948
+ return saved
949
+ return int(round(self.memory.value() * GIB))
950
+
951
+ def _configured_view(self) -> ResourcePolicyView:
952
+ """F-78's pair, with the configured half taken from the controls.
953
+
954
+ The screen owns the configured value between the edit and the save:
955
+ an edit leaves as a signal, the window turns it into
956
+ ``update_settings``, and nothing hands the result back here. Reading
957
+ ``self._settings`` for this line would therefore show the owner the
958
+ value the screen was opened with, and ``differs`` -- which decides
959
+ whether the applied line is flagged -- would be computed against that
960
+ same stale number and stay ``False`` exactly when it should not.
961
+ """
962
+ return ResourcePolicyView(
963
+ configured_cpu_percent=int(self.cpu.value()),
964
+ configured_memory_bytes=self._configured_memory_bytes(),
965
+ applied=self._applied,
966
+ )
967
+
968
+ def _render_resources(self) -> None:
969
+ view = self._configured_view()
970
+ configured_memory = (
971
+ memory_size(view.configured_memory_bytes)
972
+ if view.configured_memory_bytes is not None
973
+ else memory_size(default_memory_bytes(self._machine.total_ram_bytes))
974
+ )
975
+ self.resources_configured.setText(
976
+ tr("Configured: {cpu}% processor, {memory} memory").format(
977
+ cpu=view.configured_cpu_percent, memory=configured_memory
978
+ )
979
+ )
980
+ if self._job_running and self._applied is not None:
981
+ self.resources_applied.setText(
982
+ tr("Applied to the job running now: {cpu}% processor, {memory} memory").format(
983
+ cpu=self._applied.cpu_percent,
984
+ memory=memory_size(self._applied.memory_bytes),
985
+ )
986
+ )
987
+ self.resources_applied.setProperty("role", "warn" if view.differs else "secondary")
988
+ else:
989
+ self.resources_applied.setText(
990
+ tr("No job is running. These values apply to the next job.")
991
+ )
992
+ self.resources_applied.setProperty("role", "secondary")
993
+ _repolish(self.resources_applied)
994
+
995
+ # ==================================================================
996
+ # Audio (F-67)
997
+ # ==================================================================
998
+
999
+ def _build_audio(self) -> QFrame:
1000
+ panel, box = self._panel("Audio")
1001
+
1002
+ self.device = QComboBox()
1003
+ self._field(box, "Output device", self.device)
1004
+ self.device_note = self._value_label("warn")
1005
+ self.device_note.setVisible(False)
1006
+ box.addWidget(self.device_note)
1007
+ self.device_refresh = self._button("Look for devices again", "quiet")
1008
+ refresh_row = QHBoxLayout()
1009
+ refresh_row.addWidget(self.device_refresh)
1010
+ refresh_row.addStretch(1)
1011
+ box.addLayout(refresh_row)
1012
+
1013
+ self.volume = QSlider(Qt.Orientation.Horizontal)
1014
+ self.volume.setRange(0, 100)
1015
+ self.volume.setSingleStep(5)
1016
+ self.volume.setPageStep(10)
1017
+ self.volume.setValue(100)
1018
+ volume_row = QHBoxLayout()
1019
+ volume_row.setSpacing(METRICS.gap)
1020
+ volume_row.addWidget(self._label("Volume", "secondary"))
1021
+ volume_row.addStretch(1)
1022
+ self.volume_value = self._value_label()
1023
+ volume_row.addWidget(self.volume_value)
1024
+ box.addLayout(volume_row)
1025
+ self._text(self.volume, "Volume", setter="setAccessibleName")
1026
+ box.addWidget(self.volume)
1027
+
1028
+ self.muted = self._check("Mute")
1029
+ box.addWidget(self.muted)
1030
+ box.addWidget(
1031
+ self._label(
1032
+ "Volume changes what you hear now. It does not change the saved WAV file.",
1033
+ "muted",
1034
+ )
1035
+ )
1036
+
1037
+ self.device.currentIndexChanged.connect(self._on_device)
1038
+ self.device_refresh.clicked.connect(self._on_device_refresh)
1039
+ self.volume.valueChanged.connect(self._on_volume)
1040
+ self.muted.toggled.connect(self._on_muted)
1041
+ return panel
1042
+
1043
+ def _on_device_refresh(self) -> None:
1044
+ self.refresh_devices()
1045
+ self.devices_refresh_requested.emit()
1046
+
1047
+ def refresh_devices(self) -> None:
1048
+ """Re-read the device list (F-67, F-68).
1049
+
1050
+ Enumeration failure is a state of the screen, not an exception:
1051
+ F-79's spirit -- a subsystem being unavailable never takes the rest
1052
+ of the app with it -- applies just as well to the audio system, and
1053
+ the user still has every other setting to change.
1054
+ """
1055
+ try:
1056
+ self._devices = list(self._device_lister())
1057
+ self._device_error = ""
1058
+ except EchoActError as exc:
1059
+ self._devices = []
1060
+ self._device_error = exc.message
1061
+ self._render_devices()
1062
+
1063
+ def _render_devices(self) -> None:
1064
+ was = self._loading
1065
+ self._loading = True
1066
+ try:
1067
+ self.device.clear()
1068
+ self.device.addItem(tr("System default output"), None)
1069
+ keys = set()
1070
+ for d in self._devices:
1071
+ self.device.addItem(f"{d.name} — {d.host_api}", d.key)
1072
+ keys.add(d.key)
1073
+ chosen = self._settings.output_device
1074
+ if chosen is not None and chosen not in keys:
1075
+ # F-67 forbids moving to another speaker without confirmation,
1076
+ # so a remembered device that is not present stays selected and
1077
+ # is labelled. Dropping it from the list would silently reselect
1078
+ # the system default, which is that same unconfirmed switch.
1079
+ self.device.addItem(
1080
+ tr("{name} (not connected)").format(name=chosen.split("::", 1)[-1]), chosen
1081
+ )
1082
+ index = self.device.findData(chosen)
1083
+ self.device.setCurrentIndex(max(0, index))
1084
+ self.device_note.setVisible(bool(self._device_error))
1085
+ if self._device_error:
1086
+ self.device_note.setText(
1087
+ tr("The audio system could not be listed: {reason}").format(
1088
+ reason=self._device_error
1089
+ )
1090
+ )
1091
+ finally:
1092
+ self._loading = was
1093
+
1094
+ def _on_device(self, _index: int) -> None:
1095
+ if self._loading:
1096
+ return
1097
+ self.output_device_changed.emit(self.device.currentData())
1098
+
1099
+ def _on_volume(self, value: int) -> None:
1100
+ self.volume_value.setText(f"{value}%")
1101
+ if self._loading:
1102
+ return
1103
+ self.volume_changed.emit(value / 100)
1104
+
1105
+ def _on_muted(self, checked: bool) -> None:
1106
+ if self._loading:
1107
+ return
1108
+ self.muted_changed.emit(bool(checked))
1109
+
1110
+ # ==================================================================
1111
+ # Reading and display (F-83, F-30, F-86)
1112
+ # ==================================================================
1113
+
1114
+ def _build_display(self) -> QFrame:
1115
+ panel, box = self._panel("Reading and display")
1116
+
1117
+ self.language = QComboBox()
1118
+ self._field(box, "Display language", self.language)
1119
+ box.addWidget(
1120
+ self._label(
1121
+ "Changing the display language does not change your documents, saved "
1122
+ "jobs, or what the local service answers.",
1123
+ "muted",
1124
+ )
1125
+ )
1126
+
1127
+ box.addWidget(_separator())
1128
+ self.autoplay = self._check("Play as soon as ready")
1129
+ self.follow = self._check("Follow the reading position")
1130
+ box.addWidget(self.autoplay)
1131
+ box.addWidget(self.follow)
1132
+
1133
+ self._render_languages()
1134
+ self.language.currentIndexChanged.connect(self._on_language)
1135
+ self.autoplay.toggled.connect(self._on_autoplay)
1136
+ self.follow.toggled.connect(self._on_follow)
1137
+ return panel
1138
+
1139
+ def _render_languages(self) -> None:
1140
+ was = self._loading
1141
+ self._loading = True
1142
+ try:
1143
+ self.language.clear()
1144
+ self.language.addItem(tr("Korean"), DisplayLanguage.KO.value)
1145
+ self.language.addItem(tr("English"), DisplayLanguage.EN.value)
1146
+ index = self.language.findData(self._settings.display_language.value)
1147
+ self.language.setCurrentIndex(max(0, index))
1148
+ finally:
1149
+ self._loading = was
1150
+
1151
+ def _on_language(self, _index: int) -> None:
1152
+ if self._loading:
1153
+ return
1154
+ # The combo carries the plain value because Qt marshals a ``str``
1155
+ # subclass into the item data as a ``str``; the enum is rebuilt here so
1156
+ # that what leaves the screen is what ``Settings.display_language``
1157
+ # holds, per this module's contract.
1158
+ self.display_language_changed.emit(DisplayLanguage(str(self.language.currentData())))
1159
+
1160
+ def _on_autoplay(self, checked: bool) -> None:
1161
+ if not self._loading:
1162
+ self.autoplay_changed.emit(bool(checked))
1163
+
1164
+ def _on_follow(self, checked: bool) -> None:
1165
+ if not self._loading:
1166
+ self.follow_changed.emit(bool(checked))
1167
+
1168
+ # ==================================================================
1169
+ # Integrations (F-46, F-71, F-79, N-31, 4.1)
1170
+ # ==================================================================
1171
+
1172
+ def _build_integrations(self) -> QFrame:
1173
+ panel, box = self._panel("Integrations")
1174
+
1175
+ self.rest = self._check("Local REST service")
1176
+ box.addWidget(self.rest)
1177
+ self.rest_note = self._value_label("muted")
1178
+ box.addWidget(self.rest_note)
1179
+ self.rest_how = self._value_label("muted")
1180
+ box.addWidget(self.rest_how)
1181
+ # F-46 wants the consequence stated at the point of the change, so it
1182
+ # is standing text beside the switch and not only in the confirmation
1183
+ # the user sees after deciding.
1184
+ box.addWidget(
1185
+ self._label(
1186
+ "MCP works through this service, so turning it off also makes MCP "
1187
+ "unavailable.",
1188
+ "muted",
1189
+ )
1190
+ )
1191
+
1192
+ self.port = QSpinBox()
1193
+ self.port.setRange(MIN_USER_PORT, MAX_PORT)
1194
+ self.port.setGroupSeparatorShown(False)
1195
+ self._field(box, "Port", self.port)
1196
+ self.port_restart = self._button("Restart the service on this port")
1197
+ port_row = QHBoxLayout()
1198
+ port_row.addWidget(self.port_restart)
1199
+ port_row.addStretch(1)
1200
+ box.addLayout(port_row)
1201
+
1202
+ self.conflict = QWidget()
1203
+ conflict_box = QVBoxLayout(self.conflict)
1204
+ conflict_box.setContentsMargins(0, 0, 0, 0)
1205
+ conflict_box.setSpacing(METRICS.gap_tight)
1206
+ self.conflict_text = self._value_label("warn")
1207
+ conflict_box.addWidget(self.conflict_text)
1208
+ self.conflict_use = self._button("Use a different port")
1209
+ conflict_row = QHBoxLayout()
1210
+ conflict_row.addWidget(self.conflict_use)
1211
+ conflict_row.addStretch(1)
1212
+ conflict_box.addLayout(conflict_row)
1213
+ self.conflict.setVisible(False)
1214
+ box.addWidget(self.conflict)
1215
+
1216
+ box.addWidget(_separator())
1217
+ self.mcp = self._check("MCP server")
1218
+ box.addWidget(self.mcp)
1219
+ box.addWidget(
1220
+ self._label(
1221
+ "Off by default. It exposes nothing the REST service does not already "
1222
+ "expose.",
1223
+ "muted",
1224
+ )
1225
+ )
1226
+ box.addWidget(
1227
+ self._label(
1228
+ "Started by your MCP client over stdio, using its own REST credential.",
1229
+ "muted",
1230
+ )
1231
+ )
1232
+
1233
+ box.addWidget(_separator())
1234
+ self.credential_days = QSpinBox()
1235
+ self.credential_days.setRange(CREDENTIAL_DAYS_MIN, CREDENTIAL_DAYS_MAX)
1236
+ self.credential_days.setSuffix(" " + tr("days"))
1237
+ self._field(box, "New credentials expire after", self.credential_days)
1238
+
1239
+ box.addWidget(self._label("Clients", "section"))
1240
+ self.clients = QTreeWidget()
1241
+ self.clients.setRootIsDecorated(False)
1242
+ self.clients.setColumnCount(4)
1243
+ self.clients.setUniformRowHeights(True)
1244
+ self.clients.setMinimumHeight(120)
1245
+ self.clients.setMinimumWidth(280)
1246
+ self._text(self.clients, "Clients", setter="setAccessibleName")
1247
+ box.addWidget(self.clients)
1248
+ self.clients_empty = self._label("No clients yet.", "muted")
1249
+ box.addWidget(self.clients_empty)
1250
+ self.expiry_warning = self._value_label("warn")
1251
+ self.expiry_warning.setVisible(False)
1252
+ box.addWidget(self.expiry_warning)
1253
+
1254
+ self.permission_heading = self._value_label("secondary")
1255
+ box.addWidget(self.permission_heading)
1256
+ self.permission_boxes: dict[Capability, QCheckBox] = {}
1257
+ permission_row = QHBoxLayout()
1258
+ permission_row.setSpacing(METRICS.gap)
1259
+ for capability in (
1260
+ Capability.GENERATE,
1261
+ Capability.READ_RESULTS,
1262
+ Capability.READ_HISTORY,
1263
+ ):
1264
+ check = self._check(_CAPABILITY_TEXT[capability])
1265
+ check.toggled.connect(lambda checked, c=capability: self._on_capability(c, checked))
1266
+ self.permission_boxes[capability] = check
1267
+ permission_row.addWidget(check)
1268
+ permission_row.addStretch(1)
1269
+ box.addLayout(permission_row)
1270
+
1271
+ client_buttons = QHBoxLayout()
1272
+ client_buttons.setSpacing(METRICS.gap)
1273
+ self.issue = self._button("Issue a credential")
1274
+ self.reissue = self._button("Reissue")
1275
+ self.revoke = self._button("Revoke", "danger")
1276
+ for b in (self.issue, self.reissue, self.revoke):
1277
+ client_buttons.addWidget(b)
1278
+ client_buttons.addStretch(1)
1279
+ box.addLayout(client_buttons)
1280
+
1281
+ self.secret = QFrame()
1282
+ self.secret.setObjectName("Panel")
1283
+ secret_box = QVBoxLayout(self.secret)
1284
+ secret_box.setContentsMargins(METRICS.pad, METRICS.pad, METRICS.pad, METRICS.pad)
1285
+ secret_box.setSpacing(METRICS.gap_tight)
1286
+ secret_box.addWidget(self._label("Copy this credential now. It is shown once.", "warn"))
1287
+ self.secret_value = QLineEdit()
1288
+ self.secret_value.setReadOnly(True)
1289
+ self._text(self.secret_value, "Copy this credential now. It is shown once.",
1290
+ setter="setAccessibleName")
1291
+ secret_box.addWidget(self.secret_value)
1292
+ secret_row = QHBoxLayout()
1293
+ secret_row.setSpacing(METRICS.gap)
1294
+ self.secret_copy = self._button("Copy")
1295
+ self.secret_copy.setIcon(icons.icon("copy", self._palette.text_secondary))
1296
+ self.secret_done = self._button("Done", "quiet")
1297
+ secret_row.addWidget(self.secret_copy)
1298
+ secret_row.addWidget(self.secret_done)
1299
+ secret_row.addStretch(1)
1300
+ secret_box.addLayout(secret_row)
1301
+ secret_box.addWidget(
1302
+ self._label(
1303
+ "EchoAct keeps only a verifier, from which the credential cannot be "
1304
+ "recovered. If it is lost, reissue it.",
1305
+ "muted",
1306
+ )
1307
+ )
1308
+ self.secret.setVisible(False)
1309
+ box.addWidget(self.secret)
1310
+
1311
+ self._render_client_columns()
1312
+ # Without this the three permission boxes would start blank while the
1313
+ # next credential really would carry two grants, and the reissue and
1314
+ # revoke buttons would look available with nothing selected (N-09).
1315
+ self._render_client_buttons()
1316
+ self.rest.toggled.connect(self._on_rest)
1317
+ self.port_restart.clicked.connect(self._on_port_restart)
1318
+ self.conflict_use.clicked.connect(self._on_use_offered_port)
1319
+ self.mcp.toggled.connect(self._on_mcp)
1320
+ self.credential_days.valueChanged.connect(self._on_credential_days)
1321
+ self.issue.clicked.connect(self._on_issue)
1322
+ self.reissue.clicked.connect(self._on_reissue)
1323
+ self.revoke.clicked.connect(self._on_revoke)
1324
+ self.secret_copy.clicked.connect(self._on_copy_secret)
1325
+ self.secret_done.clicked.connect(self.dismiss_new_credential)
1326
+ self.clients.currentItemChanged.connect(lambda *_: self._render_client_buttons())
1327
+ return panel
1328
+
1329
+ def _render_client_columns(self) -> None:
1330
+ self.clients.setHeaderLabels(
1331
+ [tr("Name"), tr("Permissions"), tr("Last access"), tr("Expires")]
1332
+ )
1333
+
1334
+ def _on_rest(self, checked: bool) -> None:
1335
+ if self._loading:
1336
+ return
1337
+ if not checked and self.mcp.isChecked():
1338
+ # The consequence is confirmed only when it actually bites: with
1339
+ # MCP already off, turning REST off costs the user nothing MCP
1340
+ # related, and a dialog there would train them to dismiss this one.
1341
+ if not self.confirm(
1342
+ tr("Turn the local service off?"),
1343
+ tr(
1344
+ "MCP is a client of this service, so turning it off makes MCP "
1345
+ "unavailable too. Generation, playback, and the library keep working."
1346
+ ),
1347
+ tr("Turn off"),
1348
+ ):
1349
+ self._set_checked(self.rest, True)
1350
+ return
1351
+ self.rest_enabled_changed.emit(bool(checked))
1352
+ self._render_service()
1353
+
1354
+ def _on_port_restart(self) -> None:
1355
+ self.conflict.setVisible(False)
1356
+ self._request_port(int(self.port.value()))
1357
+
1358
+ def _request_port(self, port: int) -> None:
1359
+ """Ask for the service to move, and say so (F-79, F-46, N-31).
1360
+
1361
+ The disclosure moves with the request rather than with the typing,
1362
+ and :meth:`show_port_conflict` moves it back if the bind was refused
1363
+ -- those two are the only things this screen is ever told about where
1364
+ the service is.
1365
+ """
1366
+ self._service_port_before = self._service_port
1367
+ self._service_port = port
1368
+ self._render_service()
1369
+ self.rest_port_changed.emit(port)
1370
+
1371
+ def _on_use_offered_port(self) -> None:
1372
+ """F-79: the alternative port is offered, and taking it is an act.
1373
+
1374
+ The port is not bound until the owner presses this, which is the
1375
+ difference between offering an alternative and choosing one -- the
1376
+ latter is what F-79 forbids.
1377
+ """
1378
+ if self._conflict_port is None:
1379
+ return
1380
+ chosen = self._conflict_port
1381
+ self._set_value(self.port, chosen)
1382
+ self.conflict.setVisible(False)
1383
+ self._request_port(int(chosen))
1384
+
1385
+ def show_port_conflict(self, port: int, suggestion: int | None = None) -> None:
1386
+ """Report that the port is occupied and offer another one (F-79).
1387
+
1388
+ A refused bind is also the only way this screen learns that the port
1389
+ it just asked for is not the one in service, so the disclosure above
1390
+ goes back to the port that is.
1391
+ """
1392
+ if self._service_port == port and self._service_port_before is not None:
1393
+ self._service_port = self._service_port_before
1394
+ self._render_service()
1395
+ self._service_port_before = None
1396
+ self._conflict_port = suggestion or _next_port(port)
1397
+ self.conflict_text.setText(
1398
+ tr(
1399
+ "Port {port} is in use by another program. EchoAct does not close it "
1400
+ "and does not pick another port on its own."
1401
+ ).format(port=port)
1402
+ )
1403
+ text = tr("Use port {port} instead").format(port=self._conflict_port)
1404
+ self.conflict_use.setText(text)
1405
+ self.conflict_use.setAccessibleName(text)
1406
+ self.conflict.setVisible(True)
1407
+
1408
+ def clear_port_conflict(self) -> None:
1409
+ self._conflict_port = None
1410
+ self.conflict.setVisible(False)
1411
+
1412
+ def _on_mcp(self, checked: bool) -> None:
1413
+ if not self._loading:
1414
+ self.mcp_enabled_changed.emit(bool(checked))
1415
+
1416
+ def _on_credential_days(self, value: int) -> None:
1417
+ if not self._loading:
1418
+ self.credential_days_changed.emit(int(value))
1419
+
1420
+ def _on_issue(self) -> None:
1421
+ name = self.ask_text(
1422
+ tr("Name this client"),
1423
+ tr("What is connecting? For example, an editor or a script."),
1424
+ "",
1425
+ )
1426
+ if not name or not name.strip():
1427
+ return
1428
+ self.credential_issue_requested.emit(
1429
+ name.strip(), self._pending_capabilities, int(self.credential_days.value())
1430
+ )
1431
+
1432
+ def _selected_credential(self) -> Credential | None:
1433
+ item = self.clients.currentItem()
1434
+ if item is None:
1435
+ return None
1436
+ ref = item.data(0, Qt.ItemDataRole.UserRole)
1437
+ for cred in self._credentials:
1438
+ if cred.ref == ref:
1439
+ return cred
1440
+ return None
1441
+
1442
+ def _on_reissue(self) -> None:
1443
+ cred = self._selected_credential()
1444
+ if cred is None:
1445
+ return
1446
+ if not self.confirm(
1447
+ tr("Reissue this credential?"),
1448
+ tr(
1449
+ "The current credential for {name} stops working at once. The new one "
1450
+ "is shown once, here."
1451
+ ).format(name=cred.name),
1452
+ tr("Reissue"),
1453
+ ):
1454
+ return
1455
+ self.credential_reissue_requested.emit(cred.ref)
1456
+
1457
+ def _on_revoke(self) -> None:
1458
+ cred = self._selected_credential()
1459
+ if cred is None:
1460
+ return
1461
+ if not self.confirm(
1462
+ tr("Revoke this credential?"),
1463
+ tr(
1464
+ "{name} loses access immediately, including to results it already "
1465
+ "generated, and its running job is canceled."
1466
+ ).format(name=cred.name),
1467
+ tr("Revoke"),
1468
+ ):
1469
+ return
1470
+ self.credential_revoke_requested.emit(cred.ref)
1471
+
1472
+ def show_new_credential(self, token: str) -> None:
1473
+ """F-71: the credential exists on screen once, and is copyable.
1474
+
1475
+ The token is put in a read-only line edit rather than a label so the
1476
+ keyboard can select it; nothing here writes it anywhere, and
1477
+ :meth:`dismiss_new_credential` clears the widget so it does not
1478
+ survive in the screen's state either.
1479
+ """
1480
+ self.secret_value.setText(token)
1481
+ self.secret.setVisible(True)
1482
+ self.secret_value.setFocus()
1483
+ self.secret_value.selectAll()
1484
+
1485
+ def dismiss_new_credential(self) -> None:
1486
+ self.secret_value.clear()
1487
+ self.secret.setVisible(False)
1488
+
1489
+ def _on_copy_secret(self) -> None:
1490
+ self.secret_value.selectAll()
1491
+ self.secret_value.copy()
1492
+
1493
+ def set_credentials(
1494
+ self, credentials: Iterable[Credential], *, now: float | None = None
1495
+ ) -> None:
1496
+ """F-71's list: name, capabilities, last access, expiry."""
1497
+ self._credentials = list(credentials)
1498
+ self._render_credentials(now)
1499
+
1500
+ def _render_credentials(self, now: float | None = None) -> None:
1501
+ selected = self.clients.currentItem()
1502
+ keep = selected.data(0, Qt.ItemDataRole.UserRole) if selected is not None else None
1503
+ self.clients.clear()
1504
+ for cred in self._credentials:
1505
+ item = QTreeWidgetItem(
1506
+ [
1507
+ cred.name,
1508
+ ", ".join(
1509
+ tr(_CAPABILITY_TEXT[c])
1510
+ for c in sorted(cred.capabilities, key=lambda c: c.value)
1511
+ if c in _CAPABILITY_TEXT
1512
+ ),
1513
+ _date_time(cred.last_access_at) or tr("never"),
1514
+ _expiry_text(cred, now),
1515
+ ]
1516
+ )
1517
+ item.setData(0, Qt.ItemDataRole.UserRole, cred.ref)
1518
+ self.clients.addTopLevelItem(item)
1519
+ if cred.ref == keep:
1520
+ self.clients.setCurrentItem(item)
1521
+ for column in range(4):
1522
+ self.clients.resizeColumnToContents(column)
1523
+ self.clients.setVisible(bool(self._credentials))
1524
+ self.clients_empty.setVisible(not self._credentials)
1525
+
1526
+ warned = [c for c in self._credentials if c.expiry_warning(now)]
1527
+ self.expiry_warning.setVisible(bool(warned))
1528
+ if warned:
1529
+ first = warned[0]
1530
+ self.expiry_warning.setText(
1531
+ tr("A credential for {name} expires in {days} days. Reissue it before then.")
1532
+ .format(name=first.name, days=max(0, int(first.days_until_expiry(now))))
1533
+ )
1534
+ self._render_client_buttons()
1535
+
1536
+ def _render_client_buttons(self) -> None:
1537
+ cred = self._selected_credential()
1538
+ self.reissue.setEnabled(cred is not None and cred.revoked_at is None)
1539
+ self.revoke.setEnabled(cred is not None and cred.revoked_at is None)
1540
+ self._render_permissions()
1541
+
1542
+ def _render_permissions(self) -> None:
1543
+ """F-61's three grants, for the selected client or for the next one.
1544
+
1545
+ One group of checkboxes serves both, because they are the same three
1546
+ permissions either way; the heading above says which set a tick
1547
+ changes, so the owner is never guessing whose permissions they are
1548
+ editing.
1549
+ """
1550
+ cred = self._selected_credential()
1551
+ if cred is None:
1552
+ self.permission_heading.setText(tr("Permissions for the next credential"))
1553
+ chosen: frozenset[Capability] = self._pending_capabilities
1554
+ editable = True
1555
+ else:
1556
+ self.permission_heading.setText(
1557
+ tr("Permissions for {name}").format(name=cred.name)
1558
+ )
1559
+ chosen = cred.effective_capabilities
1560
+ # An owner credential is the app's own, and narrowing it here
1561
+ # would lock the owner out of their own service; a revoked one
1562
+ # has no permissions left to change.
1563
+ editable = not cred.is_owner and cred.revoked_at is None
1564
+ was, self._loading = self._loading, True
1565
+ try:
1566
+ for capability, check in self.permission_boxes.items():
1567
+ check.setChecked(capability in chosen)
1568
+ check.setEnabled(editable)
1569
+ finally:
1570
+ self._loading = was
1571
+
1572
+ def _on_capability(self, _capability: Capability, _checked: bool) -> None:
1573
+ if self._loading:
1574
+ return
1575
+ chosen = frozenset(c for c, box in self.permission_boxes.items() if box.isChecked())
1576
+ cred = self._selected_credential()
1577
+ if cred is None:
1578
+ self._pending_capabilities = chosen
1579
+ return
1580
+ # F-71: the change takes effect immediately, which is the store's to
1581
+ # do -- and 5.3 may require that client's jobs to be cancelled with
1582
+ # it, which only the window can arrange.
1583
+ self.credential_capabilities_changed.emit(cred.ref, chosen)
1584
+
1585
+ def _render_service(self) -> None:
1586
+ self.rest_note.setText(
1587
+ tr(
1588
+ "Listening on {host}:{port} — this computer only. Every request needs a "
1589
+ "credential."
1590
+ ).format(host=REST_HOST, port=self._service_port)
1591
+ )
1592
+ # F-46 lists the connection method among what must be reviewable, and
1593
+ # for a REST client the base URL is the whole of it.
1594
+ self.rest_how.setText(
1595
+ tr(
1596
+ "Clients connect to {url} with their credential. Nothing else on the "
1597
+ "network can reach it."
1598
+ ).format(url=f"http://{REST_HOST}:{self._service_port}{API_PREFIX}")
1599
+ )
1600
+ on = self.rest.isChecked()
1601
+ self.port.setEnabled(on)
1602
+ self.port_restart.setEnabled(on)
1603
+ # F-58: MCP is a REST client, so it cannot be enabled while REST is off.
1604
+ self.mcp.setEnabled(on)
1605
+ self.issue.setEnabled(on)
1606
+
1607
+ # ==================================================================
1608
+ # Storage (F-73, F-76)
1609
+ # ==================================================================
1610
+
1611
+ def _build_storage(self) -> QFrame:
1612
+ panel, box = self._panel("Storage")
1613
+
1614
+ grid = QGridLayout()
1615
+ grid.setHorizontalSpacing(METRICS.pad)
1616
+ grid.setVerticalSpacing(METRICS.gap_tight)
1617
+ self.size_labels: dict[str, QLabel] = {}
1618
+ rows = (
1619
+ ("model_cache", "Model cache"),
1620
+ ("documents", "Documents and history"),
1621
+ ("audio", "Audio"),
1622
+ ("temp", "Temporary data"),
1623
+ ("logs", "Logs"),
1624
+ )
1625
+ for row, (key, name) in enumerate(rows):
1626
+ grid.addWidget(self._label(name, "secondary"), row, 0)
1627
+ value = self._value_label()
1628
+ value.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
1629
+ grid.addWidget(value, row, 1)
1630
+ self.size_labels[key] = value
1631
+ grid.setColumnStretch(0, 1)
1632
+ box.addLayout(grid)
1633
+
1634
+ self.storage_refresh = self._button("Refresh", "quiet")
1635
+ refresh_row = QHBoxLayout()
1636
+ refresh_row.addWidget(self.storage_refresh)
1637
+ refresh_row.addStretch(1)
1638
+ box.addLayout(refresh_row)
1639
+
1640
+ box.addWidget(_separator())
1641
+ self.retention = QSpinBox()
1642
+ self.retention.setRange(RETENTION_MIN_BYTES // GB, RETENTION_MAX_BYTES // GB)
1643
+ self.retention.setSuffix(" GB")
1644
+ self._field(
1645
+ box,
1646
+ "Retention limit",
1647
+ self.retention,
1648
+ "1-100 GB for documents, history, and audio together. Lowering it below "
1649
+ "what is stored keeps that data and refuses new retention.",
1650
+ )
1651
+ self.retention_used = self._value_label("muted")
1652
+ box.addWidget(self.retention_used)
1653
+
1654
+ self.cleanup = self._button("Clean up expired data")
1655
+ cleanup_row = QHBoxLayout()
1656
+ cleanup_row.addWidget(self.cleanup)
1657
+ cleanup_row.addStretch(1)
1658
+ box.addLayout(cleanup_row)
1659
+ box.addWidget(
1660
+ self._label("Data in use by playback, generation, or a backup is left alone.", "muted")
1661
+ )
1662
+ self.cleanup_result = self._value_label("ok")
1663
+ self.cleanup_result.setVisible(False)
1664
+ box.addWidget(self.cleanup_result)
1665
+
1666
+ box.addWidget(_separator())
1667
+ self.reset_buttons: dict[ResetScope, QPushButton] = {}
1668
+ for scope, button_text, description in (
1669
+ (
1670
+ ResetScope.VOICE_AND_DISPLAY,
1671
+ "Reset",
1672
+ "Voice, tempo, style, display language, and playback options return to "
1673
+ "their defaults. Documents, history, and audio are untouched.",
1674
+ ),
1675
+ (
1676
+ ResetScope.INTEGRATIONS,
1677
+ "Revoke all",
1678
+ "Every client credential stops working at once and their running jobs "
1679
+ "are canceled. A new owner credential is issued on the next launch.",
1680
+ ),
1681
+ (
1682
+ ResetScope.RETAINED_DATA,
1683
+ "Delete",
1684
+ "Saved documents, job history, and generated audio are deleted from this "
1685
+ "app and cannot be recovered. Files you exported yourself and your own "
1686
+ "backups are not touched.",
1687
+ ),
1688
+ (
1689
+ ResetScope.MODEL_CACHE,
1690
+ "Delete",
1691
+ "The downloaded model files are deleted, about {size}. Generation needs "
1692
+ "them downloaded again before it can run, which needs a network.",
1693
+ ),
1694
+ ):
1695
+ box.addWidget(self._label(_SCOPE_TITLE[scope], "secondary"))
1696
+ row = QHBoxLayout()
1697
+ row.setSpacing(METRICS.gap)
1698
+ button = self._button(button_text, "danger")
1699
+ button.setMinimumWidth(120)
1700
+ self._text(button, _SCOPE_TITLE[scope], setter="setAccessibleName")
1701
+ row.addWidget(button)
1702
+ row.addStretch(1)
1703
+ box.addLayout(row)
1704
+ if scope is ResetScope.MODEL_CACHE:
1705
+ self.model_cache_note = self._label(description, "muted", size=bytes_size(0))
1706
+ box.addWidget(self.model_cache_note)
1707
+ else:
1708
+ box.addWidget(self._label(description, "muted"))
1709
+ button.clicked.connect(lambda _=False, s=scope: self._on_reset(s))
1710
+ self.reset_buttons[scope] = button
1711
+
1712
+ self.failures = self._value_label("danger")
1713
+ self.failures.setVisible(False)
1714
+ box.addWidget(self.failures)
1715
+
1716
+ self.retention.valueChanged.connect(self._on_retention)
1717
+ self.cleanup.clicked.connect(self.cleanup_requested)
1718
+ self.storage_refresh.clicked.connect(self.storage_refresh_requested)
1719
+ return panel
1720
+
1721
+ def _on_retention(self, value: int) -> None:
1722
+ if not self._loading:
1723
+ self.retention_changed.emit(int(value) * GB)
1724
+
1725
+ def _on_reset(self, scope: ResetScope) -> None:
1726
+ """F-76: each scope confirms its own impact before anything goes.
1727
+
1728
+ The confirmation names what is deleted, what cannot be recovered, and
1729
+ what is deliberately left alone -- the user's own exported files and
1730
+ backups -- because F-76 requires all three and a generic "are you
1731
+ sure" answers none of them.
1732
+ """
1733
+ if not self.confirm(
1734
+ tr(_SCOPE_TITLE[scope]) + "?",
1735
+ self._scope_body(scope),
1736
+ tr(_SCOPE_ACCEPT[scope]),
1737
+ ):
1738
+ return
1739
+ self.failures.setVisible(False)
1740
+ self.reset_requested.emit(scope.value)
1741
+
1742
+ def _scope_body(self, scope: ResetScope) -> str:
1743
+ if scope is ResetScope.MODEL_CACHE:
1744
+ size = bytes_size(self._sizes.model_cache_bytes if self._sizes else 0)
1745
+ body = tr(
1746
+ "The downloaded model files are deleted, about {size}. Generation needs "
1747
+ "them downloaded again before it can run, which needs a network."
1748
+ ).format(size=size)
1749
+ elif scope is ResetScope.RETAINED_DATA:
1750
+ body = tr(
1751
+ "Saved documents, job history, and generated audio are deleted from this "
1752
+ "app and cannot be recovered. Files you exported yourself and your own "
1753
+ "backups are not touched."
1754
+ )
1755
+ elif scope is ResetScope.INTEGRATIONS:
1756
+ body = tr(
1757
+ "Every client credential stops working at once and their running jobs "
1758
+ "are canceled. A new owner credential is issued on the next launch."
1759
+ )
1760
+ else:
1761
+ body = tr(
1762
+ "Voice, tempo, style, display language, and playback options return to "
1763
+ "their defaults. Documents, history, and audio are untouched."
1764
+ )
1765
+ if scope is ResetScope.VOICE_AND_DISPLAY:
1766
+ return body
1767
+ return body + " " + tr("This cannot be undone.")
1768
+
1769
+ def set_storage(self, sizes: StorageSizes) -> None:
1770
+ """F-73's five figures, each on its own line."""
1771
+ self._sizes = sizes
1772
+ self._render_storage()
1773
+
1774
+ def _render_storage(self) -> None:
1775
+ sizes = self._sizes
1776
+ if sizes is None:
1777
+ return
1778
+ self.size_labels["model_cache"].setText(bytes_size(sizes.model_cache_bytes))
1779
+ self.size_labels["documents"].setText(bytes_size(sizes.documents_bytes))
1780
+ self.size_labels["audio"].setText(bytes_size(sizes.audio_bytes))
1781
+ self.size_labels["temp"].setText(bytes_size(sizes.temp_bytes))
1782
+ self.size_labels["logs"].setText(bytes_size(sizes.log_bytes))
1783
+ self.retention_used.setText(
1784
+ tr("{used} of {limit} used").format(
1785
+ used=bytes_size(sizes.retained_used_bytes),
1786
+ limit=bytes_size(sizes.retention_limit_bytes),
1787
+ )
1788
+ )
1789
+ self.model_cache_note.setText(
1790
+ tr(
1791
+ "The downloaded model files are deleted, about {size}. Generation needs "
1792
+ "them downloaded again before it can run, which needs a network."
1793
+ ).format(size=bytes_size(sizes.model_cache_bytes))
1794
+ )
1795
+
1796
+ def report_cleanup(self, removed: int, freed_bytes: int) -> None:
1797
+ """F-73's cleanup, reported in the number the user can act on.
1798
+
1799
+ Three phrasings rather than one with a count, because "Removed 1
1800
+ items" and "Removed 0 items" are both wrong in English and the
1801
+ second is not even about a removal.
1802
+ """
1803
+ self.cleanup_result.setVisible(True)
1804
+ if removed <= 0:
1805
+ self.cleanup_result.setText(tr("Nothing had expired."))
1806
+ elif removed == 1:
1807
+ self.cleanup_result.setText(
1808
+ tr("Removed 1 item, freeing {size}.").format(size=bytes_size(freed_bytes))
1809
+ )
1810
+ else:
1811
+ self.cleanup_result.setText(
1812
+ tr("Removed {n} items, freeing {size}.").format(
1813
+ n=removed, size=bytes_size(freed_bytes)
1814
+ )
1815
+ )
1816
+
1817
+ def report_failures(self, items: Sequence[str]) -> None:
1818
+ """F-76: what could not be deleted is named, not swallowed."""
1819
+ self.failures.setVisible(bool(items))
1820
+ if items:
1821
+ self.failures.setText(
1822
+ tr("These items could not be deleted: {items}").format(items=", ".join(items))
1823
+ )
1824
+
1825
+ # ==================================================================
1826
+ # Voice presets (F-66)
1827
+ # ==================================================================
1828
+
1829
+ def _build_presets(self) -> QFrame:
1830
+ panel, box = self._panel("Voice presets")
1831
+
1832
+ self.preset_count = self._value_label("muted")
1833
+ box.addWidget(self.preset_count)
1834
+ self.presets = QListWidget()
1835
+ self.presets.setMinimumHeight(110)
1836
+ self._text(self.presets, "Voice presets", setter="setAccessibleName")
1837
+ box.addWidget(self.presets)
1838
+ self.presets_empty = self._label("No presets yet.", "muted")
1839
+ box.addWidget(self.presets_empty)
1840
+
1841
+ buttons = QHBoxLayout()
1842
+ buttons.setSpacing(METRICS.gap)
1843
+ self.preset_apply = self._button("Apply")
1844
+ self.preset_rename = self._button("Rename")
1845
+ self.preset_delete = self._button("Delete", "danger")
1846
+ for b in (self.preset_apply, self.preset_rename, self.preset_delete):
1847
+ buttons.addWidget(b)
1848
+ buttons.addStretch(1)
1849
+ box.addLayout(buttons)
1850
+
1851
+ box.addWidget(_separator())
1852
+ box.addWidget(self._label("Save the current voice settings as", "secondary"))
1853
+ save_row = QHBoxLayout()
1854
+ save_row.setSpacing(METRICS.gap)
1855
+ self.preset_name = QLineEdit()
1856
+ self._text(self.preset_name, "A name for this preset", setter="setPlaceholderText")
1857
+ self._text(self.preset_name, "A name for this preset", setter="setAccessibleName")
1858
+ self.preset_save = self._button("Save")
1859
+ save_row.addWidget(self.preset_name, 1)
1860
+ save_row.addWidget(self.preset_save)
1861
+ box.addLayout(save_row)
1862
+ self.preset_limit = self._label(
1863
+ "The limit of {max} presets has been reached.", "warn", max=VOICE_PRESET_MAX
1864
+ )
1865
+ self.preset_limit.setVisible(False)
1866
+ box.addWidget(self.preset_limit)
1867
+
1868
+ self.presets.currentRowChanged.connect(lambda *_: self._render_preset_buttons())
1869
+ self.preset_apply.clicked.connect(self._on_preset_apply)
1870
+ self.preset_rename.clicked.connect(self._on_preset_rename)
1871
+ self.preset_delete.clicked.connect(self._on_preset_delete)
1872
+ self.preset_save.clicked.connect(self._on_preset_save)
1873
+ self.preset_name.returnPressed.connect(self._on_preset_save)
1874
+ return panel
1875
+
1876
+ def _render_presets(self) -> None:
1877
+ was = self._loading
1878
+ self._loading = True
1879
+ try:
1880
+ current = self._selected_preset()
1881
+ self.presets.clear()
1882
+ self._unavailable = set()
1883
+ for preset in self._presets:
1884
+ problem = _preset_problem(preset)
1885
+ item = QListWidgetItem(
1886
+ preset.name if problem is None else tr(problem).format(name=preset.name)
1887
+ )
1888
+ item.setData(Qt.ItemDataRole.UserRole, preset.name)
1889
+ self.presets.addItem(item)
1890
+ if problem is not None:
1891
+ self._unavailable.add(preset.name)
1892
+ if current is not None:
1893
+ for row in range(self.presets.count()):
1894
+ item = self.presets.item(row)
1895
+ if item.data(Qt.ItemDataRole.UserRole) == current:
1896
+ self.presets.setCurrentItem(item)
1897
+ break
1898
+ finally:
1899
+ self._loading = was
1900
+ self.presets.setVisible(bool(self._presets))
1901
+ self.presets_empty.setVisible(not self._presets)
1902
+ self.preset_count.setText(
1903
+ tr("{n} of {max} saved").format(n=len(self._presets), max=VOICE_PRESET_MAX)
1904
+ )
1905
+ full = len(self._presets) >= VOICE_PRESET_MAX
1906
+ self.preset_limit.setVisible(full)
1907
+ self.preset_save.setEnabled(not full)
1908
+ self._render_preset_buttons()
1909
+
1910
+ def _render_preset_buttons(self) -> None:
1911
+ name = self._selected_preset()
1912
+ for b in (self.preset_rename, self.preset_delete):
1913
+ b.setEnabled(name is not None)
1914
+ # F-66: an unavailable model or voice is flagged and never
1915
+ # substituted, so applying such a preset is refused rather than
1916
+ # silently corrected to a voice the user did not choose. Renaming
1917
+ # and deleting stay available -- deleting it is the likely fix.
1918
+ self.preset_apply.setEnabled(name is not None and name not in self._unavailable)
1919
+
1920
+ def _selected_preset(self) -> str | None:
1921
+ item = self.presets.currentItem()
1922
+ if item is None:
1923
+ return None
1924
+ return str(item.data(Qt.ItemDataRole.UserRole))
1925
+
1926
+ def _on_preset_apply(self) -> None:
1927
+ name = self._selected_preset()
1928
+ if name:
1929
+ self.preset_apply_requested.emit(name)
1930
+
1931
+ def _on_preset_rename(self) -> None:
1932
+ """F-66 / 4.1: a rename onto an occupied name is an overwrite.
1933
+
1934
+ ``Settings.rename_preset`` drops the preset that was already called
1935
+ this, deliberately and without asking -- the same overwrite
1936
+ ``save_preset`` performs -- so the confirmation 4.1 requires for a
1937
+ duplicate name has to be given here, on the way in, exactly as it is
1938
+ for the Save box.
1939
+ """
1940
+ name = self._selected_preset()
1941
+ if not name:
1942
+ return
1943
+ new = self.ask_text(tr("Rename"), tr("New name"), name)
1944
+ if not new or not new.strip() or new.strip() == name:
1945
+ return
1946
+ clean = new.strip()
1947
+ if any(p.name == clean for p in self._presets) and not self.confirm(
1948
+ tr("Replace the preset {name}?").format(name=clean),
1949
+ tr("A preset with this name already exists. Renaming replaces its settings."),
1950
+ tr("Replace"),
1951
+ ):
1952
+ return
1953
+ self.preset_rename_requested.emit(name, clean)
1954
+
1955
+ def _on_preset_delete(self) -> None:
1956
+ name = self._selected_preset()
1957
+ if not name:
1958
+ return
1959
+ if not self.confirm(
1960
+ tr("Delete the preset {name}?").format(name=name),
1961
+ tr("This cannot be undone."),
1962
+ tr("Delete"),
1963
+ ):
1964
+ return
1965
+ self.preset_delete_requested.emit(name)
1966
+
1967
+ def _on_preset_save(self) -> None:
1968
+ """4.1: a duplicate name is confirmed, then overwritten.
1969
+
1970
+ The confirmation is here rather than in ``Settings.save_preset``,
1971
+ which overwrites without asking on purpose -- by the time the model
1972
+ is asked, the answer has been given.
1973
+ """
1974
+ name = self.preset_name.text().strip()
1975
+ if not name:
1976
+ return
1977
+ if len(self._presets) >= VOICE_PRESET_MAX and all(p.name != name for p in self._presets):
1978
+ return
1979
+ if any(p.name == name for p in self._presets):
1980
+ if not self.confirm(
1981
+ tr("Replace the preset {name}?").format(name=name),
1982
+ tr("A preset with this name already exists. Saving replaces its settings."),
1983
+ tr("Replace"),
1984
+ ):
1985
+ return
1986
+ self.preset_name.clear()
1987
+ self.preset_save_requested.emit(name)
1988
+
1989
+ # ==================================================================
1990
+ # About (F-75, F-80)
1991
+ # ==================================================================
1992
+
1993
+ def _build_about(self) -> QFrame:
1994
+ panel, box = self._panel("About")
1995
+ box.addWidget(self._label("Installed version {version}", "secondary", version=__version__))
1996
+
1997
+ self.check_version = self._button("Check for a released version")
1998
+ check_row = QHBoxLayout()
1999
+ check_row.addWidget(self.check_version)
2000
+ check_row.addStretch(1)
2001
+ box.addLayout(check_row)
2002
+ box.addWidget(
2003
+ self._label(
2004
+ "Nothing is downloaded, installed, or restarted without your consent. "
2005
+ "Checking sends only a version query.",
2006
+ "muted",
2007
+ )
2008
+ )
2009
+ self.release_note = self._value_label()
2010
+ self.release_note.setVisible(False)
2011
+ box.addWidget(self.release_note)
2012
+
2013
+ box.addWidget(_separator())
2014
+ box.addWidget(self._label("Change history", "section"))
2015
+ for version, released, lines in CHANGE_HISTORY:
2016
+ box.addWidget(self._value_with_text(f"{version} — {released}", "secondary"))
2017
+ for line in lines:
2018
+ box.addWidget(self._bullet(line))
2019
+
2020
+ self.check_version.clicked.connect(self.version_check_requested)
2021
+ return panel
2022
+
2023
+ def _value_with_text(self, text: str, role: str | None = None) -> QLabel:
2024
+ lb = self._value_label(role)
2025
+ lb.setText(text)
2026
+ return lb
2027
+
2028
+ def set_released_version(self, version: str | None, *, error: str | None = None) -> None:
2029
+ """F-75: report what a check found, and download nothing.
2030
+
2031
+ Called only after :attr:`version_check_requested`; there is no timer
2032
+ and no start-up call anywhere in this module, because F-75 makes the
2033
+ query the user's to ask for.
2034
+ """
2035
+ self.release_note.setVisible(True)
2036
+ if error:
2037
+ self.release_note.setText(
2038
+ tr("The released version could not be checked: {reason}").format(reason=error)
2039
+ )
2040
+ elif version and version != __version__:
2041
+ self.release_note.setText(
2042
+ tr("Version {version} has been released.").format(version=version)
2043
+ )
2044
+ else:
2045
+ self.release_note.setText(tr("This is the newest released version."))
2046
+
2047
+ # ==================================================================
2048
+ # Applying settings, and retranslating
2049
+ # ==================================================================
2050
+
2051
+ def apply(self, settings: Settings) -> None:
2052
+ """Show remembered settings without emitting a change (F-24).
2053
+
2054
+ The view keeps a reference rather than a copy: ``Settings`` is frozen,
2055
+ so the window replacing it is the only way this changes, and the
2056
+ screen can never disagree with what was saved.
2057
+ """
2058
+ self._settings = settings
2059
+ self._presets = settings.presets
2060
+ self._loading = True
2061
+ try:
2062
+ self.cpu.setValue(
2063
+ max(CPU_PERCENT_MIN, min(CPU_PERCENT_MAX, settings.cpu_percent))
2064
+ )
2065
+ auto = settings.memory_bytes is None
2066
+ self.memory_auto.setChecked(auto)
2067
+ self.memory.setEnabled(not auto)
2068
+ shown = (
2069
+ settings.memory_bytes
2070
+ if settings.memory_bytes is not None
2071
+ else default_memory_bytes(self._machine.total_ram_bytes)
2072
+ )
2073
+ self.memory.setValue(shown / GIB)
2074
+ self.volume.setValue(int(round(settings.volume * 100)))
2075
+ self.volume_value.setText(f"{int(round(settings.volume * 100))}%")
2076
+ self.muted.setChecked(settings.muted)
2077
+ self.autoplay.setChecked(settings.autoplay)
2078
+ self.follow.setChecked(settings.follow)
2079
+ self.rest.setChecked(settings.rest_enabled)
2080
+ self.port.setValue(
2081
+ max(MIN_USER_PORT, min(MAX_PORT, settings.rest_port))
2082
+ )
2083
+ self.mcp.setChecked(settings.mcp_enabled)
2084
+ self.credential_days.setValue(
2085
+ max(CREDENTIAL_DAYS_MIN, min(CREDENTIAL_DAYS_MAX, settings.credential_days))
2086
+ )
2087
+ self.retention.setValue(
2088
+ max(
2089
+ RETENTION_MIN_BYTES // GB,
2090
+ min(RETENTION_MAX_BYTES // GB, settings.retention_bytes // GB),
2091
+ )
2092
+ )
2093
+ finally:
2094
+ self._loading = False
2095
+ # A remembered port is one the service was started on; a port typed
2096
+ # into the box afterwards is not, so the disclosure follows this and
2097
+ # not the control.
2098
+ self._service_port = int(self.port.value())
2099
+ self._service_port_before = None
2100
+ self._render_languages()
2101
+ self._render_devices()
2102
+ self._render_service()
2103
+ self._render_resources()
2104
+ self._render_presets()
2105
+
2106
+ @property
2107
+ def settings(self) -> Settings:
2108
+ return self._settings
2109
+
2110
+ def retranslate(self) -> None:
2111
+ """F-86, in place.
2112
+
2113
+ Every literal is re-set from its recorded source, and every rendered
2114
+ value is recomputed; the widgets themselves are the same objects, so
2115
+ the focused control and the scroll position survive the switch --
2116
+ which is the difference between changing a language and losing your
2117
+ place.
2118
+ """
2119
+ for widget, setter, source, fmt in self._translatables:
2120
+ getattr(widget, setter)(tr(source).format(**fmt) if fmt else tr(source))
2121
+ self.credential_days.setSuffix(" " + tr("days"))
2122
+ self._render_client_columns()
2123
+ self._render_languages()
2124
+ self._render_devices()
2125
+ self._render_service()
2126
+ self._render_resources()
2127
+ self._render_credentials()
2128
+ self._render_storage()
2129
+ self._render_presets()
2130
+ if self._conflict_port is not None and self.conflict.isVisible():
2131
+ text = tr("Use port {port} instead").format(port=self._conflict_port)
2132
+ self.conflict_use.setText(text)
2133
+ self.conflict_use.setAccessibleName(text)
2134
+
2135
+ # -- guarded programmatic changes ------------------------------------
2136
+
2137
+ def _set_checked(self, box: QCheckBox, checked: bool) -> None:
2138
+ was, self._loading = self._loading, True
2139
+ try:
2140
+ box.setChecked(checked)
2141
+ finally:
2142
+ self._loading = was
2143
+
2144
+ def _set_value(self, spin: QSpinBox, value: int) -> None:
2145
+ was, self._loading = self._loading, True
2146
+ try:
2147
+ spin.setValue(value)
2148
+ finally:
2149
+ self._loading = was
2150
+
2151
+
2152
+ def _separator() -> QFrame:
2153
+ f = QFrame()
2154
+ f.setProperty("role", "separator")
2155
+ f.setFrameShape(QFrame.Shape.HLine)
2156
+ f.setFixedHeight(1)
2157
+ return f
2158
+
2159
+
2160
+ def _repolish(widget: QWidget) -> None:
2161
+ """Re-evaluate a property selector after the property changed."""
2162
+ style = widget.style()
2163
+ style.unpolish(widget)
2164
+ style.polish(widget)
2165
+
2166
+
2167
+ def _next_port(port: int) -> int:
2168
+ """The port to offer after a conflict (F-79).
2169
+
2170
+ One above the occupied one, wrapping to the bottom of the user range at
2171
+ the top end. It is only a suggestion: nothing binds it until the owner
2172
+ presses the button, so the choice stays theirs.
2173
+ """
2174
+ if port >= MAX_PORT:
2175
+ return MIN_USER_PORT
2176
+ return max(MIN_USER_PORT, port + 1)
2177
+
2178
+
2179
+ def _preset_problem(preset: VoicePreset) -> str | None:
2180
+ """F-66's warning: the model or the voice this preset names is gone.
2181
+
2182
+ Checked against the shipped manifest rather than against what is
2183
+ downloaded: a model that is merely not prepared yet can still be
2184
+ prepared, and flagging that as unavailable would warn about every
2185
+ preset on a fresh install.
2186
+ """
2187
+ voice = preset.voice
2188
+ if not MANIFEST.has(voice.model_id):
2189
+ return "{name} — this model is not available"
2190
+ try:
2191
+ MANIFEST.get(voice.model_id).validate_voice(voice.voice_id, voice.gender)
2192
+ except EchoActError:
2193
+ return "{name} — this voice is not available"
2194
+ return None
2195
+
2196
+
2197
+ def _expiry_text(cred: Credential, now: float | None = None) -> str:
2198
+ status = cred.status(now)
2199
+ if status is CredentialStatus.REVOKED:
2200
+ return tr("Revoked")
2201
+ if status is CredentialStatus.EXPIRED:
2202
+ return tr("Expired")
2203
+ return tr("{date} (in {days} days)").format(
2204
+ date=_date(cred.expires_at), days=max(0, int(cred.days_until_expiry(now)))
2205
+ )
2206
+
2207
+
2208
+ _SCOPE_TITLE: Final[dict[ResetScope, str]] = {
2209
+ ResetScope.VOICE_AND_DISPLAY: "Reset voice and display settings",
2210
+ ResetScope.INTEGRATIONS: "Revoke integration permissions",
2211
+ ResetScope.RETAINED_DATA: "Delete retained data",
2212
+ ResetScope.MODEL_CACHE: "Delete the model cache",
2213
+ }
2214
+
2215
+ _SCOPE_ACCEPT: Final[dict[ResetScope, str]] = {
2216
+ ResetScope.VOICE_AND_DISPLAY: "Reset",
2217
+ ResetScope.INTEGRATIONS: "Revoke all",
2218
+ ResetScope.RETAINED_DATA: "Delete",
2219
+ ResetScope.MODEL_CACHE: "Delete",
2220
+ }
2221
+
2222
+
2223
+ def _follow_language(view: SettingsView) -> None:
2224
+ """Retranslate this view whenever the display language changes.
2225
+
2226
+ ``i18n.on_change`` keeps its listeners for the life of the process, so
2227
+ the hook holds a weak reference and does nothing once the view is gone;
2228
+ a bound method here would keep a closed settings screen alive and, worse,
2229
+ would eventually call into a deleted C++ object.
2230
+ """
2231
+ ref = weakref.ref(view)
2232
+
2233
+ def hook(_lang: object) -> None:
2234
+ target = ref()
2235
+ if target is None or not _alive(target):
2236
+ return
2237
+ target.retranslate()
2238
+
2239
+ on_change(hook)
2240
+
2241
+
2242
+ def _alive(widget: QWidget) -> bool:
2243
+ try:
2244
+ from shiboken6 import isValid
2245
+ except ImportError: # pragma: no cover - shiboken ships with PySide6
2246
+ return True
2247
+ return bool(isValid(widget))
2248
+
2249
+
2250
+ __all__ = [
2251
+ "CHANGE_HISTORY",
2252
+ "Machine",
2253
+ "ResetScope",
2254
+ "SettingsView",
2255
+ "StorageSizes",
2256
+ "directory_bytes",
2257
+ "storage_snapshot",
2258
+ ]