Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,16 @@ modules from a system-site build venv.
behavior on `capabilities`. Bump the API version only for semantic breaks, and
support the old version for a short, documented release window before removing
it.
- Keep app-owned JSON documents explicitly versioned with a top-level
`version` field plus migration, unsupported-version, and corrupted-schema
normalization tests. Presets and output preset links are JSON documents; do
not move them to GSettings. Valid legacy documents should load without being
rewritten on startup, then be written in the current schema only when the
user changes related state. Future-version or corrupted documents should not
be overwritten just because the app started. If GSettings is introduced
later, keep it to small typed preferences such as appearance, monitor, and
background/startup choices, and update the schema install, Flatpak, and test
paths in the same change.
- Keep the `mini-eq` CLI user-oriented. Maintainer automation belongs in
`tools/`, `docs/`, or this file.
- Keep the GNOME Shell extension source in `extensions/gnome-shell/`; do not
Expand Down
40 changes: 40 additions & 0 deletions docs/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ Run the narrowest gate that covers the release risk:
- **When background mode, Start at Login, hidden-window lifecycle, or Shell
control changed:** one clean-permission Flatpak portal smoke in a real GNOME
session.
- **When preset, output, startup, routing, monitor, or inspector UI behavior
changed:** run the workflow usability gate below before release.
- **When the GNOME Shell extension source changed:** run the extension checker,
build the review zip, test the supported Shell versions, and upload after the
app release is ready.
Expand All @@ -31,6 +33,44 @@ TestPyPI is package-index validation. It is not a user beta channel. Flathub PR
test builds are the normal stable handoff validation. Flathub beta is a
temporary user-installable Flatpak beta, not a permanent second release line.

## Workflow Usability Gate

Before releasing UI or state-machine changes, review the workflows as state
transitions, not as isolated controls. Every changed workflow should have a
single obvious current state, a reversible path, and no first-frame state
change after the window is shown.

For preset and output changes, cover these cases with unit tests when possible
and with AT-SPI or live smoke when they require real GTK behavior:

- Load, edit, reset to neutral, then reload the same saved preset from the
preset loader. Keep revert-style actions for unsaved sources that are not in
the preset library.
- Verify the preset loader does not pretend to be the running state: the visible
running-curve label must distinguish neutral, exact saved preset, modified
preset, and unsaved/imported curves.
- Import or create an unsaved curve, save it, reset it, and recover a neutral
curve without deleting the only route back.
- Delete the loaded preset, delete or modify it outside the app, and keep the
current curve understandable as an unsaved copy.
- Link, unlink, miss, and modify auto presets for both port-scoped and
output-scoped targets.
- Set, miss, and clear the default preset.
- Change output while a curve is clean, modified, auto-applied, missing, or
unavailable.
- Turn Monitor on/off and freeze/unfreeze it without leaving hidden frozen
state behind.
- Start the app with auto/default preset and auto-route inputs and verify the
visible window appears only after startup state is applied.
- Check Shell extension/D-Bus state after preset, output, background, and
window-visibility changes.

AT-SPI tests should assert externally visible behavior: accessible names, roles,
checked state, sensitivity, and critical status labels. They should not depend
on widget internals when a unit test can cover the state transition directly.
When in doubt, add a small state-level unit test first, then one AT-SPI smoke
assertion for the visible contract.

## Prepare Version

Set the release version once for the shell session:
Expand Down
Binary file modified docs/screenshots/mini-eq-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/mini-eq.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/social-preview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 6 additions & 9 deletions src/mini_eq/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,10 @@ def ensure_window(self, *, present: bool) -> None:
return
if present:
self.window.present_after_setup = True
self.window.set_visible(True)
self.window.present()
self.emit_control_state_changed()
if self.window.post_present_ready:
self.window.set_visible(True)
self.window.present()
self.emit_control_state_changed()
self.window.schedule_post_present_setup()
return

Expand All @@ -110,13 +111,9 @@ def ensure_window(self, *, present: bool) -> None:
self.window = MiniEqWindow(self, self.controller, self.args.auto_route, initial_curve_label=initial_curve_label)
self.window.set_icon_name(APP_ICON_NAME)
self.window.present_after_setup = present
self.window.set_visible(present)
if present:
self.window.present()
self.window.set_visible(False)
self.window.schedule_post_present_setup()
if present:
self.window_present_source_id = GLib.idle_add(self.on_window_present_idle)
else:
if not present:
self.update_background_status()
self.emit_control_state_changed()

Expand Down
3 changes: 1 addition & 2 deletions src/mini_eq/appearance.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

from gi.repository import Adw

from .settings import load_settings, update_setting
from .settings import APPEARANCE_KEY, load_settings, update_setting
from .settings import settings_path as _settings_path

APPEARANCE_SYSTEM: Final = "system"
Expand All @@ -18,7 +18,6 @@
APPEARANCE_MODES: Final = (APPEARANCE_SYSTEM, APPEARANCE_LIGHT, APPEARANCE_DARK)
DEFAULT_APPEARANCE: Final = APPEARANCE_SYSTEM
SETTINGS_FILE_NAME: Final = "settings.json"
APPEARANCE_KEY: Final = "appearance"


def normalize_appearance(value: object) -> str:
Expand Down
11 changes: 7 additions & 4 deletions src/mini_eq/background.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,14 @@
from gi.repository import Gio, GLib

from .desktop_integration import APP_DISPLAY_NAME, APP_ICON_NAME, APP_ID, quote_desktop_exec_arg
from .settings import load_settings, update_setting
from .settings import (
BACKGROUND_MODE_KEY,
START_ACTIVE_AT_LOGIN_KEY,
START_AT_LOGIN_KEY,
load_settings,
update_setting,
)

