Skip to content

Commit 3b98aec

Browse files
authored
Prepare Mini EQ 0.7.0 release (#17)
1 parent 740aaf0 commit 3b98aec

26 files changed

Lines changed: 1154 additions & 179 deletions

AGENTS.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,16 @@ modules from a system-site build venv.
115115
behavior on `capabilities`. Bump the API version only for semantic breaks, and
116116
support the old version for a short, documented release window before removing
117117
it.
118+
- Keep app-owned JSON documents explicitly versioned with a top-level
119+
`version` field plus migration, unsupported-version, and corrupted-schema
120+
normalization tests. Presets and output preset links are JSON documents; do
121+
not move them to GSettings. Valid legacy documents should load without being
122+
rewritten on startup, then be written in the current schema only when the
123+
user changes related state. Future-version or corrupted documents should not
124+
be overwritten just because the app started. If GSettings is introduced
125+
later, keep it to small typed preferences such as appearance, monitor, and
126+
background/startup choices, and update the schema install, Flatpak, and test
127+
paths in the same change.
118128
- Keep the `mini-eq` CLI user-oriented. Maintainer automation belongs in
119129
`tools/`, `docs/`, or this file.
120130
- Keep the GNOME Shell extension source in `extensions/gnome-shell/`; do not

docs/release.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ Run the narrowest gate that covers the release risk:
2020
- **When background mode, Start at Login, hidden-window lifecycle, or Shell
2121
control changed:** one clean-permission Flatpak portal smoke in a real GNOME
2222
session.
23+
- **When preset, output, startup, routing, monitor, or inspector UI behavior
24+
changed:** run the workflow usability gate below before release.
2325
- **When the GNOME Shell extension source changed:** run the extension checker,
2426
build the review zip, test the supported Shell versions, and upload after the
2527
app release is ready.
@@ -31,6 +33,44 @@ TestPyPI is package-index validation. It is not a user beta channel. Flathub PR
3133
test builds are the normal stable handoff validation. Flathub beta is a
3234
temporary user-installable Flatpak beta, not a permanent second release line.
3335

36+
## Workflow Usability Gate
37+
38+
Before releasing UI or state-machine changes, review the workflows as state
39+
transitions, not as isolated controls. Every changed workflow should have a
40+
single obvious current state, a reversible path, and no first-frame state
41+
change after the window is shown.
42+
43+
For preset and output changes, cover these cases with unit tests when possible
44+
and with AT-SPI or live smoke when they require real GTK behavior:
45+
46+
- Load, edit, reset to neutral, then reload the same saved preset from the
47+
preset loader. Keep revert-style actions for unsaved sources that are not in
48+
the preset library.
49+
- Verify the preset loader does not pretend to be the running state: the visible
50+
running-curve label must distinguish neutral, exact saved preset, modified
51+
preset, and unsaved/imported curves.
52+
- Import or create an unsaved curve, save it, reset it, and recover a neutral
53+
curve without deleting the only route back.
54+
- Delete the loaded preset, delete or modify it outside the app, and keep the
55+
current curve understandable as an unsaved copy.
56+
- Link, unlink, miss, and modify auto presets for both port-scoped and
57+
output-scoped targets.
58+
- Set, miss, and clear the default preset.
59+
- Change output while a curve is clean, modified, auto-applied, missing, or
60+
unavailable.
61+
- Turn Monitor on/off and freeze/unfreeze it without leaving hidden frozen
62+
state behind.
63+
- Start the app with auto/default preset and auto-route inputs and verify the
64+
visible window appears only after startup state is applied.
65+
- Check Shell extension/D-Bus state after preset, output, background, and
66+
window-visibility changes.
67+
68+
AT-SPI tests should assert externally visible behavior: accessible names, roles,
69+
checked state, sensitivity, and critical status labels. They should not depend
70+
on widget internals when a unit test can cover the state transition directly.
71+
When in doubt, add a small state-level unit test first, then one AT-SPI smoke
72+
assertion for the visible contract.
73+
3474
## Prepare Version
3575

3676
Set the release version once for the shell session:

docs/screenshots/mini-eq-dark.png

4.91 KB
Loading

docs/screenshots/mini-eq.png

1.29 KB
Loading

docs/social-preview.png

593 Bytes
Loading

src/mini_eq/app.py

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,10 @@ def ensure_window(self, *, present: bool) -> None:
8585
return
8686
if present:
8787
self.window.present_after_setup = True
88-
self.window.set_visible(True)
89-
self.window.present()
90-
self.emit_control_state_changed()
88+
if self.window.post_present_ready:
89+
self.window.set_visible(True)
90+
self.window.present()
91+
self.emit_control_state_changed()
9192
self.window.schedule_post_present_setup()
9293
return
9394

@@ -110,13 +111,9 @@ def ensure_window(self, *, present: bool) -> None:
110111
self.window = MiniEqWindow(self, self.controller, self.args.auto_route, initial_curve_label=initial_curve_label)
111112
self.window.set_icon_name(APP_ICON_NAME)
112113
self.window.present_after_setup = present
113-
self.window.set_visible(present)
114-
if present:
115-
self.window.present()
114+
self.window.set_visible(False)
116115
self.window.schedule_post_present_setup()
117-
if present:
118-
self.window_present_source_id = GLib.idle_add(self.on_window_present_idle)
119-
else:
116+
if not present:
120117
self.update_background_status()
121118
self.emit_control_state_changed()
122119

src/mini_eq/appearance.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
from gi.repository import Adw
1111

12-
from .settings import load_settings, update_setting
12+
from .settings import APPEARANCE_KEY, load_settings, update_setting
1313
from .settings import settings_path as _settings_path
1414

1515
APPEARANCE_SYSTEM: Final = "system"
@@ -18,7 +18,6 @@
1818
APPEARANCE_MODES: Final = (APPEARANCE_SYSTEM, APPEARANCE_LIGHT, APPEARANCE_DARK)
1919
DEFAULT_APPEARANCE: Final = APPEARANCE_SYSTEM
2020
SETTINGS_FILE_NAME: Final = "settings.json"
21-
APPEARANCE_KEY: Final = "appearance"
2221

2322

2423
def normalize_appearance(value: object) -> str:

src/mini_eq/background.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,14 @@
1111
from gi.repository import Gio, GLib
1212

1313
from .desktop_integration import APP_DISPLAY_NAME, APP_ICON_NAME, APP_ID, quote_desktop_exec_arg
14-
from .settings import load_settings, update_setting
14+
from .settings import (
15+
BACKGROUND_MODE_KEY,
16+
START_ACTIVE_AT_LOGIN_KEY,
17+
START_AT_LOGIN_KEY,
18+
load_settings,
19+
update_setting,
20+
)
1521

16-
BACKGROUND_MODE_KEY: Final = "background_mode"
17-
START_AT_LOGIN_KEY: Final = "start_at_login"
18-
START_ACTIVE_AT_LOGIN_KEY: Final = "start_active_at_login"
1922
BACKGROUND_PORTAL_REASON: Final = "Keep equalizer settings active for desktop audio."
2023
BACKGROUND_PORTAL_BUS_NAME: Final = "org.freedesktop.portal.Desktop"
2124
BACKGROUND_PORTAL_OBJECT_PATH: Final = "/org/freedesktop/portal/desktop"

src/mini_eq/core.py

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,42 @@ def list_preset_names() -> list[str]:
332332
return sorted(dict.fromkeys(names), key=str.casefold)
333333

334334

335+
def json_document_version(payload: dict[str, object], document_name: str, supported_version: int) -> int:
336+
if "version" not in payload:
337+
return 0
338+
339+
version = payload["version"]
340+
if isinstance(version, bool) or not isinstance(version, int) or version < 0:
341+
raise ValueError(f"{document_name} version must be a non-negative integer")
342+
343+
if version > supported_version:
344+
raise ValueError(f"{document_name} version {version} is newer than this Mini EQ build")
345+
346+
return version
347+
348+
349+
def preset_payload_state_signature(payload: dict[str, object]) -> str:
350+
json_document_version(payload, "preset", PRESET_VERSION)
351+
352+
bands_data = payload.get("bands")
353+
if not isinstance(bands_data, list):
354+
raise ValueError("preset file does not contain a valid bands list")
355+
356+
bands = inactive_eq_bands()
357+
for index, band_data in enumerate(bands_data[:MAX_BANDS]):
358+
if not isinstance(band_data, dict):
359+
raise ValueError("preset bands must be JSON objects")
360+
361+
bands[index] = eq_band_from_dict(band_data, bands[index])
362+
363+
signature_payload = {
364+
"version": PRESET_VERSION,
365+
"preamp_db": clamp(float(payload.get("preamp_db", 0.0)), EQ_PREAMP_MIN_DB, EQ_PREAMP_MAX_DB),
366+
"bands": [eq_band_to_dict(band) for band in bands],
367+
}
368+
return json.dumps(signature_payload, sort_keys=True, separators=(",", ":"))
369+
370+
335371
def normalize_output_preset_links(links: dict[object, object]) -> dict[str, str]:
336372
normalized: dict[str, str] = {}
337373

@@ -382,9 +418,7 @@ def load_output_preset_config() -> tuple[dict[str, str], str | None]:
382418
if not isinstance(payload, dict):
383419
raise ValueError("output preset links file must contain a JSON object")
384420

385-
version = int(payload.get("version", 0))
386-
if version > OUTPUT_PRESET_LINKS_VERSION:
387-
raise ValueError(f"output preset links version {version} is newer than this Mini EQ build")
421+
json_document_version(payload, "output preset links", OUTPUT_PRESET_LINKS_VERSION)
388422

389423
links = payload.get("links", {})
390424
if not isinstance(links, dict):
@@ -500,9 +534,7 @@ def load_mini_eq_preset_file(path: str | Path) -> dict[str, object]:
500534
if not isinstance(payload, dict):
501535
raise ValueError("preset file must contain a JSON object")
502536

503-
version = int(payload.get("version", 0))
504-
if version > PRESET_VERSION:
505-
raise ValueError(f"preset version {version} is newer than this Mini EQ build")
537+
json_document_version(payload, "preset", PRESET_VERSION)
506538

507539
bands = payload.get("bands")
508540
if not isinstance(bands, list):

src/mini_eq/settings.py

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,64 @@
77
from .core import app_config_dir
88

99
SETTINGS_FILE_NAME: Final = "settings.json"
10+
SETTINGS_VERSION_KEY: Final = "version"
11+
SETTINGS_VERSION: Final = 1
1012
MONITOR_ENABLED_KEY: Final = "monitor_enabled"
13+
APPEARANCE_KEY: Final = "appearance"
14+
BACKGROUND_MODE_KEY: Final = "background_mode"
15+
START_AT_LOGIN_KEY: Final = "start_at_login"
16+
START_ACTIVE_AT_LOGIN_KEY: Final = "start_active_at_login"
17+
BOOL_SETTINGS_KEYS: Final = frozenset(
18+
(
19+
MONITOR_ENABLED_KEY,
20+
BACKGROUND_MODE_KEY,
21+
START_AT_LOGIN_KEY,
22+
START_ACTIVE_AT_LOGIN_KEY,
23+
),
24+
)
25+
APPEARANCE_VALUES: Final = frozenset(("system", "light", "dark"))
1126

1227

1328
def settings_path() -> Path:
1429
return app_config_dir() / SETTINGS_FILE_NAME
1530

1631

32+
def settings_payload_version(payload: dict[str, object]) -> int | None:
33+
if SETTINGS_VERSION_KEY not in payload:
34+
return 0
35+
36+
raw_version = payload[SETTINGS_VERSION_KEY]
37+
if isinstance(raw_version, bool) or not isinstance(raw_version, int) or raw_version < 0:
38+
return None
39+
40+
return raw_version
41+
42+
43+
def normalize_settings_values(payload: dict[str, object]) -> dict[str, object]:
44+
normalized: dict[str, object] = {}
45+
46+
for key in BOOL_SETTINGS_KEYS:
47+
value = payload.get(key)
48+
if isinstance(value, bool):
49+
normalized[key] = value
50+
51+
appearance = payload.get(APPEARANCE_KEY)
52+
if isinstance(appearance, str) and appearance in APPEARANCE_VALUES:
53+
normalized[APPEARANCE_KEY] = appearance
54+
55+
return normalized
56+
57+
58+
def normalize_settings_payload(payload: dict[str, object]) -> dict[str, object]:
59+
version = settings_payload_version(payload)
60+
if version is None or version > SETTINGS_VERSION:
61+
return {}
62+
63+
normalized = normalize_settings_values(payload)
64+
normalized[SETTINGS_VERSION_KEY] = SETTINGS_VERSION
65+
return normalized
66+
67+
1768
def load_settings() -> dict[str, object]:
1869
path = settings_path()
1970
if not path.is_file():
@@ -27,13 +78,15 @@ def load_settings() -> dict[str, object]:
2778
if not isinstance(payload, dict):
2879
return {}
2980

30-
return payload
81+
return normalize_settings_payload(payload)
3182

3283

3384
def save_settings(payload: dict[str, object]) -> None:
3485
path = settings_path()
3586
path.parent.mkdir(parents=True, exist_ok=True)
36-
path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
87+
normalized = normalize_settings_values(payload)
88+
normalized[SETTINGS_VERSION_KEY] = SETTINGS_VERSION
89+
path.write_text(json.dumps(normalized, indent=2) + "\n", encoding="utf-8")
3790

3891

3992
def update_setting(key: str, value: object) -> None:

0 commit comments

Comments
 (0)