BACKGROUND_MODE_KEY: Final = "background_mode"
START_AT_LOGIN_KEY: Final = "start_at_login"
START_ACTIVE_AT_LOGIN_KEY: Final = "start_active_at_login"
BACKGROUND_PORTAL_REASON: Final = "Keep equalizer settings active for desktop audio."
BACKGROUND_PORTAL_BUS_NAME: Final = "org.freedesktop.portal.Desktop"
BACKGROUND_PORTAL_OBJECT_PATH: Final = "/org/freedesktop/portal/desktop"
Expand Down
44 changes: 38 additions & 6 deletions src/mini_eq/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,42 @@ def list_preset_names() -> list[str]:
return sorted(dict.fromkeys(names), key=str.casefold)


def json_document_version(payload: dict[str, object], document_name: str, supported_version: int) -> int:
if "version" not in payload:
return 0

version = payload["version"]
if isinstance(version, bool) or not isinstance(version, int) or version < 0:
raise ValueError(f"{document_name} version must be a non-negative integer")

if version > supported_version:
raise ValueError(f"{document_name} version {version} is newer than this Mini EQ build")

return version


def preset_payload_state_signature(payload: dict[str, object]) -> str:
json_document_version(payload, "preset", PRESET_VERSION)

bands_data = payload.get("bands")
if not isinstance(bands_data, list):
raise ValueError("preset file does not contain a valid bands list")

bands = inactive_eq_bands()
for index, band_data in enumerate(bands_data[:MAX_BANDS]):
if not isinstance(band_data, dict):
raise ValueError("preset bands must be JSON objects")

bands[index] = eq_band_from_dict(band_data, bands[index])

signature_payload = {
"version": PRESET_VERSION,
"preamp_db": clamp(float(payload.get("preamp_db", 0.0)), EQ_PREAMP_MIN_DB, EQ_PREAMP_MAX_DB),
"bands": [eq_band_to_dict(band) for band in bands],
}
return json.dumps(signature_payload, sort_keys=True, separators=(",", ":"))


def normalize_output_preset_links(links: dict[object, object]) -> dict[str, str]:
normalized: dict[str, str] = {}

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

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

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

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

bands = payload.get("bands")
if not isinstance(bands, list):
Expand Down
57 changes: 55 additions & 2 deletions src/mini_eq/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,64 @@
from .core import app_config_dir

SETTINGS_FILE_NAME: Final = "settings.json"
SETTINGS_VERSION_KEY: Final = "version"
SETTINGS_VERSION: Final = 1
MONITOR_ENABLED_KEY: Final = "monitor_enabled"
APPEARANCE_KEY: Final = "appearance"
BACKGROUND_MODE_KEY: Final = "background_mode"
START_AT_LOGIN_KEY: Final = "start_at_login"
START_ACTIVE_AT_LOGIN_KEY: Final = "start_active_at_login"
BOOL_SETTINGS_KEYS: Final = frozenset(
(
MONITOR_ENABLED_KEY,
BACKGROUND_MODE_KEY,
START_AT_LOGIN_KEY,
START_ACTIVE_AT_LOGIN_KEY,
),
)
APPEARANCE_VALUES: Final = frozenset(("system", "light", "dark"))


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


def settings_payload_version(payload: dict[str, object]) -> int | None:
if SETTINGS_VERSION_KEY not in payload:
return 0

raw_version = payload[SETTINGS_VERSION_KEY]
if isinstance(raw_version, bool) or not isinstance(raw_version, int) or raw_version < 0:
return None

return raw_version


def normalize_settings_values(payload: dict[str, object]) -> dict[str, object]:
normalized: dict[str, object] = {}

for key in BOOL_SETTINGS_KEYS:
value = payload.get(key)
if isinstance(value, bool):
normalized[key] = value

appearance = payload.get(APPEARANCE_KEY)
if isinstance(appearance, str) and appearance in APPEARANCE_VALUES:
normalized[APPEARANCE_KEY] = appearance

return normalized


def normalize_settings_payload(payload: dict[str, object]) -> dict[str, object]:
version = settings_payload_version(payload)
if version is None or version > SETTINGS_VERSION:
return {}

normalized = normalize_settings_values(payload)
normalized[SETTINGS_VERSION_KEY] = SETTINGS_VERSION
return normalized


def load_settings() -> dict[str, object]:
path = settings_path()
if not path.is_file():
Expand All @@ -27,13 +78,15 @@ def load_settings() -> dict[str, object]:
if not isinstance(payload, dict):
return {}

return payload
return normalize_settings_payload(payload)


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


def update_setting(key: str, value: object) -> None:
Expand Down
18 changes: 18 additions & 0 deletions src/mini_eq/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,11 @@ overlay-split-view.mini-eq-workspace > widget.sidebar-pane {
color: alpha(var(--window-fg-color), 0.86);
}

.current-curve-label {
color: alpha(var(--window-fg-color), 0.92);
font-weight: 700;
}

.compare-row {
padding: 4px 0;
border-radius: 0;
Expand Down Expand Up @@ -206,6 +211,19 @@ overlay-split-view.mini-eq-workspace > widget.sidebar-pane {
min-width: 190px;
}

.preset-library-popover {
min-width: 260px;
}

.preset-library-list {
min-width: 248px;
}

.preset-library-action {
padding-top: 5px;
padding-bottom: 5px;
}

.preset-menu-separator {
margin-top: 4px;
margin-bottom: 4px;
Expand Down
Loading
Loading