From add01f95da8d66c68d6203ac4993b55806ac0a80 Mon Sep 17 00:00:00 2001 From: Leandro Rodrigues Date: Sat, 11 Jul 2026 23:15:57 -0300 Subject: [PATCH 1/6] refactor: remove boilerplate and simplify the way for registering modules - Introduced unit tests for the `scoreIconWeaveCandidate` function to validate scoring logic based on various input scenarios. - Added comprehensive tests for `ModuleManager` to ensure proper handling of module lifecycle, including enabling, disabling, and capability changes. - Enhanced registry tests to ensure consistency between module definitions and GSettings schema. - Updated runtime tests to reflect changes in module manifest structure and validation. - Improved schema tests to validate XML structure and ensure all module keys are accounted for. --- AGENTS.md | 98 ++-- ARCHITECTURE.md | 206 +++------ CONTRIBUTING.md | 133 ++---- data/po/pt_BR.po | 339 +++++++------- ....shell.extensions.aurora-shell.gschema.xml | 10 + src/clipboard/clipboardHistory.manifest.ts | 26 ++ src/clipboard/clipboardHistory.ts | 69 +-- src/core/cleanupBag.ts | 67 +++ src/core/context.ts | 6 + src/desktop/trayIcons/trayIcons.manifest.ts | 55 +++ src/desktop/trayIcons/trayIcons.ts | 149 ++---- src/device/device.ts | 152 ++++-- src/device/runtime.ts | 125 +++++ src/dock/dock.manifest.ts | 30 ++ src/dock/dock.ts | 86 ++-- src/extension.ts | 137 ++---- src/module.ts | 24 +- src/moduleCatalog.ts | 64 +++ src/moduleManager.ts | 101 ++++ src/panel/auroraMenu.manifest.ts | 88 ++++ src/panel/auroraMenu.ts | 136 +----- src/panel/auroraMenuState.ts | 34 ++ .../bluetoothMenu/bluetoothMenu.manifest.ts | 10 + src/panel/bluetoothMenu/bluetoothMenu.ts | 10 - .../meetingClock/meetingClock.manifest.ts | 62 +++ src/panel/clock/meetingClock/meetingClock.ts | 100 +--- .../weatherClock/weatherClock.manifest.ts | 18 + src/panel/clock/weatherClock/weatherClock.ts | 57 +-- src/panel/lockKeyIndicators.manifest.ts | 10 + src/panel/lockKeyIndicators.ts | 10 - src/panel/lowBatteryPercentage.manifest.ts | 10 + src/panel/lowBatteryPercentage.ts | 10 - src/panel/volumeMixer/volumeMixer.manifest.ts | 10 + src/panel/volumeMixer/volumeMixer.ts | 10 - src/patches/appSearchTooltip.manifest.ts | 10 + src/patches/appSearchTooltip.ts | 10 - src/patches/focusLaunchedWindows.manifest.ts | 10 + src/patches/focusLaunchedWindows.ts | 10 - src/patches/iconWeave.manifest.ts | 10 + src/patches/iconWeave.ts | 76 +-- src/patches/iconWeaveScoring.ts | 95 ++++ src/patches/noOverview.manifest.ts | 10 + src/patches/noOverview.ts | 10 - src/patches/pipOnTop.manifest.ts | 10 + src/patches/pipOnTop.ts | 10 - src/patches/velaVpnQuickSettings.manifest.ts | 18 + src/patches/velaVpnQuickSettings.ts | 153 +++++++ src/patches/xwaylandIndicator.manifest.ts | 10 + src/patches/xwaylandIndicator.ts | 10 - src/prefs.ts | 12 +- src/prefsMetadata.ts | 432 ------------------ src/privacy/privacy.manifest.ts | 24 + src/privacy/privacy.ts | 24 - src/registry.ts | 102 +++-- src/shared/ui/dash.ts | 47 +- src/shared/ui/dashLayout.ts | 37 ++ src/theme/autoThemeSwitcher.manifest.ts | 26 ++ src/theme/autoThemeSwitcher.ts | 26 -- src/theme/themeChanger.manifest.ts | 10 + src/theme/themeChanger.ts | 10 - tests/shell/auroraVelaVpnQuickSettings.js | 83 ++++ tests/unit/auroraMenuState.test.ts | 23 + tests/unit/cleanupBag.test.ts | 62 +++ tests/unit/dashLayout.test.ts | 42 ++ tests/unit/deviceRuntime.test.ts | 102 +++++ tests/unit/iconWeaveScoring.test.ts | 42 ++ tests/unit/moduleManager.test.ts | 157 +++++++ tests/unit/registry.test.ts | 421 +++++++---------- tests/unit/runtime.test.ts | 31 +- tests/unit/schema.test.ts | 315 ++++++++----- 70 files changed, 2721 insertions(+), 2211 deletions(-) create mode 100644 src/clipboard/clipboardHistory.manifest.ts create mode 100644 src/core/cleanupBag.ts create mode 100644 src/desktop/trayIcons/trayIcons.manifest.ts create mode 100644 src/device/runtime.ts create mode 100644 src/dock/dock.manifest.ts create mode 100644 src/moduleCatalog.ts create mode 100644 src/moduleManager.ts create mode 100644 src/panel/auroraMenu.manifest.ts create mode 100644 src/panel/auroraMenuState.ts create mode 100644 src/panel/bluetoothMenu/bluetoothMenu.manifest.ts create mode 100644 src/panel/clock/meetingClock/meetingClock.manifest.ts create mode 100644 src/panel/clock/weatherClock/weatherClock.manifest.ts create mode 100644 src/panel/lockKeyIndicators.manifest.ts create mode 100644 src/panel/lowBatteryPercentage.manifest.ts create mode 100644 src/panel/volumeMixer/volumeMixer.manifest.ts create mode 100644 src/patches/appSearchTooltip.manifest.ts create mode 100644 src/patches/focusLaunchedWindows.manifest.ts create mode 100644 src/patches/iconWeave.manifest.ts create mode 100644 src/patches/iconWeaveScoring.ts create mode 100644 src/patches/noOverview.manifest.ts create mode 100644 src/patches/pipOnTop.manifest.ts create mode 100644 src/patches/velaVpnQuickSettings.manifest.ts create mode 100644 src/patches/velaVpnQuickSettings.ts create mode 100644 src/patches/xwaylandIndicator.manifest.ts delete mode 100644 src/prefsMetadata.ts create mode 100644 src/privacy/privacy.manifest.ts create mode 100644 src/shared/ui/dashLayout.ts create mode 100644 src/theme/autoThemeSwitcher.manifest.ts create mode 100644 src/theme/themeChanger.manifest.ts create mode 100644 tests/shell/auroraVelaVpnQuickSettings.js create mode 100644 tests/unit/auroraMenuState.test.ts create mode 100644 tests/unit/cleanupBag.test.ts create mode 100644 tests/unit/dashLayout.test.ts create mode 100644 tests/unit/deviceRuntime.test.ts create mode 100644 tests/unit/iconWeaveScoring.test.ts create mode 100644 tests/unit/moduleManager.test.ts diff --git a/AGENTS.md b/AGENTS.md index ed36142..0826af1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,13 +13,14 @@ or `just shexli` unless the task specifically requires that validation. 1. **Run `just validate`** — type-checks the source, lints, and checks formatting. Fix any reported errors. 2. **Run `just shexli`** — packages the extension and runs the extensions.gnome.org static analyzer on the generated ZIP. Review every finding. Some `warning` or `manual_review` findings can be false positives or accepted GNOME-review tradeoffs, but they must be called out explicitly; fix any real regression before finishing. 3. **Run targeted integration tests:** - * If you modified only **one module**, run only the integration test for that module (e.g., `just test tests/shell/auroraTrayIcons.js`). - * If you made **formatting-only changes** (Prettier) and have already passed the tests in a previous turn, you only need to run `just validate` and `just shexli`. - * If you made **architectural or cross-cutting changes**, run `just toolbox test-all`. + - If you modified only **one module**, run only the integration test for that module (e.g., `just test tests/shell/auroraTrayIcons.js`). + - If you made **formatting-only changes** (Prettier) and have already passed the tests in a previous turn, you only need to run `just validate` and `just shexli`. + - If you made **architectural or cross-cutting changes**, run `just toolbox test-all`. **IMPORTANT:** Never execute the `test` command (or `test-all`) chained with another command using `&&`. Always run it as a separate standalone turn. To read only the relevant output from a `test-all` run (pass/fail summary): + ```sh just toolbox test-all 2>&1 | grep -E "PASS:|FAIL:|Results:" ``` @@ -63,12 +64,13 @@ Do not leave a task incomplete if either command reports errors or failures. ## Repository Structure - `src/` — TypeScript source root - - `extension.ts` — entry point; iterates the registry and instantiates modules via each definition's `factory` - - `module.ts` — base `Module` class plus the shared `ModuleOption` / `ModuleMetadata` / `ModuleDefinition` / runtime policy types - - `registry.ts` — aggregator; imports each module's `definition` export and returns them in UI order (used by `extension.ts`) - - `prefsMetadata.ts` — pure metadata mirror for the prefs UI; cannot import modules because prefs runs in `gnome-extensions-app` (no `resource:///org/gnome/shell/*` available). Also exports `getSections()` (the ordered list of prefs sections) - - `prefs.ts` — generic extension preferences UI driven by `prefsMetadata.ts`; renders one `Adw.PreferencesGroup` per section - - `core/` — Clean Architecture core + - `extension.ts` — entry point; creates the context and delegates lifecycle to `ModuleManager` + - `module.ts` — base `Module` plus manifest, option, factory, and runtime policy types + - `moduleCatalog.ts` — ordered Shell-free manifest catalog shared by runtime and preferences + - `moduleManager.ts` — settings/runtime reconciliation, failure isolation, and teardown + - `registry.ts` — associates every catalog manifest with its runtime factory + - `prefs.ts` — generic preferences UI driven directly by `moduleCatalog.ts` + - `core/` — small shared context, cleanup, logging, and settings utilities - `context.ts` — `ExtensionContext` interface and implementation - `logger.ts` — Abstracted logging - `settings.ts` — `SettingsManager` abstraction for GSettings @@ -105,80 +107,42 @@ Do not leave a task incomplete if either command reports errors or failures. 1. **Settings via context:** Modules receive an `ExtensionContext` in their constructor and read configuration through `this.context.settings` (the `SettingsManager` abstraction) rather than touching `Gio.Settings` directly. 2. **`Main` is fair game:** Importing `Main` (`resource:///org/gnome/shell/ui/main.js`) directly is the idiomatic GNOME-extension way and is expected — there is no shell adapter. Confidence in shell interactions comes from the `tests/shell/` integration suite running a real headless GNOME Shell, not from mocking `Main`. 3. **Layering & testability:** Keep UI logic (Clutter/St) separated from pure domain logic. Extract complex algorithms into pure TypeScript files with no shell imports (e.g., `src/dock/monitorTopology.ts`, `src/desktop/trayIcons/trayState.ts`) so they can be unit-tested with `node --test`. UI/shell glue is covered by integration tests instead. -4. **Metadata-Driven UI:** The preferences window is generated dynamically from `src/prefsMetadata.ts` (a hand-maintained mirror of each module's metadata, kept in parity by `tests/unit/registry.test.ts`). If a module needs options, define them in the `options` array of its `ModuleDefinition` and mirror them into `prefsMetadata.ts`. -5. **Self-Registering Modules:** Each module file exports a `definition: ModuleDefinition` co-located with its class. The factory that constructs the module lives on the definition itself — `src/registry.ts` is a pure aggregator and never references module classes directly. +4. **Metadata-Driven UI:** Each feature owns a Shell-free `*.manifest.ts`. `moduleCatalog.ts` is the single ordered metadata source consumed by preferences and runtime. +5. **Factories:** Runtime implementations export their classes. `registry.ts` is the explicit association between catalog keys and factories; implementation files contain no preference metadata. ## Adding a Module -1. Create a module in the appropriate semantic area with a `Module` subclass **and** a co-located `definition` export. Single-file patches can live directly in `src/patches/`; complex modules should use a feature folder such as `src/panel/myModule/myModule.ts`. +1. Create a Shell-free `*.manifest.ts` beside the implementation: ```typescript import { gettext as _ } from 'gettext'; +import type { ModuleManifest } from '~/module.ts'; -import type { ExtensionContext } from '~/core/context.ts'; -import type { ModuleDefinition } from '~/module.ts'; -import { Module } from '~/module.ts'; - -export class MyModule extends Module { - constructor(context: ExtensionContext) { - super(context); - } - override enable(): void { /* setup using this.context */ } - override disable(): void { /* cleanup */ } -} - -export const definition: ModuleDefinition = { - key: 'my-module', - settingsKey: 'module-my-module', - section: 'behavior', // must match an id from getSections() in prefsMetadata.ts - title: _('My Module'), - subtitle: _('Description'), - runtime: { targets: ['desktop'] }, // optional; omitted modules default to desktop-only - options: [ - { key: 'my-option', title: _('Option'), subtitle: _('Desc'), type: 'switch' }, - ], - factory: (ctx) => new MyModule(ctx), -}; -``` - -2. Register the definition in `src/registry.ts` (one import + one array entry, preserving UI order): - -```typescript -import { definition as myModule } from '~/patches/myModule.ts'; -// …inside getModuleRegistry(): -return [/* …, */ myModule]; -``` - -3. Mirror the metadata into `src/prefsMetadata.ts` (prefs cannot import modules — see the file header). Include the same `section`: - -```typescript -{ +export const manifest: ModuleManifest = { key: 'my-module', settingsKey: 'module-my-module', section: 'behavior', title: _('My Module'), subtitle: _('Description'), - options: [ - { key: 'my-option', title: _('Option'), subtitle: _('Desc'), type: 'switch' }, - ], -}, + runtime: { roles: ['desktop'], scope: 'session' }, + options: [{ key: 'my-option', title: _('Option'), subtitle: _('Desc'), type: 'switch' }], +}; ``` -4. Add a GSettings key (`data/schemas/org.gnome.shell.extensions.aurora-shell.gschema.xml`): - -```xml - - true - Enable My Module - What this module does - -``` +2. Export the `Module` implementation class from its behavior file. Keep preference metadata out of + that implementation. +3. Add the manifest to `moduleCatalog.ts` in display order and associate its class factory in + `registry.ts`. +4. Add every declared module, option, and internal setting key to the GSettings schema. +5. Add unit and Shell integration coverage as appropriate. -`tests/unit/registry.test.ts` enforces that steps 2, 3, and 4 stay in parity — including that every module's `section` is a known section id and matches between the registry and `prefsMetadata.ts`. A half-finished addition will fail CI. +`registry.test.ts` checks the catalog/factory relationship through the TypeScript AST. +`schema.test.ts` structurally validates that every catalog setting is present in the XML and +that no stale schema setting remains. ### Prefs sections -The prefs window groups modules by `section`. The ordered section list lives in `getSections()` in `src/prefsMetadata.ts`: +The prefs window groups modules by `section`. The ordered section list lives in `getSections()` in `src/moduleCatalog.ts`: ```typescript export function getSections(): ModuleSection[] { @@ -213,7 +177,7 @@ Avoid code that only looks plausible. A human reviewer should be able to read a - Keep runtime capability checks honest. Hardware-specific modules must detect missing services/devices at runtime and stay inactive or degrade explicitly. - Do not scatter `as unknown as ...` casts through feature modules. If GObject construction or Shell internals require a cast, isolate it in a small shared helper/factory with a clear name. - Do not leave placeholder helpers, legacy duplicates, or unused compatibility functions after a refactor. Remove dead code instead of keeping it “just in case”. -- Keep strings and metadata truthful and synchronized across module `definition`, `src/prefsMetadata.ts`, schema XML, README/architecture docs, and `.po` files when strings change. +- Keep strings and metadata truthful and synchronized across `*.manifest.ts`, `moduleCatalog.ts`, schema XML, README/architecture docs, and `.po` files when strings change. - Search for obvious generated-code artifacts before finishing: broken joined words in docs, stale project names, obsolete env vars, and UI descriptions that exceed what is implemented. - Prefer explicit D-Bus/property handling over no-op calls that only log success. If a feature cannot be safely implemented yet, make the limitation visible in the title/subtitle/docs rather than implying it works. @@ -254,8 +218,6 @@ gresource extract /usr/share/gnome-shell/gnome-shell-theme.gresource /org/gnome/ Extract css file: - - Common files of interest: - `/org/gnome/shell/ui/dash.js` — Dash widget (DashIcon, Dash class, DnD handling) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2a3b79f..3c3794d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,172 +1,96 @@ # Aurora Shell Architecture -Aurora Shell is a modular GNOME Shell extension. Regular features live as registry modules: each -module exports a colocated `definition` object and is instantiated by `src/extension.ts` through -`src/registry.ts`. +Aurora Shell is a pragmatic modular GNOME Shell extension. Features remain grouped by functional +area (`dock`, `panel`, `desktop`, `privacy`, `clipboard`, `theme`, `patches`, and `shared`) and use +Shell APIs directly where appropriate. ## Overview ```mermaid flowchart TD Shell[GNOME Shell] --> Extension[src/extension.ts] + Extension --> Manager[src/moduleManager.ts] Extension --> Context[src/core/context.ts] - Extension --> Registry[src/registry.ts] - Registry --> Modules[semantic module folders] - Modules --> Shared[src/shared/*] - Modules --> Core[src/core/*] + Manager --> Registry[src/registry.ts] + Registry --> Catalog[src/moduleCatalog.ts] + Catalog --> Manifests[feature *.manifest.ts] + Registry --> Modules[feature implementations] + Prefs[src/prefs.ts] --> Catalog Context --> Device[src/device/*] - Prefs[src/prefs.ts] --> PrefsMetadata[src/prefsMetadata.ts] - PrefsMetadata -. mirrors .-> Registry - Schema[data/schemas/*.xml] -. validates settings .-> Registry - Tests[tests/unit + tests/shell] --> Registry - Tests --> Modules + Catalog -. setting contract .-> Schema[data/schemas/*.xml] ``` +Manifests contain only presentation metadata and runtime policy, so preferences can load the same +catalog as the runtime without importing GNOME Shell UI modules. `registry.ts` is the sole mapping +from manifest keys to runtime factories. + +## Runtime Lifecycle + +`ModuleManager` owns module instances, setting subscriptions, runtime compatibility, failure +isolation, reconciliation after device/topology changes, and reverse-order teardown. Modules keep +the intentionally small `enable()` / `disable()` contract. + +`CleanupBag` is available through `ExtensionContext.createCleanupBag()` for repetitive signals, +source IDs, D-Bus watches, and arbitrary cleanup. Cleanup is reverse-order and idempotent. Dynamic +resources may remain explicit when their lifecycle needs module-specific state. + +## Device And Display Runtime + +`DeviceService` maintains a reactive snapshot containing: + +- device class (`phone`, `tablet`, `laptop`, `desktop`, or `unknown`); +- input mode (`touch`, `pointer`, `keyboard`, `mixed`, or `unknown`); +- logical monitor geometry, scale, orientation, built-in status, and display role; +- capabilities that are backed by real probes. + +Display roles are `desktop`, `mobile`, and `unknown`; `shared` is not a hardware role. A module that +works on both declares both roles. Mobile-only topologies currently add a desktop runtime fallback, +so the existing desktop experience remains available until mobile surfaces are registered. Mixed +topologies retain both roles, allowing a built-in phone display and an external desktop display to +coexist later. + ## Source Layout ```text src/ - core/ Extension context, settings, and logging + module.ts Module, manifest, option, and runtime policy contracts + moduleCatalog.ts Ordered preference/runtime manifest catalog and sections + moduleManager.ts Runtime lifecycle and reconciliation + registry.ts Manifest-to-factory association + core/ Context, CleanupBag, settings, and logging + device/ Reactive detection plus pure classification clipboard/ Clipboard history module and UI desktop/ Desktop-only modules such as tray icons - dev/ Developer-only tools - device/ Runtime target and hardware capability detection - dock/ Dock module and dock-specific helpers - panel/ GNOME panel and Quick Settings integrations - patches/ Focused Shell behavior patches and monkey-patches + dock/ Dock module, bindings, intellihide, and topology helpers + panel/ Panel, clocks, menus, and Quick Settings integrations + patches/ Focused Shell behavior patches privacy/ Privacy and screen-sharing behavior - shared/ Utilities shared by modules - styles/ SCSS partials compiled into Shell stylesheets + shared/ Utilities and shared widgets theme/ Theme and color-scheme modules ``` -## Runtime Lifecycle - -```mermaid -sequenceDiagram - participant Shell as GNOME Shell - participant Ext as extension.ts - participant Ctx as ExtensionContext - participant Reg as registry.ts - participant Mod as Module - participant Settings as GSettings - - Shell->>Ext: enable() - Ext->>Settings: getSettings() - Ext->>Ctx: create context - Ext->>Reg: getModuleRegistry() - Reg-->>Ext: ModuleDefinition[] - loop enabled module setting - Ext->>Settings: get_boolean(def.settingsKey) - Ext->>Mod: def.factory(context) - Ext->>Mod: enable() - end - Ext->>Settings: connect changed::module-* - Settings-->>Ext: setting changed - Ext->>Mod: enable() or disable() - Shell->>Ext: disable() - Ext->>Settings: disconnectObject(this) - Ext->>Mod: disable() -``` - -## Module Contract +Complex Shell widgets keep pure calculations in Shell-free files where useful. Current examples +include device classification, Dash layout, monitor topology, tray state, and clock presentation. -```mermaid -classDiagram - class Module { - <> - #context ExtensionContext - +enable() void - +disable() void - } - - class ModuleDefinition { - +key string - +settingsKey string - +section string - +title string - +subtitle string - +options ModuleOption[] - +runtime ModuleRuntimePolicy - +factory(context) Module - } - - class ExtensionContext { - +uuid string - +path string - +settings SettingsManager - +device DeviceService - } - - ModuleDefinition --> Module : creates - Module --> ExtensionContext : uses -``` +## Adding A Module -Each module owns its runtime behavior and cleanup. `enable()` and `disable()` must stay symmetric: -actors, signal handlers, timeouts, D-Bus watches, and injected Shell UI must be removed by the same -module that created them. +1. Add `feature.manifest.ts` and the runtime implementation in the appropriate functional area. +2. Import the manifest into `moduleCatalog.ts` in preference order. +3. Associate its factory in `registry.ts`. +4. Add its module and option keys to the GSettings schema. +5. Add unit and/or Shell integration coverage. -`ModuleDefinition.runtime` is optional. When omitted, the module is treated as desktop-only. Use -`targets: ['shared']` only for modules that are intentionally valid on both desktop and future -mobile runtimes. Hardware-specific modules should declare `requires` capabilities instead of -probing and failing late inside `enable()`. - -## Registry And Preferences - -Every registry module must stay in sync across: - -- `src/registry.ts` -- `src/prefsMetadata.ts` -- `data/schemas/org.gnome.shell.extensions.aurora-shell.gschema.xml` - -```mermaid -flowchart LR - Definition[Module definition] --> Registry[src/registry.ts] - Definition --> PrefsMirror[src/prefsMetadata.ts] - Definition --> SchemaKey[GSettings module-* key] - - Registry --> Runtime[Runtime enable/disable] - PrefsMirror --> Preferences[Preferences UI] - SchemaKey --> Settings[GSettings storage] - - UnitTests[registry.test.ts + schema.test.ts] --> Registry - UnitTests --> PrefsMirror - UnitTests --> SchemaKey -``` - -`tests/unit/registry.test.ts` and `tests/unit/schema.test.ts` enforce that parity. A module addition -is incomplete until all three places are updated. +`registry.test.ts` inspects the TypeScript AST to enforce catalog/factory coverage and stable module +order. `schema.test.ts` structurally parses the XML and requires all manifest-declared setting keys +to match the schema exactly. ## Test Boundaries -```mermaid -flowchart TD - Pure[Pure TypeScript logic] --> Unit[tests/unit/*.test.ts] - ShellGlue[St / Clutter / Main integration] --> ShellTests[tests/shell/aurora*.js] - Build[TypeScript + SCSS + schemas] --> Validate[just validate] - ShellTests --> Toolbox[just toolbox test-all] -``` - -Keep heavy algorithms outside Shell imports when practical. Shell-facing code is expected to import -GNOME Shell internals directly and is verified through integration tests running a real headless -Shell session. +Pure TypeScript logic belongs in `tests/unit`. St/Clutter/Main integration belongs in +`tests/shell`. Architectural changes are accepted only after `just validate`, `just unit-test`, +`just shexli`, and `just toolbox test-all`. ## Packaging -```mermaid -flowchart TD - Source[src/**/*.ts] --> Build[yarn build] - Styles[src/styles/*.scss] --> Build - Metadata[metadata.json] --> Package[gnome-extensions pack] - Schemas[data/schemas] --> Package - Icons[data/icons] --> Package - Translations[data/po] --> MO[compile .mo files] - Build --> Dist[dist/] - MO --> Dist - Dist --> Package - Package --> Zip[dist/target/*.shell-extension.zip] -``` - -`just package` builds TypeScript and SCSS into `dist/`, compiles schemas and translations, then -packs the GNOME extension zip. Top-level generated directories imported at runtime must be listed as -extra sources in the `justfile`. +`just package` builds TypeScript and SCSS into `dist/`, compiles schemas and translations, and packs +the extension. Manifests stay as source modules; no TypeScript or XML generation step is involved. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d431e33..b079d74 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,123 +4,46 @@ Thank you for your interest in contributing to Aurora Shell! This document outli ## Architecture Overview -Aurora Shell is designed to be highly modular. Each feature is an independent module that can be enabled or disabled by the user without affecting other features. +Aurora Shell keeps GNOME integration close to each functional area. `extension.ts` creates the +context and delegates module lifecycle to `ModuleManager`. The ordered `moduleCatalog.ts` is shared by +runtime and preferences; `registry.ts` only associates each manifest with its implementation factory. -```mermaid -graph TD - classDef core fill:#f9f2f4,stroke:#e83e8c,stroke-width:2px,color:#c7254e; - classDef adapter fill:#e7f3ff,stroke:#007bff,stroke-width:2px; - - A["extension.ts"] -->|Injects| B["ExtensionContext"]:::core - B -->|Provides| C["SettingsManager"]:::core - B -->|Provides| D["DeviceService"]:::adapter - - E["Module"]:::core -->|uses| B - - M["semantic folders
(class + definition)"]:::core -->|definition export| F["registry.ts"]:::core - F -->|Definitions + factory| A - P["prefsMetadata.ts"]:::core -->|Metadata mirror| G["prefs.ts (Generic UI)"] -``` - -1. **Dependency Injection:** `extension.ts` instantiates an `ExtensionContext` and injects it into every module. Modules read Aurora settings through `this.context.settings`; direct imports of GNOME Shell APIs such as `Main` are expected for Shell UI integration. -2. **Abstractions:** - - Use `this.context.settings` to interact with GSettings. - - Use `this.context.device` for runtime target and hardware capabilities. - - Keep pure logic outside Shell imports when it needs unit coverage. -3. **Layering:** Separate UI orchestration (Clutter actors) from pure business logic. Complex logic should be extracted into standalone TypeScript functions or classes. -4. **Self-Registering Modules:** Each module file exports a `definition: ModuleDefinition` co-located with its class, including the factory that constructs it. `src/registry.ts` is a pure aggregator — it imports every `definition` and returns them in UI order. `extension.ts` iterates the registry and calls `def.factory(ctx)` directly; no central factory map exists. -5. **Metadata-Driven Preferences:** The preferences UI is generated dynamically from `src/prefsMetadata.ts`, a hand-maintained metadata mirror. Prefs cannot import `registry.ts` because it runs in `gnome-extensions-app` (GTK/Adw), which cannot resolve `resource:///org/gnome/shell/*` imports pulled in transitively by module classes. Parity between `registry.ts`, `prefsMetadata.ts`, and the GSettings schema is enforced by `tests/unit/registry.test.ts`. +Module descriptions live in Shell-free `*.manifest.ts` files. Implementations contain behavior and +lifecycle only. Pure calculations should be extracted when they are independently testable, while direct +`Main`/St/Clutter integration remains idiomatic for a GNOME Shell extension. ## Adding a Module -1. Choose the semantic source area, then create a `Module` subclass **and** a co-located `definition` export. Single-file modules can live directly in their area, for example `src/patches/myPatch.ts`; complex modules should use a subfolder, for example `src/panel/myPanelFeature/myPanelFeature.ts`. +1. Create `feature.manifest.ts` and the `Module` implementation in the appropriate functional area. +2. Add the manifest to `moduleCatalog.ts` in preference order. +3. Associate the manifest key with its factory in `registry.ts`. +4. Add every module, option, and internal setting key to the GSettings schema. +5. Add unit coverage for pure logic and a Shell test for GNOME integration. -Current source areas are `dock`, `panel`, `desktop`, `patches`, `theme`, `privacy`, and `clipboard`. Use `desktop` for desktop-only features such as tray icons. Add future hardware probes under `device`, not inside feature modules. +A minimal manifest looks like: ```typescript import { gettext as _ } from 'gettext'; +import type { ModuleManifest } from '~/module.ts'; -import type { ExtensionContext } from '~/core/context.ts'; -import type { ModuleDefinition } from '~/module.ts'; -import { Module } from '~/module.ts'; - -export class MyModule extends Module { - constructor(context: ExtensionContext) { - super(context); - } - - override enable(): void { - // setup using this.context (e.g., this.context.settings.getBoolean('...')) - } - - override disable(): void { - // cleanup - } -} - -export const definition: ModuleDefinition = { +export const manifest: ModuleManifest = { key: 'my-module', settingsKey: 'module-my-module', section: 'behavior', title: _('My Module'), subtitle: _('Description'), - runtime: { targets: ['desktop'] }, - options: [ - // (Optional) add sub-settings here - { key: 'my-sub-key', title: _('Sub Setting'), subtitle: _('...'), type: 'switch' }, - ], - factory: (ctx) => new MyModule(ctx), + runtime: { roles: ['desktop'], scope: 'session' }, + options: [{ key: 'my-option', title: _('Option'), subtitle: _('Description'), type: 'switch' }], }; ``` -2. Register the definition in `src/registry.ts` — one import line plus one array entry (preserve UI order): - -```typescript -import { definition as myModule } from '~/patches/myPatch.ts'; - -export function getModuleRegistry(): ModuleDefinition[] { - return [ - // …existing modules… - myModule, - ]; -} -``` - -3. Mirror the metadata into `src/prefsMetadata.ts`. Prefs runs in a separate process that cannot statically import module source files — see the comment at the top of `prefsMetadata.ts` for the full explanation: - -```typescript -{ - key: 'my-module', - settingsKey: 'module-my-module', - section: 'behavior', - title: _('My Module'), - subtitle: _('Description'), - options: [ - { key: 'my-sub-key', title: _('Sub Setting'), subtitle: _('...'), type: 'switch' }, - ], -}, -``` - -4. Add a GSettings toggle key (`data/schemas/org.gnome.shell.extensions.aurora-shell.gschema.xml`): - -```xml - - true - Enable My Module - What this module does - -``` - -5. Build and verify: - -```bash -just build -just unit-test -``` - -`tests/unit/registry.test.ts` parses each module's `definition`, `prefsMetadata.ts`, `registry.ts`, and the schema XML. It fails if any of the four drift out of sync (missing key, duplicate settingsKey, mismatched order, missing schema entry, etc.), so a half-finished module addition is caught immediately. +The runtime implementation keeps the small `enable()`/`disable()` contract. Use +`this.context.createCleanupBag()` for repetitive signals, source IDs, D-Bus watches, and arbitrary +cleanup; keep explicit teardown where stateful GNOME APIs make it clearer. -After these steps, your module appears in Preferences and respects the runtime enable/disable toggles. +`tests/unit/registry.test.ts` verifies catalog order, uniqueness, known sections, and factory +coverage through the TypeScript AST. `tests/unit/schema.test.ts` structurally validates XML and +requires catalog settings and schema keys to match exactly. ## Branching & Release Model @@ -128,11 +51,11 @@ Aurora Shell follows a branching model aligned with GNOME Shell's own release cy ### Branches -| Branch | Purpose | -|--------|---------| -| `main` | Active development targeting the next GNOME release | -| `release/v50.x` | Maintenance branch for GNOME 50 | -| `release/v51.x` | Maintenance branch for GNOME 51 | +| Branch | Purpose | +| --------------- | --------------------------------------------------- | +| `main` | Active development targeting the next GNOME release | +| `release/v50.x` | Maintenance branch for GNOME 50 | +| `release/v51.x` | Maintenance branch for GNOME 51 | Maintenance branches are created automatically when the first tag for a new major version is pushed. @@ -203,7 +126,7 @@ The CI pipeline runs all tests and, if they pass, publishes the GitHub Release a - **Clean:** `just clean` — removes `dist/` - **Deep clean:** `just distclean` — removes `dist/` and `node_modules/` -*For a full test environment, create a Fedora toolbox via `just toolbox create` and run tests inside it using `just toolbox test-all` (preferred over `just test-all`).* +_For a full test environment, create a Fedora toolbox via `just toolbox create` and run tests inside it using `just toolbox test-all` (preferred over `just test-all`)._ ## GNOME Extensions Review diff --git a/data/po/pt_BR.po b/data/po/pt_BR.po index 33d5b7f..9d01fc1 100644 --- a/data/po/pt_BR.po +++ b/data/po/pt_BR.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: aurora-shell\n" "Report-Msgid-Bugs-To: https://github.com/luminusOS/aurora-shell/issues\n" -"POT-Creation-Date: 2026-07-05 21:35-0300\n" +"POT-Creation-Date: 2026-07-11 22:48-0300\n" "PO-Revision-Date: 2026-03-09 18:00-0300\n" "Last-Translator: Aurora Shell Contributors\n" "Language-Team: Portuguese (Brazil)\n" @@ -16,27 +16,27 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" -#: dist/clipboard/clipboardHistory.js:177 dist/prefsMetadata.js:386 +#: dist/clipboard/clipboardHistory.manifest.js:8 msgid "Clipboard History" msgstr "" -#: dist/clipboard/clipboardHistory.js:178 dist/prefsMetadata.js:387 +#: dist/clipboard/clipboardHistory.manifest.js:9 msgid "Searchable clipboard history with pinning and keyboard navigation" msgstr "" -#: dist/clipboard/clipboardHistory.js:182 dist/prefsMetadata.js:391 +#: dist/clipboard/clipboardHistory.manifest.js:13 msgid "Open Shortcut" msgstr "" -#: dist/clipboard/clipboardHistory.js:183 dist/prefsMetadata.js:392 +#: dist/clipboard/clipboardHistory.manifest.js:14 msgid "Keyboard shortcut to open the clipboard history panel" msgstr "" -#: dist/clipboard/clipboardHistory.js:188 dist/prefsMetadata.js:397 +#: dist/clipboard/clipboardHistory.manifest.js:19 msgid "Poll Interval (ms)" msgstr "" -#: dist/clipboard/clipboardHistory.js:189 dist/prefsMetadata.js:398 +#: dist/clipboard/clipboardHistory.manifest.js:20 msgid "How often to check the clipboard for changes" msgstr "" @@ -82,92 +82,92 @@ msgstr "" msgid "Quit" msgstr "" -#: dist/desktop/trayIcons/trayIcons.js:451 dist/prefsMetadata.js:335 +#: dist/desktop/trayIcons/trayIcons.manifest.js:8 msgid "Tray Icons" msgstr "" -#: dist/desktop/trayIcons/trayIcons.js:452 dist/prefsMetadata.js:336 +#: dist/desktop/trayIcons/trayIcons.manifest.js:9 msgid "System tray with SNI and background app icons" msgstr "" -#: dist/desktop/trayIcons/trayIcons.js:457 dist/prefsMetadata.js:340 +#: dist/desktop/trayIcons/trayIcons.manifest.js:14 msgid "Visible Icon Limit" msgstr "" -#: dist/desktop/trayIcons/trayIcons.js:458 dist/prefsMetadata.js:341 +#: dist/desktop/trayIcons/trayIcons.manifest.js:15 msgid "Maximum number of icons shown before the expand button appears" msgstr "" -#: dist/desktop/trayIcons/trayIcons.js:465 dist/prefsMetadata.js:348 +#: dist/desktop/trayIcons/trayIcons.manifest.js:22 msgid "Icon Size" msgstr "" -#: dist/desktop/trayIcons/trayIcons.js:466 dist/prefsMetadata.js:349 +#: dist/desktop/trayIcons/trayIcons.manifest.js:23 msgid "Tray icon size in pixels (14–24)" msgstr "" -#: dist/desktop/trayIcons/trayIcons.js:473 dist/prefsMetadata.js:356 +#: dist/desktop/trayIcons/trayIcons.manifest.js:30 msgid "Attention Auto-Collapse (seconds)" msgstr "" -#: dist/desktop/trayIcons/trayIcons.js:474 dist/prefsMetadata.js:357 +#: dist/desktop/trayIcons/trayIcons.manifest.js:31 msgid "Seconds before the tray collapses after a notification icon appears" msgstr "" -#: dist/desktop/trayIcons/trayIcons.js:481 dist/prefsMetadata.js:364 +#: dist/desktop/trayIcons/trayIcons.manifest.js:38 msgid "Hide Background App When Tray Icon Present" msgstr "" -#: dist/desktop/trayIcons/trayIcons.js:482 dist/prefsMetadata.js:365 +#: dist/desktop/trayIcons/trayIcons.manifest.js:39 msgid "Remove the background app icon when the same app has an SNI tray icon" msgstr "" -#: dist/desktop/trayIcons/trayIcons.js:487 dist/prefsMetadata.js:370 +#: dist/desktop/trayIcons/trayIcons.manifest.js:44 msgid "Hide Background Apps from Quick Settings" msgstr "" -#: dist/desktop/trayIcons/trayIcons.js:488 dist/prefsMetadata.js:371 +#: dist/desktop/trayIcons/trayIcons.manifest.js:45 msgid "Hide the Background Apps section from the Quick Settings dropdown" msgstr "" -#: dist/desktop/trayIcons/trayIcons.js:493 dist/prefsMetadata.js:376 +#: dist/desktop/trayIcons/trayIcons.manifest.js:50 msgid "Recolor Symbolic Tray Icons" msgstr "" -#: dist/desktop/trayIcons/trayIcons.js:494 dist/prefsMetadata.js:377 +#: dist/desktop/trayIcons/trayIcons.manifest.js:51 msgid "Automatically recolor monochrome SNI icons to match the panel theme" msgstr "" -#: dist/dock/dock.js:512 dist/prefsMetadata.js:47 +#: dist/dock/dock.manifest.js:8 msgid "Dock" msgstr "Dock" -#: dist/dock/dock.js:513 dist/prefsMetadata.js:48 +#: dist/dock/dock.manifest.js:9 msgid "Custom dock with auto-hide and intellihide features" msgstr "Dock personalizado com ocultação automática e inteligente" -#: dist/dock/dock.js:517 dist/prefsMetadata.js:52 +#: dist/dock/dock.manifest.js:13 msgid "Always Show Dock" msgstr "" -#: dist/dock/dock.js:518 dist/prefsMetadata.js:53 +#: dist/dock/dock.manifest.js:14 msgid "" "Keep dock permanently visible and shrink windows so they never overlap it" msgstr "" -#: dist/dock/dock.js:523 dist/prefsMetadata.js:58 +#: dist/dock/dock.manifest.js:19 msgid "Show Trash Icon" msgstr "" -#: dist/dock/dock.js:524 dist/prefsMetadata.js:59 +#: dist/dock/dock.manifest.js:20 msgid "Show a trash can in the dock; click to open it, right-click to empty it" msgstr "" -#: dist/dock/dock.js:529 dist/prefsMetadata.js:64 +#: dist/dock/dock.manifest.js:25 msgid "Show External Storage" msgstr "" -#: dist/dock/dock.js:530 dist/prefsMetadata.js:65 +#: dist/dock/dock.manifest.js:26 msgid "Show removable drives in the dock when they are connected" msgstr "" @@ -201,281 +201,296 @@ msgstr "" msgid "Empty Trash" msgstr "" -#: dist/panel/auroraMenu.js:118 +#: dist/moduleCatalog.js:26 +msgid "Dock & Panel" +msgstr "" + +#: dist/moduleCatalog.js:27 +msgid "Appearance" +msgstr "" + +#: dist/moduleCatalog.js:28 +msgid "Behavior" +msgstr "" + +#: dist/moduleCatalog.js:29 +msgid "Privacy & Clipboard" +msgstr "" + +#: dist/panel/auroraMenu.js:119 msgid "About This PC" msgstr "" -#: dist/panel/auroraMenu.js:125 +#: dist/panel/auroraMenu.js:126 msgid "Home Folder" msgstr "" -#: dist/panel/auroraMenu.js:131 +#: dist/panel/auroraMenu.js:132 msgid "Downloads" msgstr "" -#: dist/panel/auroraMenu.js:141 +#: dist/panel/auroraMenu.js:142 msgid "System Settings" msgstr "" -#: dist/panel/auroraMenu.js:147 +#: dist/panel/auroraMenu.js:148 msgid "Software" msgstr "" -#: dist/panel/auroraMenu.js:153 +#: dist/panel/auroraMenu.js:154 msgid "Extensions" msgstr "" -#: dist/panel/auroraMenu.js:210 +#: dist/panel/auroraMenu.js:211 msgid "Recent Items" msgstr "" -#: dist/panel/auroraMenu.js:216 +#: dist/panel/auroraMenu.js:217 msgid "No recent items" msgstr "" -#: dist/panel/auroraMenu.js:338 dist/panel/auroraMenu.js:351 -#: dist/panel/auroraMenu.js:370 dist/panel/auroraMenu.js:501 -#: dist/prefsMetadata.js:74 +#: dist/panel/auroraMenu.js:339 dist/panel/auroraMenu.js:352 +#: dist/panel/auroraMenu.js:371 dist/panel/auroraMenu.manifest.js:8 msgid "Aurora Menu" msgstr "" -#: dist/panel/auroraMenu.js:338 dist/panel/auroraMenu.js:351 +#: dist/panel/auroraMenu.js:339 dist/panel/auroraMenu.js:352 msgid "Could not launch the selected command." msgstr "" -#: dist/panel/auroraMenu.js:370 +#: dist/panel/auroraMenu.js:371 msgid "Could not open the selected recent item." msgstr "" -#: dist/panel/auroraMenu.js:502 dist/prefsMetadata.js:75 +#: dist/panel/auroraMenu.manifest.js:9 msgid "Aurora panel menu with recent items and useful shortcuts" msgstr "" -#: dist/panel/auroraMenu.js:506 dist/prefsMetadata.js:79 +#: dist/panel/auroraMenu.manifest.js:18 msgid "Menu Icon" msgstr "" -#: dist/panel/auroraMenu.js:507 dist/prefsMetadata.js:80 +#: dist/panel/auroraMenu.manifest.js:19 msgid "Choose the icon shown in the top panel" msgstr "" -#: dist/panel/auroraMenu.js:512 dist/prefsMetadata.js:85 +#: dist/panel/auroraMenu.manifest.js:22 #, fuzzy msgid "Aurora Shell" msgstr "Logo do Aurora Shell" -#: dist/panel/auroraMenu.js:517 dist/prefsMetadata.js:90 +#: dist/panel/auroraMenu.manifest.js:23 msgid "GNOME" msgstr "" -#: dist/panel/auroraMenu.js:522 dist/prefsMetadata.js:95 +#: dist/panel/auroraMenu.manifest.js:24 msgid "Luminus OS" msgstr "" -#: dist/panel/auroraMenu.js:529 dist/prefsMetadata.js:102 +#: dist/panel/auroraMenu.manifest.js:29 msgid "Hide Activities Button" msgstr "" -#: dist/panel/auroraMenu.js:530 dist/prefsMetadata.js:103 +#: dist/panel/auroraMenu.manifest.js:30 msgid "Hide the Activities button while Aurora Menu is enabled" msgstr "" -#: dist/panel/auroraMenu.js:535 dist/prefsMetadata.js:108 +#: dist/panel/auroraMenu.manifest.js:35 msgid "Show About This PC" msgstr "" -#: dist/panel/auroraMenu.js:536 dist/prefsMetadata.js:109 +#: dist/panel/auroraMenu.manifest.js:36 msgid "Show the About This PC item in Aurora Menu" msgstr "" -#: dist/panel/auroraMenu.js:541 dist/prefsMetadata.js:114 +#: dist/panel/auroraMenu.manifest.js:41 msgid "Show Home Folder" msgstr "" -#: dist/panel/auroraMenu.js:542 dist/prefsMetadata.js:115 +#: dist/panel/auroraMenu.manifest.js:42 msgid "Show the Home Folder item in Aurora Menu" msgstr "" -#: dist/panel/auroraMenu.js:547 dist/prefsMetadata.js:120 +#: dist/panel/auroraMenu.manifest.js:47 msgid "Show Downloads" msgstr "" -#: dist/panel/auroraMenu.js:548 dist/prefsMetadata.js:121 +#: dist/panel/auroraMenu.manifest.js:48 msgid "Show the Downloads item in Aurora Menu" msgstr "" -#: dist/panel/auroraMenu.js:553 dist/prefsMetadata.js:126 +#: dist/panel/auroraMenu.manifest.js:53 msgid "Show Recent Items" msgstr "" -#: dist/panel/auroraMenu.js:554 dist/prefsMetadata.js:127 +#: dist/panel/auroraMenu.manifest.js:54 msgid "Show the Recent Items submenu in Aurora Menu" msgstr "" -#: dist/panel/auroraMenu.js:559 dist/prefsMetadata.js:132 +#: dist/panel/auroraMenu.manifest.js:59 msgid "Show System Settings" msgstr "" -#: dist/panel/auroraMenu.js:560 dist/prefsMetadata.js:133 +#: dist/panel/auroraMenu.manifest.js:60 msgid "Show the System Settings item in Aurora Menu" msgstr "" -#: dist/panel/auroraMenu.js:565 dist/prefsMetadata.js:138 +#: dist/panel/auroraMenu.manifest.js:65 msgid "Show Software" msgstr "" -#: dist/panel/auroraMenu.js:566 dist/prefsMetadata.js:139 +#: dist/panel/auroraMenu.manifest.js:66 msgid "Show the Software item in Aurora Menu" msgstr "" -#: dist/panel/auroraMenu.js:571 dist/prefsMetadata.js:144 +#: dist/panel/auroraMenu.manifest.js:71 msgid "Show Extensions" msgstr "" -#: dist/panel/auroraMenu.js:572 dist/prefsMetadata.js:145 +#: dist/panel/auroraMenu.manifest.js:72 msgid "Show the Extensions item in Aurora Menu" msgstr "" -#: dist/panel/auroraMenu.js:577 dist/prefsMetadata.js:150 +#: dist/panel/auroraMenu.manifest.js:77 msgid "Software Command" msgstr "" -#: dist/panel/auroraMenu.js:578 dist/prefsMetadata.js:151 +#: dist/panel/auroraMenu.manifest.js:78 msgid "Command used by the Software menu item" msgstr "" -#: dist/panel/auroraMenu.js:583 dist/prefsMetadata.js:156 +#: dist/panel/auroraMenu.manifest.js:83 msgid "Custom Menu Commands" msgstr "" -#: dist/panel/auroraMenu.js:584 dist/prefsMetadata.js:157 +#: dist/panel/auroraMenu.manifest.js:84 msgid "One command per line, using “Label | command”" msgstr "" -#: dist/panel/bluetoothMenu/bluetoothMenu.js:93 dist/prefsMetadata.js:254 +#: dist/panel/bluetoothMenu/bluetoothMenu.manifest.js:8 msgid "Bluetooth Menu" msgstr "" -#: dist/panel/bluetoothMenu/bluetoothMenu.js:94 dist/prefsMetadata.js:255 +#: dist/panel/bluetoothMenu/bluetoothMenu.manifest.js:9 msgid "" "Shows battery level and animated icons in the Bluetooth Quick Settings panel" msgstr "" -#: dist/panel/clock/meetingClock/meetingClock.js:322 +#: dist/panel/clock/meetingClock/meetingClock.js:325 msgid "Meeting starting soon" msgstr "" -#: dist/panel/clock/meetingClock/meetingClock.js:330 +#: dist/panel/clock/meetingClock/meetingClock.js:333 msgid "Join" msgstr "" -#: dist/panel/clock/meetingClock/meetingClock.js:331 +#: dist/panel/clock/meetingClock/meetingClock.js:334 msgid "Snooze" msgstr "" -#: dist/panel/clock/meetingClock/meetingClock.js:332 +#: dist/panel/clock/meetingClock/meetingClock.js:335 msgid "Dismiss" msgstr "" -#: dist/panel/clock/meetingClock/meetingClock.js:333 +#: dist/panel/clock/meetingClock/meetingClock.js:336 msgid "Ignore" msgstr "" -#: dist/panel/clock/meetingClock/meetingClock.js:494 -#: dist/panel/clock/meetingClock/meetingClock.js:540 dist/prefsMetadata.js:276 +#: dist/panel/clock/meetingClock/meetingClock.js:497 +#: dist/panel/clock/meetingClock/meetingClock.manifest.js:8 msgid "Meeting Clock" msgstr "" -#: dist/panel/clock/meetingClock/meetingClock.js:541 dist/prefsMetadata.js:277 +#: dist/panel/clock/meetingClock/meetingClock.manifest.js:9 msgid "Shows upcoming calendar events next to the clock" msgstr "" -#: dist/panel/clock/meetingClock/meetingClock.js:545 dist/prefsMetadata.js:281 +#: dist/panel/clock/meetingClock/meetingClock.manifest.js:13 msgid "Meeting Alerts" msgstr "" -#: dist/panel/clock/meetingClock/meetingClock.js:546 dist/prefsMetadata.js:282 +#: dist/panel/clock/meetingClock/meetingClock.manifest.js:14 msgid "Show a notification when a meeting is about to start" msgstr "" -#: dist/panel/clock/meetingClock/meetingClock.js:551 dist/prefsMetadata.js:287 +#: dist/panel/clock/meetingClock/meetingClock.manifest.js:19 msgid "Alert Lead Time (minutes)" msgstr "" -#: dist/panel/clock/meetingClock/meetingClock.js:552 dist/prefsMetadata.js:288 +#: dist/panel/clock/meetingClock/meetingClock.manifest.js:20 msgid "Minutes before a meeting starts to show the alert" msgstr "" -#: dist/panel/clock/meetingClock/meetingClock.js:559 dist/prefsMetadata.js:295 +#: dist/panel/clock/meetingClock/meetingClock.manifest.js:27 msgid "Snooze Duration (minutes)" msgstr "" -#: dist/panel/clock/meetingClock/meetingClock.js:560 dist/prefsMetadata.js:296 +#: dist/panel/clock/meetingClock/meetingClock.manifest.js:28 msgid "Minutes to wait before showing a snoozed alert again" msgstr "" -#: dist/panel/clock/meetingClock/meetingClock.js:567 dist/prefsMetadata.js:303 +#: dist/panel/clock/meetingClock/meetingClock.manifest.js:35 msgid "Alert Events Without Links" msgstr "" -#: dist/panel/clock/meetingClock/meetingClock.js:568 dist/prefsMetadata.js:304 +#: dist/panel/clock/meetingClock/meetingClock.manifest.js:36 msgid "Show meeting alerts for calendar events that do not include a join link" msgstr "" -#: dist/panel/clock/meetingClock/meetingClock.js:573 dist/prefsMetadata.js:309 +#: dist/panel/clock/meetingClock/meetingClock.manifest.js:41 msgid "Panel Reveal Interval (minutes)" msgstr "" -#: dist/panel/clock/meetingClock/meetingClock.js:574 dist/prefsMetadata.js:310 +#: dist/panel/clock/meetingClock/meetingClock.manifest.js:42 msgid "Minutes between automatic Meeting Clock slide reveals in the panel" msgstr "" -#: dist/panel/clock/meetingClock/meetingClock.js:581 dist/prefsMetadata.js:317 +#: dist/panel/clock/meetingClock/meetingClock.manifest.js:49 msgid "Panel Lookahead (minutes)" msgstr "" -#: dist/panel/clock/meetingClock/meetingClock.js:582 dist/prefsMetadata.js:318 +#: dist/panel/clock/meetingClock/meetingClock.manifest.js:50 msgid "" "Maximum minutes before an event starts for it to appear in the panel clock" msgstr "" -#: dist/panel/clock/meetingClock/meetingClock.js:589 dist/prefsMetadata.js:325 +#: dist/panel/clock/meetingClock/meetingClock.manifest.js:57 msgid "Hide All-Day Events" msgstr "" -#: dist/panel/clock/meetingClock/meetingClock.js:590 dist/prefsMetadata.js:326 +#: dist/panel/clock/meetingClock/meetingClock.manifest.js:58 msgid "Exclude all-day events from the clock and alerts" msgstr "" -#: dist/panel/clock/weatherClock/weatherClock.js:357 dist/prefsMetadata.js:261 +#: dist/panel/clock/weatherClock/weatherClock.manifest.js:8 msgid "Weather Clock" msgstr "" -#: dist/panel/clock/weatherClock/weatherClock.js:358 dist/prefsMetadata.js:262 +#: dist/panel/clock/weatherClock/weatherClock.manifest.js:9 msgid "Shows GNOME Weather next to the clock" msgstr "" -#: dist/panel/clock/weatherClock/weatherClock.js:362 dist/prefsMetadata.js:266 +#: dist/panel/clock/weatherClock/weatherClock.manifest.js:13 msgid "Show Weather After Clock" msgstr "" -#: dist/panel/clock/weatherClock/weatherClock.js:363 dist/prefsMetadata.js:267 +#: dist/panel/clock/weatherClock/weatherClock.manifest.js:14 msgid "Place the weather indicator after the clock instead of before it" msgstr "" -#: dist/panel/lockKeyIndicators.js:92 dist/prefsMetadata.js:180 +#: dist/panel/lockKeyIndicators.manifest.js:8 msgid "Lock Key Indicators" msgstr "" -#: dist/panel/lockKeyIndicators.js:93 dist/prefsMetadata.js:181 +#: dist/panel/lockKeyIndicators.manifest.js:9 msgid "Shows Caps Lock and Num Lock indicators in the top panel" msgstr "" -#: dist/panel/lowBatteryPercentage.js:126 dist/prefsMetadata.js:173 +#: dist/panel/lowBatteryPercentage.manifest.js:8 msgid "Low Battery Percentage" msgstr "" -#: dist/panel/lowBatteryPercentage.js:127 dist/prefsMetadata.js:174 +#: dist/panel/lowBatteryPercentage.manifest.js:9 msgid "Shows battery percentage in the panel while below 20%" msgstr "" @@ -498,7 +513,7 @@ msgstr "" #: dist/panel/volumeMixer/volumeMixer.js:108 #: dist/panel/volumeMixer/volumeMixer.js:117 -#: dist/panel/volumeMixer/volumeMixer.js:136 dist/prefsMetadata.js:166 +#: dist/panel/volumeMixer/volumeMixer.manifest.js:8 msgid "Volume Mixer" msgstr "Mixer de Volume" @@ -506,56 +521,74 @@ msgstr "Mixer de Volume" msgid "Sound Output" msgstr "" -#: dist/panel/volumeMixer/volumeMixer.js:137 dist/prefsMetadata.js:167 +#: dist/panel/volumeMixer/volumeMixer.manifest.js:9 msgid "Per-application volume control in Quick Settings" msgstr "Controle de volume por aplicativo nas Configurações Rápidas" -#: dist/patches/appSearchTooltip.js:151 dist/prefsMetadata.js:224 +#: dist/patches/appSearchTooltip.manifest.js:8 msgid "App Search Tooltip" msgstr "" -#: dist/patches/appSearchTooltip.js:152 dist/prefsMetadata.js:225 +#: dist/patches/appSearchTooltip.manifest.js:9 msgid "Shows app name on hover in the overview search results" msgstr "" -#: dist/patches/focusLaunchedWindows.js:35 dist/prefsMetadata.js:33 +#: dist/patches/focusLaunchedWindows.manifest.js:8 msgid "Focus Launched Windows" msgstr "" -#: dist/patches/focusLaunchedWindows.js:36 dist/prefsMetadata.js:34 +#: dist/patches/focusLaunchedWindows.manifest.js:9 msgid "" "Focuses newly launched windows instead of showing window-ready notifications" msgstr "" -#: dist/patches/iconWeave.js:482 dist/prefsMetadata.js:217 +#: dist/patches/iconWeave.manifest.js:8 msgid "Icon Weave" msgstr "" -#: dist/patches/iconWeave.js:483 dist/prefsMetadata.js:218 +#: dist/patches/iconWeave.manifest.js:9 msgid "Automatically fixes missing app icons using an in-memory approach" msgstr "" -#: dist/patches/noOverview.js:36 -msgid "No Overview" -msgstr "Sem Visão Geral" +#: dist/patches/noOverview.manifest.js:8 +msgid "Skip Overview on Login" +msgstr "" -#: dist/patches/noOverview.js:37 -msgid "Disables the overview at startup" -msgstr "Desativa a visão geral ao iniciar" +#: dist/patches/noOverview.manifest.js:9 +msgid "Goes directly to the desktop when GNOME Shell starts" +msgstr "" -#: dist/patches/pipOnTop.js:104 dist/prefsMetadata.js:26 +#: dist/patches/pipOnTop.manifest.js:8 msgid "Pip On Top" msgstr "PiP Sempre no Topo" -#: dist/patches/pipOnTop.js:105 dist/prefsMetadata.js:27 +#: dist/patches/pipOnTop.manifest.js:9 msgid "Keeps Picture-in-Picture windows always on top" msgstr "Mantém janelas Picture-in-Picture sempre no topo" -#: dist/patches/xwaylandIndicator.js:108 dist/prefsMetadata.js:187 +#: dist/patches/velaVpnQuickSettings.manifest.js:8 +msgid "Vela VPN Quick Settings" +msgstr "" + +#: dist/patches/velaVpnQuickSettings.manifest.js:9 +msgid "Routes VPN Quick Settings activation through Vela" +msgstr "" + +#: dist/patches/velaVpnQuickSettings.manifest.js:13 +msgid "Use GNOME Shell Fallback" +msgstr "" + +#: dist/patches/velaVpnQuickSettings.manifest.js:14 +msgid "" +"Use GNOME Shell only when the Vela D-Bus service or control API is " +"unavailable" +msgstr "" + +#: dist/patches/xwaylandIndicator.manifest.js:8 msgid "XWayland Indicator" msgstr "" -#: dist/patches/xwaylandIndicator.js:109 dist/prefsMetadata.js:188 +#: dist/patches/xwaylandIndicator.manifest.js:9 msgid "Shows an X11 badge on XWayland apps in the Alt+Tab switcher" msgstr "" @@ -583,87 +616,69 @@ msgstr "" msgid "Press a shortcut…" msgstr "" -#: dist/prefsMetadata.js:6 -msgid "Dock & Panel" -msgstr "" - -#: dist/prefsMetadata.js:7 -msgid "Appearance" -msgstr "" - -#: dist/prefsMetadata.js:8 -msgid "Behavior" -msgstr "" - -#: dist/prefsMetadata.js:9 -msgid "Privacy & Clipboard" -msgstr "" - -#: dist/prefsMetadata.js:19 -msgid "Skip Overview on Login" -msgstr "" - -#: dist/prefsMetadata.js:20 -msgid "Goes directly to the desktop when GNOME Shell starts" -msgstr "" - -#: dist/prefsMetadata.js:40 dist/theme/themeChanger.js:55 -msgid "Theme Changer" -msgstr "Alternador de Tema" - -#: dist/prefsMetadata.js:41 dist/theme/themeChanger.js:56 -msgid "Monitors and synchronizes GNOME color scheme" -msgstr "Monitora e sincroniza o esquema de cores do GNOME" - -#: dist/prefsMetadata.js:194 dist/privacy/privacy.js:67 +#: dist/privacy/privacy.manifest.js:8 msgid "Privacy" msgstr "" -#: dist/prefsMetadata.js:195 dist/privacy/privacy.js:68 +#: dist/privacy/privacy.manifest.js:9 msgid "Screen sharing privacy features" msgstr "" -#: dist/prefsMetadata.js:199 dist/privacy/privacy.js:72 +#: dist/privacy/privacy.manifest.js:13 msgid "DND on Screen Share" msgstr "" -#: dist/prefsMetadata.js:200 dist/privacy/privacy.js:73 +#: dist/privacy/privacy.manifest.js:14 msgid "Automatically enables Do Not Disturb mode when screen sharing" msgstr "" -#: dist/prefsMetadata.js:205 dist/privacy/privacy.js:78 +#: dist/privacy/privacy.manifest.js:19 msgid "Privacy Panel" msgstr "" -#: dist/prefsMetadata.js:207 dist/privacy/privacy.js:79 +#: dist/privacy/privacy.manifest.js:20 msgid "" "Hides panel content during screen sharing; shows only the sharing indicator" msgstr "" -#: dist/prefsMetadata.js:231 dist/theme/autoThemeSwitcher.js:112 +#: dist/theme/autoThemeSwitcher.manifest.js:8 msgid "Auto Theme Switcher" msgstr "" -#: dist/prefsMetadata.js:232 dist/theme/autoThemeSwitcher.js:113 +#: dist/theme/autoThemeSwitcher.manifest.js:9 msgid "Automatically switches between light and dark theme based on time" msgstr "" -#: dist/prefsMetadata.js:237 dist/theme/autoThemeSwitcher.js:118 +#: dist/theme/autoThemeSwitcher.manifest.js:14 msgid "Light Time" msgstr "" -#: dist/prefsMetadata.js:238 dist/theme/autoThemeSwitcher.js:119 +#: dist/theme/autoThemeSwitcher.manifest.js:15 msgid "Time to switch to light theme (HH:MM)" msgstr "" -#: dist/prefsMetadata.js:244 dist/theme/autoThemeSwitcher.js:125 +#: dist/theme/autoThemeSwitcher.manifest.js:21 msgid "Dark Time" msgstr "" -#: dist/prefsMetadata.js:245 dist/theme/autoThemeSwitcher.js:126 +#: dist/theme/autoThemeSwitcher.manifest.js:22 msgid "Time to switch to dark theme (HH:MM)" msgstr "" +#: dist/theme/themeChanger.manifest.js:8 +msgid "Theme Changer" +msgstr "Alternador de Tema" + +#: dist/theme/themeChanger.manifest.js:9 +msgid "Monitors and synchronizes GNOME color scheme" +msgstr "Monitora e sincroniza o esquema de cores do GNOME" + +#~ msgid "No Overview" +#~ msgstr "Sem Visão Geral" + +#~ msgid "Disables the overview at startup" +#~ msgstr "Desativa a visão geral ao iniciar" + #~ msgid "Modules" #~ msgstr "Módulos" diff --git a/data/schemas/org.gnome.shell.extensions.aurora-shell.gschema.xml b/data/schemas/org.gnome.shell.extensions.aurora-shell.gschema.xml index c32d660..aa2bc81 100644 --- a/data/schemas/org.gnome.shell.extensions.aurora-shell.gschema.xml +++ b/data/schemas/org.gnome.shell.extensions.aurora-shell.gschema.xml @@ -56,6 +56,16 @@ Enable App Search Tooltip module Shows app name tooltip on hover in the overview search results + + true + Enable Vela VPN Quick Settings module + Routes VPN Quick Settings activation through Vela + + + false + Use GNOME Shell VPN fallback + Use GNOME Shell only when the Vela D-Bus service or control API is unavailable + true Enable Privacy Panel diff --git a/src/clipboard/clipboardHistory.manifest.ts b/src/clipboard/clipboardHistory.manifest.ts new file mode 100644 index 0000000..3aa924c --- /dev/null +++ b/src/clipboard/clipboardHistory.manifest.ts @@ -0,0 +1,26 @@ +import { gettext as _ } from 'gettext'; +import type { ModuleManifest } from '~/module.ts'; + +export const manifest: ModuleManifest = { + key: 'clipboard-history', + settingsKey: 'module-clipboard-history', + section: 'privacy-clipboard', + title: _('Clipboard History'), + subtitle: _('Searchable clipboard history with pinning and keyboard navigation'), + options: [ + { + key: 'clipboard-history-shortcut', + title: _('Open Shortcut'), + subtitle: _('Keyboard shortcut to open the clipboard history panel'), + type: 'shortcut', + }, + { + key: 'clipboard-history-poll-interval', + title: _('Poll Interval (ms)'), + subtitle: _('How often to check the clipboard for changes'), + type: 'spin', + min: 250, + max: 5000, + }, + ], +}; diff --git a/src/clipboard/clipboardHistory.ts b/src/clipboard/clipboardHistory.ts index 4a96752..1a3df72 100644 --- a/src/clipboard/clipboardHistory.ts +++ b/src/clipboard/clipboardHistory.ts @@ -9,9 +9,9 @@ import Shell from '@girs/shell-18'; import * as Main from '@girs/gnome-shell/ui/main'; import type { ExtensionContext } from '~/core/context.ts'; +import type { CleanupBag } from '~/core/cleanupBag.ts'; import { logger } from '~/core/logger.ts'; import { Module } from '~/module.ts'; -import type { ModuleDefinition } from '~/module.ts'; import type { ClipboardEntry, ClipboardImagePayload } from '~/clipboard/clipboardStore.ts'; import { ClipboardStore } from '~/clipboard/clipboardStore.ts'; @@ -28,7 +28,7 @@ export class ClipboardHistory extends Module { private _store: ClipboardStore | null = null; private _monitor: ClipboardMonitor | null = null; private _panel: ClipboardPanel | null = null; - private _settingsIds: number[] = []; + private _cleanup: CleanupBag | null = null; private _startupIdleId: number = 0; constructor(context: ExtensionContext) { @@ -36,6 +36,7 @@ export class ClipboardHistory extends Module { } override enable(): void { + this._cleanup = this.context.createCleanupBag(); const sessionDir = GLib.get_user_runtime_dir() + '/aurora-shell/' + this.context.uuid; const filePath = sessionDir + '/clipboard-history.log'; const mediaDir = sessionDir + '/clipboard-media'; @@ -69,6 +70,12 @@ export class ClipboardHistory extends Module { this._monitor?.start(); return GLib.SOURCE_REMOVE; }); + const startupIdleId = this._startupIdleId; + this._cleanup.source(startupIdleId, (id) => { + if (this._startupIdleId !== id) return; + GLib.source_remove(id); + this._startupIdleId = 0; + }); try { Main.wm.addKeybinding( @@ -78,28 +85,25 @@ export class ClipboardHistory extends Module { Shell.ActionMode.ALL, () => this.togglePanel(), ); + this._cleanup.add(() => { + try { + Main.wm.removeKeybinding(KEYBINDING_KEY); + } catch { + // The Shell may already have removed it during shutdown. + } + }); } catch (e) { logger.error('Failed to register keybinding:', { prefix: LOG_PREFIX }, e as Error); } - this._settingsIds = [ - rawSettings.connect('changed::clipboard-history-poll-interval', () => { - this._monitor?.setInterval(rawSettings.get_int('clipboard-history-poll-interval')); - }), - ]; + this._cleanup.connect(rawSettings, 'changed::clipboard-history-poll-interval', () => { + this._monitor?.setInterval(rawSettings.get_int('clipboard-history-poll-interval')); + }); } override disable(): void { - if (this._startupIdleId !== 0) { - GLib.source_remove(this._startupIdleId); - this._startupIdleId = 0; - } - - try { - Main.wm.removeKeybinding(KEYBINDING_KEY); - } catch (_e) { - // ignore if not registered - } + this._cleanup?.dispose(); + this._cleanup = null; this._panel?.close(); this._panel?.destroy(); @@ -110,12 +114,6 @@ export class ClipboardHistory extends Module { this._store?.save(); this._store = null; - - const rawSettings = this.context.settings.getRawSettings(); - for (const id of this._settingsIds) { - rawSettings.disconnect(id); - } - this._settingsIds = []; } openPanel(): boolean { @@ -200,28 +198,3 @@ export class ClipboardHistory extends Module { this._panel?.refresh(); } } - -export const definition: ModuleDefinition = { - key: 'clipboard-history', - settingsKey: 'module-clipboard-history', - section: 'privacy-clipboard', - title: _('Clipboard History'), - subtitle: _('Searchable clipboard history with pinning and keyboard navigation'), - options: [ - { - key: 'clipboard-history-shortcut', - title: _('Open Shortcut'), - subtitle: _('Keyboard shortcut to open the clipboard history panel'), - type: 'shortcut', - }, - { - key: 'clipboard-history-poll-interval', - title: _('Poll Interval (ms)'), - subtitle: _('How often to check the clipboard for changes'), - type: 'spin', - min: 250, - max: 5000, - }, - ], - factory: (ctx) => new ClipboardHistory(ctx), -}; diff --git a/src/core/cleanupBag.ts b/src/core/cleanupBag.ts new file mode 100644 index 0000000..292c365 --- /dev/null +++ b/src/core/cleanupBag.ts @@ -0,0 +1,67 @@ +export type Cleanup = () => void; + +type SignalTarget = { + connect(signal: string, callback: (...args: Args) => void): number; + disconnect(id: number): void; +}; + +type ConnectObjectTarget = { + disconnectObject(owner: Owner): void; +}; + +export class CleanupBag { + private _cleanups: Cleanup[] = []; + private _disposed = false; + + add(cleanup: Cleanup): Cleanup { + if (this._disposed) { + cleanup(); + return cleanup; + } + this._cleanups.push(cleanup); + return cleanup; + } + + connect( + target: SignalTarget, + signal: string, + callback: (...args: Args) => void, + ): number { + const id = target.connect(signal, callback); + this.add(() => target.disconnect(id)); + return id; + } + + connectObject( + target: ConnectObjectTarget, + owner: Owner, + connect: () => void, + ): void { + connect(); + this.add(() => target.disconnectObject(owner)); + } + + source(id: number, remove: (id: number) => void): number { + this.add(() => remove(id)); + return id; + } + + watch(id: number, unwatch: (id: number) => void): number { + this.add(() => unwatch(id)); + return id; + } + + dispose(): void { + if (this._disposed) return; + this._disposed = true; + const cleanups = this._cleanups; + this._cleanups = []; + for (let index = cleanups.length - 1; index >= 0; index--) { + try { + cleanups[index]?.(); + } catch { + // Cleanup is best-effort; remaining resources must still be released. + } + } + } +} diff --git a/src/core/context.ts b/src/core/context.ts index bd89521..ca69385 100644 --- a/src/core/context.ts +++ b/src/core/context.ts @@ -1,6 +1,7 @@ import GObject from '@girs/gobject-2.0'; import type { SettingsManager } from './settings.ts'; import type { DeviceService } from '~/device/device.ts'; +import { CleanupBag } from '~/core/cleanupBag.ts'; /** * Global signal bus for Aurora Shell modules @@ -18,6 +19,7 @@ export interface ExtensionContext { readonly settings: SettingsManager; readonly signals: AuroraSignals; readonly device: DeviceService; + createCleanupBag(): CleanupBag; } export class DefaultExtensionContext implements ExtensionContext { @@ -31,4 +33,8 @@ export class DefaultExtensionContext implements ExtensionContext { ) { this.signals = new AuroraSignals(); } + + createCleanupBag(): CleanupBag { + return new CleanupBag(); + } } diff --git a/src/desktop/trayIcons/trayIcons.manifest.ts b/src/desktop/trayIcons/trayIcons.manifest.ts new file mode 100644 index 0000000..88029dc --- /dev/null +++ b/src/desktop/trayIcons/trayIcons.manifest.ts @@ -0,0 +1,55 @@ +import { gettext as _ } from 'gettext'; +import type { ModuleManifest } from '~/module.ts'; + +export const manifest: ModuleManifest = { + key: 'tray-icons', + settingsKey: 'module-tray-icons', + section: 'dock-panel', + title: _('Tray Icons'), + subtitle: _('System tray with SNI and background app icons'), + runtime: { roles: ['desktop'], scope: 'session' }, + options: [ + { + key: 'tray-icons-limit', + title: _('Visible Icon Limit'), + subtitle: _('Maximum number of icons shown before the expand button appears'), + type: 'spin', + min: 1, + max: 20, + }, + { + key: 'tray-icons-icon-size', + title: _('Icon Size'), + subtitle: _('Tray icon size in pixels (14–24)'), + type: 'spin', + min: 14, + max: 24, + }, + { + key: 'tray-icons-attention-timeout', + title: _('Attention Auto-Collapse (seconds)'), + subtitle: _('Seconds before the tray collapses after a notification icon appears'), + type: 'spin', + min: 1, + max: 30, + }, + { + key: 'tray-icons-dedup-bg-apps', + title: _('Hide Background App When Tray Icon Present'), + subtitle: _('Remove the background app icon when the same app has an SNI tray icon'), + type: 'switch', + }, + { + key: 'tray-icons-hide-bg-quick-settings', + title: _('Hide Background Apps from Quick Settings'), + subtitle: _('Hide the Background Apps section from the Quick Settings dropdown'), + type: 'switch', + }, + { + key: 'tray-icons-recolor-symbolic-pixmaps', + title: _('Recolor Symbolic Tray Icons'), + subtitle: _('Automatically recolor monochrome SNI icons to match the panel theme'), + type: 'switch', + }, + ], +}; diff --git a/src/desktop/trayIcons/trayIcons.ts b/src/desktop/trayIcons/trayIcons.ts index 30d446c..35c3836 100644 --- a/src/desktop/trayIcons/trayIcons.ts +++ b/src/desktop/trayIcons/trayIcons.ts @@ -13,9 +13,9 @@ Gio._promisify(Gio.DBusConnection.prototype, 'call'); Gio._promisify(Gio.File.prototype, 'load_contents_async'); import type { ExtensionContext } from '~/core/context.ts'; +import type { CleanupBag } from '~/core/cleanupBag.ts'; import { logger } from '~/core/logger.ts'; import { Module } from '~/module.ts'; -import type { ModuleDefinition } from '~/module.ts'; import type { SettingsManager } from '~/core/settings.ts'; import { TrayContainer } from './trayContainer.ts'; @@ -37,20 +37,20 @@ export class TrayIcons extends Module { private _sniWatcher: SniWatcher | null = null; private _sniHost: SniHost | null = null; private _bgSource: BackgroundAppsSource | null = null; - private _settingsChangedIds: number[] = []; + private _cleanup: CleanupBag | null = null; private _bgItemAppIds = new Map(); private _dedupBgApps = true; private _bgAppsToggle: any = null; private _bgAppsGrid: any = null; private _bgAppsGridChildAddedId = 0; private _desktopSettings: SettingsManager | null = null; - private _desktopSettingsChangedId = 0; constructor(context: ExtensionContext) { super(context); } override enable(): void { + this._cleanup = this.context.createCleanupBag(); const settings = this.context.settings.getRawSettings(); this._desktopSettings = this.context.settings.getSchema('org.gnome.desktop.interface'); const iconSize = settings.get_int('tray-icons-icon-size'); @@ -97,7 +97,7 @@ export class TrayIcons extends Module { }, ); this._sniWatcher.start(); - this._desktopSettingsChangedId = this._desktopSettings.connect('changed::color-scheme', () => { + this._cleanup.connect(this._desktopSettings, 'changed::color-scheme', () => { const scheme = this._desktopSettings?.getString('color-scheme') ?? 'unknown'; logger.debug(`Color scheme changed to ${scheme}; refreshing SNI icons`, { prefix: LOG_PREFIX, @@ -113,46 +113,44 @@ export class TrayIcons extends Module { .start() .catch((e) => logger.warn(`bg source start failed: ${e}`, { prefix: LOG_PREFIX })); - this._settingsChangedIds.push( - settings.connect('changed::tray-icons-limit', () => { - this._container?.setLimit(settings.get_int('tray-icons-limit')); - }), - settings.connect('changed::tray-icons-icon-size', () => { - this._container?.setIconSize(settings.get_int('tray-icons-icon-size')); - }), - settings.connect('changed::tray-icons-attention-timeout', () => { - this._container?.setAttentionTimeout(settings.get_int('tray-icons-attention-timeout')); - }), - settings.connect('changed::tray-icons-dedup-bg-apps', () => { - this._dedupBgApps = settings.get_boolean('tray-icons-dedup-bg-apps'); - if (this._dedupBgApps) { - for (const [appId, entry] of [...this._bgItemAppIds]) { - this._sniCoversApp(appId, entry.app) - .then((covered) => { - if (covered) { - this._bgItemAppIds.delete(appId); - this._container?.removeItem(entry.itemId); - } - }) - .catch(() => {}); - } - } - }), - settings.connect('changed::tray-icons-hide-bg-quick-settings', () => { - if (settings.get_boolean('tray-icons-hide-bg-quick-settings')) { - this._hideBgAppsQuickSettings(); - } else { - this._restoreBgAppsQuickSettings(); + this._cleanup.connect(settings, 'changed::tray-icons-limit', () => { + this._container?.setLimit(settings.get_int('tray-icons-limit')); + }); + this._cleanup.connect(settings, 'changed::tray-icons-icon-size', () => { + this._container?.setIconSize(settings.get_int('tray-icons-icon-size')); + }); + this._cleanup.connect(settings, 'changed::tray-icons-attention-timeout', () => { + this._container?.setAttentionTimeout(settings.get_int('tray-icons-attention-timeout')); + }); + this._cleanup.connect(settings, 'changed::tray-icons-dedup-bg-apps', () => { + this._dedupBgApps = settings.get_boolean('tray-icons-dedup-bg-apps'); + if (this._dedupBgApps) { + for (const [appId, entry] of [...this._bgItemAppIds]) { + this._sniCoversApp(appId, entry.app) + .then((covered) => { + if (covered) { + this._bgItemAppIds.delete(appId); + this._container?.removeItem(entry.itemId); + } + }) + .catch(() => {}); } - }), - settings.connect('changed::tray-icons-recolor-symbolic-pixmaps', () => { - logger.debug( - `Recolor symbolic SNI pixmaps=${settings.get_boolean('tray-icons-recolor-symbolic-pixmaps')}; refreshing SNI icons`, - { prefix: LOG_PREFIX }, - ); - this._sniHost?.refreshIcons('recolor-setting'); - }), - ); + } + }); + this._cleanup.connect(settings, 'changed::tray-icons-hide-bg-quick-settings', () => { + if (settings.get_boolean('tray-icons-hide-bg-quick-settings')) { + this._hideBgAppsQuickSettings(); + } else { + this._restoreBgAppsQuickSettings(); + } + }); + this._cleanup.connect(settings, 'changed::tray-icons-recolor-symbolic-pixmaps', () => { + logger.debug( + `Recolor symbolic SNI pixmaps=${settings.get_boolean('tray-icons-recolor-symbolic-pixmaps')}; refreshing SNI icons`, + { prefix: LOG_PREFIX }, + ); + this._sniHost?.refreshIcons('recolor-setting'); + }); } private _onSniItemAdded(item: TrayItem): void { @@ -468,15 +466,8 @@ export class TrayIcons extends Module { } override disable(): void { - const settings = this.context.settings.getRawSettings(); - for (const id of this._settingsChangedIds) { - settings.disconnect(id); - } - this._settingsChangedIds = []; - if (this._desktopSettings && this._desktopSettingsChangedId > 0) { - this._desktopSettings.disconnect(this._desktopSettingsChangedId); - this._desktopSettingsChangedId = 0; - } + this._cleanup?.dispose(); + this._cleanup = null; this._desktopSettings = null; this._restoreBgAppsQuickSettings(); @@ -496,57 +487,3 @@ export class TrayIcons extends Module { this._container = null; } } - -export const definition: ModuleDefinition = { - key: 'tray-icons', - settingsKey: 'module-tray-icons', - section: 'dock-panel', - title: _('Tray Icons'), - subtitle: _('System tray with SNI and background app icons'), - runtime: { targets: ['desktop'] }, - options: [ - { - key: 'tray-icons-limit', - title: _('Visible Icon Limit'), - subtitle: _('Maximum number of icons shown before the expand button appears'), - type: 'spin', - min: 1, - max: 20, - }, - { - key: 'tray-icons-icon-size', - title: _('Icon Size'), - subtitle: _('Tray icon size in pixels (14–24)'), - type: 'spin', - min: 14, - max: 24, - }, - { - key: 'tray-icons-attention-timeout', - title: _('Attention Auto-Collapse (seconds)'), - subtitle: _('Seconds before the tray collapses after a notification icon appears'), - type: 'spin', - min: 1, - max: 30, - }, - { - key: 'tray-icons-dedup-bg-apps', - title: _('Hide Background App When Tray Icon Present'), - subtitle: _('Remove the background app icon when the same app has an SNI tray icon'), - type: 'switch', - }, - { - key: 'tray-icons-hide-bg-quick-settings', - title: _('Hide Background Apps from Quick Settings'), - subtitle: _('Hide the Background Apps section from the Quick Settings dropdown'), - type: 'switch', - }, - { - key: 'tray-icons-recolor-symbolic-pixmaps', - title: _('Recolor Symbolic Tray Icons'), - subtitle: _('Automatically recolor monochrome SNI icons to match the panel theme'), - type: 'switch', - }, - ], - factory: (ctx) => new TrayIcons(ctx), -}; diff --git a/src/device/device.ts b/src/device/device.ts index 3e0ee21..7071ac5 100644 --- a/src/device/device.ts +++ b/src/device/device.ts @@ -1,13 +1,16 @@ import Clutter from '@girs/clutter-18'; import Gio from '@girs/gio-2.0'; import GLib from '@girs/glib-2.0'; +import * as Main from '@girs/gnome-shell/ui/main'; -import type { RuntimeCapability, RuntimeTarget } from '~/module.ts'; - -export type DeviceSnapshot = { - readonly target: RuntimeTarget; - readonly capabilities: ReadonlySet; -}; +import type { RuntimeCapability } from '~/module.ts'; +import { + createDeviceSnapshot, + sameDeviceSnapshot, + type DeviceSnapshot, + type InputPresence, + type MonitorInput, +} from '~/device/runtime.ts'; export type DeviceChangeListener = (snapshot: DeviceSnapshot) => void; @@ -16,21 +19,40 @@ export interface DeviceService { hasCapability(capability: RuntimeCapability): boolean; refresh(): DeviceSnapshot; subscribeChanged(listener: DeviceChangeListener): () => void; + destroy(): void; } const SENSOR_DBUS_NAME = 'net.hadess.SensorProxy'; const SENSOR_PATH = '/net/hadess/SensorProxy'; const SENSOR_IFACE = 'net.hadess.SensorProxy'; const MODEM_MANAGER_NAME = 'org.freedesktop.ModemManager1'; +const DISPLAY_CONFIG_NAME = 'org.gnome.Mutter.DisplayConfig'; +const DISPLAY_CONFIG_PATH = '/org/gnome/Mutter/DisplayConfig'; export class DefaultDeviceService implements DeviceService { - private readonly _target: RuntimeTarget; private readonly _listeners = new Set(); + private readonly _seat = Clutter.get_default_backend().get_default_seat(); + private readonly _monitorChangedId: number; + private readonly _deviceAddedId: number; + private readonly _deviceRemovedId: number; + private readonly _nameWatchIds: number[]; private _snapshot: DeviceSnapshot; + private _destroyed = false; - constructor(target: RuntimeTarget = 'desktop') { - this._target = target; + constructor() { this._snapshot = this._detect(); + this._monitorChangedId = Main.layoutManager.connect('monitors-changed', () => this.refresh()); + this._deviceAddedId = this._seat.connect('device-added', () => this.refresh()); + this._deviceRemovedId = this._seat.connect('device-removed', () => this.refresh()); + this._nameWatchIds = [SENSOR_DBUS_NAME, MODEM_MANAGER_NAME].map((name) => + Gio.bus_watch_name( + Gio.BusType.SYSTEM, + name, + Gio.BusNameWatcherFlags.NONE, + () => this.refresh(), + () => this.refresh(), + ), + ); } get current(): DeviceSnapshot { @@ -42,8 +64,9 @@ export class DefaultDeviceService implements DeviceService { } refresh(): DeviceSnapshot { + if (this._destroyed) return this._snapshot; const next = this._detect(); - if (!sameSnapshot(this._snapshot, next)) { + if (!sameDeviceSnapshot(this._snapshot, next)) { this._snapshot = next; for (const listener of this._listeners) listener(next); } @@ -55,20 +78,54 @@ export class DefaultDeviceService implements DeviceService { return () => this._listeners.delete(listener); } + destroy(): void { + if (this._destroyed) return; + this._destroyed = true; + Main.layoutManager.disconnect(this._monitorChangedId); + this._seat.disconnect(this._deviceAddedId); + this._seat.disconnect(this._deviceRemovedId); + for (const id of this._nameWatchIds) Gio.bus_unwatch_name(id); + this._listeners.clear(); + } + private _detect(): DeviceSnapshot { - return { - target: this._target, - capabilities: detectCapabilities(), - }; + const input = detectInputPresence(this._seat.list_devices()); + const capabilities = detectCapabilities(input.touch); + return createDeviceSnapshot(detectMonitors(), input, capabilities); } } -function detectCapabilities(): ReadonlySet { - const capabilities = new Set(); +function detectMonitors(): MonitorInput[] { + const builtinMonitorIndices = detectBuiltinMonitorIndices(); + return (Main.layoutManager.monitors ?? []).map((monitor) => ({ + index: monitor.index, + x: monitor.x, + y: monitor.y, + width: monitor.width, + height: monitor.height, + scale: monitor.geometryScale, + isBuiltin: builtinMonitorIndices.has(monitor.index), + })); +} + +function detectInputPresence(devices: readonly Clutter.InputDevice[]): InputPresence { + return { + touch: devices.some( + (device) => device.get_device_type() === Clutter.InputDeviceType.TOUCHSCREEN_DEVICE, + ), + pointer: devices.some( + (device) => device.get_device_type() === Clutter.InputDeviceType.POINTER_DEVICE, + ), + keyboard: devices.some( + (device) => device.get_device_type() === Clutter.InputDeviceType.KEYBOARD_DEVICE, + ), + }; +} - if (hasTouchInput()) capabilities.add('touch'); +function detectCapabilities(hasTouch: boolean): ReadonlySet { + const capabilities = new Set(); + if (hasTouch) capabilities.add('touch'); if (hasBacklight()) capabilities.add('backlight'); - if (hasAlertSlider()) capabilities.add('hardware-alert-slider'); if (hasDBusNameOwner(MODEM_MANAGER_NAME)) capabilities.add('cellular'); const sensorProxy = getSensorProxy(); @@ -77,25 +134,54 @@ function detectCapabilities(): ReadonlySet { if (getBooleanProperty(sensorProxy, 'HasAmbientLight')) capabilities.add('light-sensor'); if (getBooleanProperty(sensorProxy, 'HasProximity')) capabilities.add('proximity-sensor'); } - return capabilities; } -function hasTouchInput(): boolean { +function detectBuiltinMonitorIndices(): ReadonlySet { try { - const devices = Clutter.get_default_backend().get_default_seat().list_devices(); - return devices.some( - (device) => device.get_device_type() === Clutter.InputDeviceType.TOUCHSCREEN_DEVICE, + const result = Gio.DBus.session.call_sync( + DISPLAY_CONFIG_NAME, + DISPLAY_CONFIG_PATH, + DISPLAY_CONFIG_NAME, + 'GetCurrentState', + null, + null, + Gio.DBusCallFlags.NONE, + 200, + null, ); + return parseBuiltinMonitorIndices(result?.recursiveUnpack()); } catch { - return false; + return new Set(); + } +} + +function parseBuiltinMonitorIndices(state: unknown): ReadonlySet { + if (!Array.isArray(state) || !Array.isArray(state[1]) || !Array.isArray(state[2])) + return new Set(); + + const builtinConnectors = new Set(); + for (const physical of state[1]) { + if (!Array.isArray(physical) || !Array.isArray(physical[0])) continue; + const connector = physical[0][0]; + const properties = physical[2]; + if (typeof connector === 'string' && isRecord(properties) && properties['is-builtin'] === true) + builtinConnectors.add(connector); } + + const indices = new Set(); + for (const [index, logical] of state[2].entries()) { + if (!Array.isArray(logical) || !Array.isArray(logical[5])) continue; + const containsBuiltin = logical[5].some( + (spec) => Array.isArray(spec) && builtinConnectors.has(spec[0]), + ); + if (containsBuiltin) indices.add(index); + } + return indices; } -function hasAlertSlider(): boolean { - // This needs a device-specific input backend. Do not scan /proc synchronously - // during Shell startup just to discover an optional hardware switch. - return false; +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); } function hasBacklight(): boolean { @@ -114,7 +200,6 @@ function hasBacklight(): boolean { function getSensorProxy(): Gio.DBusProxy | null { if (!hasDBusNameOwner(SENSOR_DBUS_NAME)) return null; - try { return Gio.DBusProxy.new_for_bus_sync( Gio.BusType.SYSTEM, @@ -156,12 +241,3 @@ function hasDBusNameOwner(name: string): boolean { return false; } } - -function sameSnapshot(a: DeviceSnapshot, b: DeviceSnapshot): boolean { - if (a.target !== b.target) return false; - if (a.capabilities.size !== b.capabilities.size) return false; - for (const capability of a.capabilities) { - if (!b.capabilities.has(capability)) return false; - } - return true; -} diff --git a/src/device/runtime.ts b/src/device/runtime.ts new file mode 100644 index 0000000..cfbecaf --- /dev/null +++ b/src/device/runtime.ts @@ -0,0 +1,125 @@ +import type { DisplayRole, RuntimeCapability } from '~/module.ts'; + +export type DeviceClass = 'phone' | 'tablet' | 'laptop' | 'desktop' | 'unknown'; +export type InputMode = 'touch' | 'pointer' | 'keyboard' | 'mixed' | 'unknown'; +export type DisplayOrientation = 'portrait' | 'landscape' | 'square' | 'unknown'; + +export type MonitorSnapshot = { + readonly index: number; + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; + readonly scale: number; + readonly orientation: DisplayOrientation; + readonly isBuiltin: boolean; + readonly role: DisplayRole; +}; + +export type MonitorInput = Omit; + +export type InputPresence = { + readonly touch: boolean; + readonly pointer: boolean; + readonly keyboard: boolean; +}; + +export type DeviceSnapshot = { + readonly deviceClass: DeviceClass; + readonly inputMode: InputMode; + readonly monitors: readonly MonitorSnapshot[]; + readonly capabilities: ReadonlySet; +}; + +export function classifyOrientation(width: number, height: number): DisplayOrientation { + if (width <= 0 || height <= 0) return 'unknown'; + if (width === height) return 'square'; + return width < height ? 'portrait' : 'landscape'; +} + +export function classifyInputMode(input: InputPresence): InputMode { + const present = [input.touch, input.pointer, input.keyboard].filter(Boolean).length; + if (present === 0) return 'unknown'; + if (present > 1) return 'mixed'; + if (input.touch) return 'touch'; + if (input.pointer) return 'pointer'; + return 'keyboard'; +} + +export function classifyDevice( + monitors: readonly MonitorInput[], + input: InputPresence, +): DeviceClass { + const builtin = monitors.find((monitor) => monitor.isBuiltin); + if (!builtin) return monitors.length > 0 ? 'desktop' : 'unknown'; + if (!input.touch) return 'laptop'; + + const longestEdge = Math.max(builtin.width, builtin.height); + if (longestEdge <= 900) return 'phone'; + if (longestEdge <= 1400) return 'tablet'; + return 'laptop'; +} + +export function classifyMonitorRole(monitor: MonitorInput, deviceClass: DeviceClass): DisplayRole { + if (!monitor.isBuiltin) return 'desktop'; + if (deviceClass === 'phone' || deviceClass === 'tablet') return 'mobile'; + if (deviceClass === 'laptop' || deviceClass === 'desktop') return 'desktop'; + return 'unknown'; +} + +export function createDeviceSnapshot( + monitors: readonly MonitorInput[], + input: InputPresence, + capabilities: ReadonlySet, +): DeviceSnapshot { + const deviceClass = classifyDevice(monitors, input); + return { + deviceClass, + inputMode: classifyInputMode(input), + monitors: monitors.map((monitor) => ({ + ...monitor, + orientation: classifyOrientation(monitor.width, monitor.height), + role: classifyMonitorRole(monitor, deviceClass), + })), + capabilities: new Set(capabilities), + }; +} + +export function activeDisplayRoles( + snapshot: DeviceSnapshot, + desktopFallback = true, +): ReadonlySet { + const roles = new Set(snapshot.monitors.map((monitor) => monitor.role)); + roles.delete('unknown'); + if (roles.size === 0) roles.add('unknown'); + if (desktopFallback && roles.has('mobile') && !roles.has('desktop')) roles.add('desktop'); + return roles; +} + +export function sameDeviceSnapshot(a: DeviceSnapshot, b: DeviceSnapshot): boolean { + if (a.deviceClass !== b.deviceClass || a.inputMode !== b.inputMode) return false; + if (!sameSet(a.capabilities, b.capabilities) || a.monitors.length !== b.monitors.length) + return false; + + return a.monitors.every((monitor, index) => { + const other = b.monitors[index]; + return ( + other !== undefined && + monitor.index === other.index && + monitor.x === other.x && + monitor.y === other.y && + monitor.width === other.width && + monitor.height === other.height && + monitor.scale === other.scale && + monitor.orientation === other.orientation && + monitor.isBuiltin === other.isBuiltin && + monitor.role === other.role + ); + }); +} + +function sameSet(a: ReadonlySet, b: ReadonlySet): boolean { + if (a.size !== b.size) return false; + for (const value of a) if (!b.has(value)) return false; + return true; +} diff --git a/src/dock/dock.manifest.ts b/src/dock/dock.manifest.ts new file mode 100644 index 0000000..c2faf6f --- /dev/null +++ b/src/dock/dock.manifest.ts @@ -0,0 +1,30 @@ +import { gettext as _ } from 'gettext'; +import type { ModuleManifest } from '~/module.ts'; + +export const manifest: ModuleManifest = { + key: 'dock', + settingsKey: 'module-dock', + section: 'dock-panel', + title: _('Dock'), + subtitle: _('Custom dock with auto-hide and intellihide features'), + options: [ + { + key: 'dock-always-show', + title: _('Always Show Dock'), + subtitle: _('Keep dock permanently visible and shrink windows so they never overlap it'), + type: 'switch', + }, + { + key: 'dock-show-trash', + title: _('Show Trash Icon'), + subtitle: _('Show a trash can in the dock; click to open it, right-click to empty it'), + type: 'switch', + }, + { + key: 'dock-show-external-storage', + title: _('Show External Storage'), + subtitle: _('Show removable drives in the dock when they are connected'), + type: 'switch', + }, + ], +}; diff --git a/src/dock/dock.ts b/src/dock/dock.ts index 31551e1..8020d5a 100644 --- a/src/dock/dock.ts +++ b/src/dock/dock.ts @@ -7,9 +7,9 @@ import GLib from '@girs/glib-2.0'; import * as Main from '@girs/gnome-shell/ui/main'; import type { ExtensionContext } from '~/core/context.ts'; +import type { CleanupBag } from '~/core/cleanupBag.ts'; import { logger } from '~/core/logger.ts'; import { Module } from '~/module.ts'; -import type { ModuleDefinition } from '~/module.ts'; import { AuroraDash, type DashBounds } from '~/shared/ui/dash.ts'; import { DockHotArea } from '~/dock/hotArea.ts'; import { DockIntellihide, OverlapStatus } from '~/dock/intellihide.ts'; @@ -33,6 +33,7 @@ export type ManagedDockBinding = { export class Dock extends Module { private _bindings = new Map(); + private _cleanup: CleanupBag | null = null; private _pendingRebuild = false; private _dockSettings: any = null; private _alwaysShow = false; @@ -44,6 +45,7 @@ export class Dock extends Module { } override enable(): void { + this._cleanup = this.context.createCleanupBag(); this._dockSettings = this.context.settings.getRawSettings(); this._alwaysShow = this._dockSettings?.get_boolean('dock-always-show') ?? false; this._showTrash = this._dockSettings?.get_boolean('dock-show-trash') ?? true; @@ -57,22 +59,32 @@ export class Dock extends Module { Main.overview.dash.hide(); this._rebuildBindings(); - Main.layoutManager.connectObject( - 'monitors-changed', - () => this._rebuildBindings(), - 'hot-corners-changed', - () => this._rebuildBindings(), - this, + this._cleanup.connectObject(Main.layoutManager, this, () => + Main.layoutManager.connectObject( + 'monitors-changed', + () => this._rebuildBindings(), + 'hot-corners-changed', + () => this._rebuildBindings(), + 'startup-complete', + () => this._rebuildBindings(), + this, + ), ); - global.display.connectObject('workareas-changed', () => this._refreshWorkAreas(), this); - Main.sessionMode.connectObject('updated', () => this._refreshBindingsLayout(), this); - - Main.overview.connectObject( - 'showing', - () => this._setOverviewVisible(true), - 'hidden', - () => this._setOverviewVisible(false), - this, + this._cleanup.connectObject(global.display, this, () => + global.display.connectObject('workareas-changed', () => this._refreshWorkAreas(), this), + ); + this._cleanup.connectObject(Main.sessionMode, this, () => + Main.sessionMode.connectObject('updated', () => this._refreshBindingsLayout(), this), + ); + + this._cleanup.connectObject(Main.overview, this, () => + Main.overview.connectObject( + 'showing', + () => this._setOverviewVisible(true), + 'hidden', + () => this._setOverviewVisible(false), + this, + ), ); this._dockSettings?.connectObject?.( @@ -95,18 +107,17 @@ export class Dock extends Module { this, ); - this.context.signals.connectObject('icons-woven', () => this._refreshBindingsLayout(), this); + this._cleanup.connectObject(this.context.signals, this, () => + this.context.signals.connectObject('icons-woven', () => this._refreshBindingsLayout(), this), + ); + this._cleanup.add(() => this._dockSettings?.disconnectObject?.(this)); } override disable(): void { Main.overview.dash.show(); - this._dockSettings?.disconnectObject?.(this); + this._cleanup?.dispose(); + this._cleanup = null; this._dockSettings = null; - this.context.signals.disconnectObject(this); - Main.layoutManager.disconnectObject(this); - global.display.disconnectObject(this); - Main.sessionMode.disconnectObject(this); - Main.overview.disconnectObject(this); this._pendingRebuild = false; this._clearBindings(); } @@ -591,32 +602,3 @@ export class Dock extends Module { }); } } - -export const definition: ModuleDefinition = { - key: 'dock', - settingsKey: 'module-dock', - section: 'dock-panel', - title: _('Dock'), - subtitle: _('Custom dock with auto-hide and intellihide features'), - options: [ - { - key: 'dock-always-show', - title: _('Always Show Dock'), - subtitle: _('Keep dock permanently visible and shrink windows so they never overlap it'), - type: 'switch', - }, - { - key: 'dock-show-trash', - title: _('Show Trash Icon'), - subtitle: _('Show a trash can in the dock; click to open it, right-click to empty it'), - type: 'switch', - }, - { - key: 'dock-show-external-storage', - title: _('Show External Storage'), - subtitle: _('Show removable drives in the dock when they are connected'), - type: 'switch', - }, - ], - factory: (ctx) => new Dock(ctx), -}; diff --git a/src/extension.ts b/src/extension.ts index e5f9ba5..402497c 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,167 +1,84 @@ import '@girs/gjs'; -import type Gio from '@girs/gio-2.0'; import GLib from '@girs/glib-2.0'; import { Extension } from '@girs/gnome-shell/extensions/extension'; -import type { Module } from './module.ts'; -import { moduleSupportsRuntime } from './module.ts'; -import { getModuleRegistry, type ModuleDefinition } from './registry.ts'; import type { ExtensionContext } from '~/core/context.ts'; -import { initIcons, cleanupIcons } from '~/shared/icons.ts'; import { DefaultExtensionContext } from '~/core/context.ts'; -import { ConsoleLogger, setGlobalLogger, logger } from '~/core/logger.ts'; +import { ConsoleLogger, logger, setGlobalLogger } from '~/core/logger.ts'; import { GSettingsManager } from '~/core/settings.ts'; import { DefaultDeviceService } from '~/device/device.ts'; import { DevTool } from '~/dev/devTool.ts'; +import { ModuleManager } from '~/moduleManager.ts'; +import type { Module } from '~/module.ts'; +import { getModuleRegistry } from '~/registry.ts'; +import { cleanupIcons, initIcons } from '~/shared/icons.ts'; const LOG_PREFIX = 'AuroraShell'; -/** - * Aurora Shell Extension - * - * Main extension that orchestrates all modules. - * Each module is independent and can be enabled/disabled separately. - */ export default class AuroraShellExtension extends Extension { - private _modules: Map = new Map(); + private _manager: ModuleManager | null = null; private _devTool: DevTool | null = null; - private _settings: Gio.Settings | null = null; private _context: ExtensionContext | null = null; + get _modules(): ReadonlyMap { + return this._manager?.modules ?? new Map(); + } + override enable(): void { const consoleLogger = new ConsoleLogger('Aurora Shell', this.uuid); setGlobalLogger(consoleLogger); consoleLogger.debug('Enabling extension', { prefix: LOG_PREFIX }); - this._settings = this.getSettings(); + const device = new DefaultDeviceService(); this._context = new DefaultExtensionContext( this.uuid, this.path, - new GSettingsManager(this._settings), - new DefaultDeviceService(), + new GSettingsManager(this.getSettings()), + device, ); initIcons(this.path); - this._initializeModules(); - this._enableAllModules(); + this._manager = new ModuleManager(getModuleRegistry(), this._context, { + debug: (message) => logger.debug(message, { prefix: LOG_PREFIX }), + error: (message) => logger.error(message, { prefix: LOG_PREFIX }), + }); + this._manager.start(); this._enableDevTool(); - this._connectSettings(); - } - - private _initializeModules(): void { - for (const def of getModuleRegistry()) { - if (this._settings?.get_boolean(def.settingsKey) && this._supportsRuntime(def)) { - this._modules.set(def.key, def.factory(this._context!)); - } - } - } - - private _enableAllModules(): void { - for (const [name, module] of this._modules) { - try { - module.enable(); - } catch (e) { - logger.error(`Failed to enable module ${name}: ${e}`, { prefix: LOG_PREFIX }); - } - } } private _enableDevTool(): void { - if (GLib.getenv('AURORA_DEVTOOLS') !== '1' || !this._context) return; + if (GLib.getenv('AURORA_DEVTOOLS') !== '1' || !this._context || !this._manager) return; try { this._devTool = new DevTool(this._context, { - getModule: (key) => this._modules.get(key) ?? null, + getModule: (key) => this._manager?.getModule(key) ?? null, openPreferences: () => this.openPreferences(), }); this._devTool.enable(); - } catch (e) { - logger.error(`Failed to enable DevTool: ${e}`, { prefix: LOG_PREFIX }); + } catch (error) { + logger.error(`Failed to enable DevTool: ${String(error)}`, { prefix: LOG_PREFIX }); this._devTool = null; } } private _disableDevTool(): void { if (!this._devTool) return; - try { this._devTool.disable(); - } catch (e) { - logger.error(`Failed to disable DevTool: ${e}`, { prefix: LOG_PREFIX }); + } catch (error) { + logger.error(`Failed to disable DevTool: ${String(error)}`, { prefix: LOG_PREFIX }); } finally { this._devTool = null; } } - private _connectSettings(): void { - if (!this._settings) return; - - const args: unknown[] = []; - for (const def of getModuleRegistry()) { - args.push(`changed::${def.settingsKey}`, () => { - this._toggleModule(def); - }); - } - args.push(this); - - this._settings.connectObject(...args); - } - - private _toggleModule(def: ModuleDefinition): void { - const enabled = this._settings!.get_boolean(def.settingsKey); - const existing = this._modules.get(def.key); - - if (enabled && !existing) { - if (!this._supportsRuntime(def)) { - logger.debug(`Skipping module ${def.key}: unsupported runtime`, { prefix: LOG_PREFIX }); - return; - } - - logger.debug(`Enabling module ${def.key}`, { prefix: LOG_PREFIX }); - try { - const module = def.factory(this._context!); - module.enable(); - this._modules.set(def.key, module); - } catch (e) { - logger.error(`Failed to enable module ${def.key}: ${e}`, { prefix: LOG_PREFIX }); - } - } else if (!enabled && existing) { - logger.debug(`Disabling module ${def.key}`, { prefix: LOG_PREFIX }); - try { - existing.disable(); - this._modules.delete(def.key); - } catch (e) { - logger.error(`Failed to disable module ${def.key}: ${e}`, { prefix: LOG_PREFIX }); - } - } - } - - private _supportsRuntime(def: ModuleDefinition): boolean { - if (!this._context) return false; - const { target, capabilities } = this._context.device.current; - return moduleSupportsRuntime(def, target, capabilities); - } - - private _disableAllModules(): void { - for (const [name, module] of this._modules) { - try { - module.disable(); - } catch (e) { - logger.error(`Failed to disable module ${name}: ${e}`, { prefix: LOG_PREFIX }); - } - } - - this._modules.clear(); - } - override disable(): void { logger.debug('Disabling extension', { prefix: LOG_PREFIX }); - - if (this._settings) this._settings.disconnectObject(this); this._disableDevTool(); - this._disableAllModules(); - this._settings = null; + this._manager?.stop(); + this._manager = null; + this._context?.device.destroy(); this._context = null; cleanupIcons(); } diff --git a/src/module.ts b/src/module.ts index 136a092..687eb67 100644 --- a/src/module.ts +++ b/src/module.ts @@ -1,6 +1,6 @@ import type { ExtensionContext } from './core/context.ts'; -export type RuntimeTarget = 'desktop' | 'mobile' | 'shared'; +export type DisplayRole = 'desktop' | 'mobile' | 'unknown'; export type RuntimeCapability = | 'touch' @@ -8,12 +8,12 @@ export type RuntimeCapability = | 'light-sensor' | 'proximity-sensor' | 'cellular' - | 'backlight' - | 'hardware-alert-slider'; + | 'backlight'; export type ModuleRuntimePolicy = { - targets?: RuntimeTarget[]; + roles?: DisplayRole[]; requires?: RuntimeCapability[]; + scope?: 'session' | 'monitor'; }; export type ModuleOption = { @@ -34,29 +34,31 @@ export type ModuleOptionChoice = { iconName?: string; }; -export type ModuleMetadata = { +export type ModuleManifest = { key: string; settingsKey: string; section: string; title: string; subtitle: string; options?: ModuleOption[]; + internalSettings?: string[]; runtime?: ModuleRuntimePolicy; }; -export type ModuleDefinition = ModuleMetadata & { +export type ModuleDefinition = { + manifest: ModuleManifest; factory: (context: ExtensionContext) => Module; }; export function moduleSupportsRuntime( - definition: ModuleDefinition, - target: RuntimeTarget, + manifest: ModuleManifest, + roles: ReadonlySet, capabilities: ReadonlySet, ): boolean { - const targets = definition.runtime?.targets ?? ['desktop']; - if (!targets.includes('shared') && !targets.includes(target)) return false; + const supportedRoles = manifest.runtime?.roles ?? ['desktop']; + if (!supportedRoles.some((role) => roles.has(role))) return false; - for (const capability of definition.runtime?.requires ?? []) { + for (const capability of manifest.runtime?.requires ?? []) { if (!capabilities.has(capability)) return false; } diff --git a/src/moduleCatalog.ts b/src/moduleCatalog.ts new file mode 100644 index 0000000..b1bf96b --- /dev/null +++ b/src/moduleCatalog.ts @@ -0,0 +1,64 @@ +import { gettext as _ } from 'gettext'; + +import { manifest as noOverview } from '~/patches/noOverview.manifest.ts'; +import { manifest as pipOnTop } from '~/patches/pipOnTop.manifest.ts'; +import { manifest as focusLaunchedWindows } from '~/patches/focusLaunchedWindows.manifest.ts'; +import { manifest as themeChanger } from '~/theme/themeChanger.manifest.ts'; +import { manifest as dock } from '~/dock/dock.manifest.ts'; +import { manifest as auroraMenu } from '~/panel/auroraMenu.manifest.ts'; +import { manifest as volumeMixer } from '~/panel/volumeMixer/volumeMixer.manifest.ts'; +import { manifest as lowBatteryPercentage } from '~/panel/lowBatteryPercentage.manifest.ts'; +import { manifest as lockKeyIndicators } from '~/panel/lockKeyIndicators.manifest.ts'; +import { manifest as xwaylandIndicator } from '~/patches/xwaylandIndicator.manifest.ts'; +import { manifest as privacy } from '~/privacy/privacy.manifest.ts'; +import { manifest as iconWeave } from '~/patches/iconWeave.manifest.ts'; +import { manifest as appSearchTooltip } from '~/patches/appSearchTooltip.manifest.ts'; +import { manifest as velaVpnQuickSettings } from '~/patches/velaVpnQuickSettings.manifest.ts'; +import { manifest as autoThemeSwitcher } from '~/theme/autoThemeSwitcher.manifest.ts'; +import { manifest as bluetoothMenu } from '~/panel/bluetoothMenu/bluetoothMenu.manifest.ts'; +import { manifest as weatherClock } from '~/panel/clock/weatherClock/weatherClock.manifest.ts'; +import { manifest as meetingClock } from '~/panel/clock/meetingClock/meetingClock.manifest.ts'; +import { manifest as trayIcons } from '~/desktop/trayIcons/trayIcons.manifest.ts'; +import { manifest as clipboardHistory } from '~/clipboard/clipboardHistory.manifest.ts'; + +import type { ModuleManifest } from '~/module.ts'; + +export type ModuleSection = { id: string; title: string }; + +export function getSections(): ModuleSection[] { + return [ + { id: 'dock-panel', title: _('Dock & Panel') }, + { id: 'appearance', title: _('Appearance') }, + { id: 'behavior', title: _('Behavior') }, + { id: 'privacy-clipboard', title: _('Privacy & Clipboard') }, + ]; +} + +const MODULE_CATALOG: readonly ModuleManifest[] = [ + noOverview, + pipOnTop, + focusLaunchedWindows, + themeChanger, + dock, + auroraMenu, + volumeMixer, + lowBatteryPercentage, + lockKeyIndicators, + xwaylandIndicator, + privacy, + iconWeave, + appSearchTooltip, + velaVpnQuickSettings, + autoThemeSwitcher, + bluetoothMenu, + weatherClock, + meetingClock, + trayIcons, + clipboardHistory, +]; + +export function getModuleCatalog(): readonly ModuleManifest[] { + return MODULE_CATALOG; +} + +export type { ModuleManifest, ModuleOption, ModuleOptionChoice } from '~/module.ts'; diff --git a/src/moduleManager.ts b/src/moduleManager.ts new file mode 100644 index 0000000..19df710 --- /dev/null +++ b/src/moduleManager.ts @@ -0,0 +1,101 @@ +import type { ExtensionContext } from '~/core/context.ts'; +import { activeDisplayRoles } from '~/device/runtime.ts'; +import type { Module, ModuleDefinition } from '~/module.ts'; +import { moduleSupportsRuntime } from '~/module.ts'; + +export type ModuleManagerLogger = { + debug(message: string): void; + error(message: string): void; +}; + +export class ModuleManager { + private readonly _modules = new Map(); + private readonly _settingSignalIds: number[] = []; + private _unsubscribeDevice: (() => void) | null = null; + private _started = false; + + constructor( + private readonly _definitions: readonly ModuleDefinition[], + private readonly _context: ExtensionContext, + private readonly _logger: ModuleManagerLogger, + ) {} + + getModule(key: string): Module | null { + return this._modules.get(key) ?? null; + } + + get modules(): ReadonlyMap { + return this._modules; + } + + start(): void { + if (this._started) return; + this._started = true; + for (const definition of this._definitions) { + const id = this._context.settings.connect(`changed::${definition.manifest.settingsKey}`, () => + this.reconcile(), + ); + this._settingSignalIds.push(id); + } + this._unsubscribeDevice = this._context.device.subscribeChanged(() => this.reconcile()); + this.reconcile(); + } + + reconcile(): void { + if (!this._started) return; + const snapshot = this._context.device.current; + const roles = activeDisplayRoles(snapshot); + + for (const definition of this._definitions) { + const { manifest } = definition; + const shouldRun = + this._context.settings.getBoolean(manifest.settingsKey) && + moduleSupportsRuntime(manifest, roles, snapshot.capabilities); + const existing = this._modules.get(manifest.key); + + if (shouldRun && !existing) this._enable(definition); + else if (!shouldRun && existing) this._disable(manifest.key, existing); + } + } + + stop(): void { + if (!this._started) return; + this._started = false; + this._unsubscribeDevice?.(); + this._unsubscribeDevice = null; + for (const id of this._settingSignalIds) this._context.settings.disconnect(id); + this._settingSignalIds.length = 0; + + for (const [key, module] of [...this._modules].reverse()) this._disable(key, module); + } + + private _enable(definition: ModuleDefinition): void { + const { manifest } = definition; + this._logger.debug(`Enabling module ${manifest.key}`); + let module: Module | null = null; + try { + module = definition.factory(this._context); + module.enable(); + this._modules.set(manifest.key, module); + } catch (error) { + if (module) { + try { + module.disable(); + } catch { + // The original enable failure is the useful error to report. + } + } + this._logger.error(`Failed to enable module ${manifest.key}: ${String(error)}`); + } + } + + private _disable(key: string, module: Module): void { + this._logger.debug(`Disabling module ${key}`); + this._modules.delete(key); + try { + module.disable(); + } catch (error) { + this._logger.error(`Failed to disable module ${key}: ${String(error)}`); + } + } +} diff --git a/src/panel/auroraMenu.manifest.ts b/src/panel/auroraMenu.manifest.ts new file mode 100644 index 0000000..4582690 --- /dev/null +++ b/src/panel/auroraMenu.manifest.ts @@ -0,0 +1,88 @@ +import { gettext as _ } from 'gettext'; +import type { ModuleManifest } from '~/module.ts'; + +export const manifest: ModuleManifest = { + key: 'aurora-menu', + settingsKey: 'module-aurora-menu', + section: 'dock-panel', + title: _('Aurora Menu'), + subtitle: _('Aurora panel menu with recent items and useful shortcuts'), + internalSettings: [ + 'aurora-menu-custom-item-enabled', + 'aurora-menu-custom-item-label', + 'aurora-menu-custom-item-command', + ], + options: [ + { + key: 'aurora-menu-icon', + title: _('Menu Icon'), + subtitle: _('Choose the icon shown in the top panel'), + type: 'icon-select', + choices: [ + { value: 'aurora', title: _('Aurora Shell'), iconName: 'aurora-shell-menu-symbolic' }, + { value: 'gnome', title: _('GNOME'), iconName: 'start-here-symbolic' }, + { value: 'luminus', title: _('Luminus OS'), iconName: 'luminus-os-symbolic' }, + ], + }, + { + key: 'aurora-menu-hide-activities', + title: _('Hide Activities Button'), + subtitle: _('Hide the Activities button while Aurora Menu is enabled'), + type: 'switch', + }, + { + key: 'aurora-menu-show-about', + title: _('Show About This PC'), + subtitle: _('Show the About This PC item in Aurora Menu'), + type: 'switch', + }, + { + key: 'aurora-menu-show-home', + title: _('Show Home Folder'), + subtitle: _('Show the Home Folder item in Aurora Menu'), + type: 'switch', + }, + { + key: 'aurora-menu-show-downloads', + title: _('Show Downloads'), + subtitle: _('Show the Downloads item in Aurora Menu'), + type: 'switch', + }, + { + key: 'aurora-menu-show-recent-items', + title: _('Show Recent Items'), + subtitle: _('Show the Recent Items submenu in Aurora Menu'), + type: 'switch', + }, + { + key: 'aurora-menu-show-settings', + title: _('Show System Settings'), + subtitle: _('Show the System Settings item in Aurora Menu'), + type: 'switch', + }, + { + key: 'aurora-menu-show-software', + title: _('Show Software'), + subtitle: _('Show the Software item in Aurora Menu'), + type: 'switch', + }, + { + key: 'aurora-menu-show-extensions', + title: _('Show Extensions'), + subtitle: _('Show the Extensions item in Aurora Menu'), + type: 'switch', + }, + { + key: 'aurora-menu-app-store-command', + title: _('Software Command'), + subtitle: _('Command used by the Software menu item'), + type: 'entry', + }, + { + key: 'aurora-menu-custom-items', + title: _('Custom Menu Commands'), + subtitle: _('One command per line, using “Label | command”'), + type: 'command-list', + }, + ], +}; diff --git a/src/panel/auroraMenu.ts b/src/panel/auroraMenu.ts index 79aa58c..d4ac4ee 100644 --- a/src/panel/auroraMenu.ts +++ b/src/panel/auroraMenu.ts @@ -13,8 +13,13 @@ import * as PopupMenu from '@girs/gnome-shell/ui/popupMenu'; import type { ExtensionContext } from '~/core/context.ts'; import { logger } from '~/core/logger.ts'; import { Module } from '~/module.ts'; -import type { ModuleDefinition } from '~/module.ts'; import { loadIcon } from '~/shared/icons.ts'; +import { + decodeXml, + parseCustomCommand, + truncateMiddle, + type CustomMenuCommand, +} from '~/panel/auroraMenuState.ts'; const LOG_PREFIX = 'AuroraMenu'; const STATUS_AREA_ID = 'aurora-menu'; @@ -59,11 +64,6 @@ type MenuCommand = { activate?: () => void; }; -type CustomMenuCommand = { - label: string; - command: string; -}; - type RecentItem = { title: string; uri: string; @@ -521,15 +521,6 @@ function parseIsoTime(value: string): number { return dateTime?.to_unix() ?? 0; } -function decodeXml(value: string): string { - return value - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/&/g, '&'); -} - function isGioError(error: unknown, code: number): boolean { return Boolean( (error as { matches?: (domain: unknown, code: unknown) => boolean })?.matches?.( @@ -573,118 +564,3 @@ function parseCommandLine(raw: string, key: string): string[] { return []; } - -function parseCustomCommand(raw: string): CustomMenuCommand | null { - const value = raw.trim(); - if (!value) return null; - - const separator = value.indexOf('|'); - if (separator <= 0) return null; - - const label = value.slice(0, separator).trim(); - const command = value.slice(separator + 1).trim(); - if (!label || !command) return null; - - return { label, command }; -} - -function truncateMiddle(value: string, limit: number): string { - if (value.length <= limit) return value; - - const edgeLength = Math.max(1, Math.floor((limit - 1) / 2)); - return `${value.slice(0, edgeLength)}…${value.slice(value.length - edgeLength)}`; -} - -export const definition: ModuleDefinition = { - key: 'aurora-menu', - settingsKey: 'module-aurora-menu', - section: 'dock-panel', - title: _('Aurora Menu'), - subtitle: _('Aurora panel menu with recent items and useful shortcuts'), - options: [ - { - key: MENU_ICON_KEY, - title: _('Menu Icon'), - subtitle: _('Choose the icon shown in the top panel'), - type: 'icon-select', - choices: [ - { - value: 'aurora', - title: _('Aurora Shell'), - iconName: 'aurora-shell-menu-symbolic', - }, - { - value: 'gnome', - title: _('GNOME'), - iconName: 'start-here-symbolic', - }, - { - value: 'luminus', - title: _('Luminus OS'), - iconName: 'luminus-os-symbolic', - }, - ], - }, - { - key: HIDE_ACTIVITIES_KEY, - title: _('Hide Activities Button'), - subtitle: _('Hide the Activities button while Aurora Menu is enabled'), - type: 'switch', - }, - { - key: SHOW_ABOUT_KEY, - title: _('Show About This PC'), - subtitle: _('Show the About This PC item in Aurora Menu'), - type: 'switch', - }, - { - key: SHOW_HOME_KEY, - title: _('Show Home Folder'), - subtitle: _('Show the Home Folder item in Aurora Menu'), - type: 'switch', - }, - { - key: SHOW_DOWNLOADS_KEY, - title: _('Show Downloads'), - subtitle: _('Show the Downloads item in Aurora Menu'), - type: 'switch', - }, - { - key: SHOW_RECENT_KEY, - title: _('Show Recent Items'), - subtitle: _('Show the Recent Items submenu in Aurora Menu'), - type: 'switch', - }, - { - key: SHOW_SETTINGS_KEY, - title: _('Show System Settings'), - subtitle: _('Show the System Settings item in Aurora Menu'), - type: 'switch', - }, - { - key: SHOW_SOFTWARE_KEY, - title: _('Show Software'), - subtitle: _('Show the Software item in Aurora Menu'), - type: 'switch', - }, - { - key: SHOW_EXTENSIONS_KEY, - title: _('Show Extensions'), - subtitle: _('Show the Extensions item in Aurora Menu'), - type: 'switch', - }, - { - key: APP_STORE_COMMAND_KEY, - title: _('Software Command'), - subtitle: _('Command used by the Software menu item'), - type: 'entry', - }, - { - key: CUSTOM_ITEMS_KEY, - title: _('Custom Menu Commands'), - subtitle: _('One command per line, using “Label | command”'), - type: 'command-list', - }, - ], - factory: (ctx) => new AuroraMenu(ctx), -}; diff --git a/src/panel/auroraMenuState.ts b/src/panel/auroraMenuState.ts new file mode 100644 index 0000000..8aac0fd --- /dev/null +++ b/src/panel/auroraMenuState.ts @@ -0,0 +1,34 @@ +export type CustomMenuCommand = { + label: string; + command: string; +}; + +export function decodeXml(value: string): string { + return value + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&'); +} + +export function parseCustomCommand(raw: string): CustomMenuCommand | null { + const value = raw.trim(); + if (!value) return null; + + const separator = value.indexOf('|'); + if (separator <= 0) return null; + + const label = value.slice(0, separator).trim(); + const command = value.slice(separator + 1).trim(); + if (!label || !command) return null; + + return { label, command }; +} + +export function truncateMiddle(value: string, limit: number): string { + if (value.length <= limit) return value; + + const edgeLength = Math.max(1, Math.floor((limit - 1) / 2)); + return `${value.slice(0, edgeLength)}…${value.slice(value.length - edgeLength)}`; +} diff --git a/src/panel/bluetoothMenu/bluetoothMenu.manifest.ts b/src/panel/bluetoothMenu/bluetoothMenu.manifest.ts new file mode 100644 index 0000000..d1d4ed7 --- /dev/null +++ b/src/panel/bluetoothMenu/bluetoothMenu.manifest.ts @@ -0,0 +1,10 @@ +import { gettext as _ } from 'gettext'; +import type { ModuleManifest } from '~/module.ts'; + +export const manifest: ModuleManifest = { + key: 'bluetooth-menu', + settingsKey: 'module-bluetooth-menu', + section: 'dock-panel', + title: _('Bluetooth Menu'), + subtitle: _('Shows battery level and animated icons in the Bluetooth Quick Settings panel'), +}; diff --git a/src/panel/bluetoothMenu/bluetoothMenu.ts b/src/panel/bluetoothMenu/bluetoothMenu.ts index e59e8b8..478a80a 100644 --- a/src/panel/bluetoothMenu/bluetoothMenu.ts +++ b/src/panel/bluetoothMenu/bluetoothMenu.ts @@ -6,7 +6,6 @@ import * as Main from '@girs/gnome-shell/ui/main'; import type { ExtensionContext } from '~/core/context.ts'; import { logger } from '~/core/logger.ts'; import { Module } from '~/module.ts'; -import type { ModuleDefinition } from '~/module.ts'; import { attachToQuickSettings } from '~/shared/quickSettings.ts'; import { BluetoothDeviceItemPatcher } from '~/panel/bluetoothMenu/deviceItem.ts'; @@ -101,12 +100,3 @@ export class BluetoothMenu extends Module { this._destroyIds.set(item, id); } } - -export const definition: ModuleDefinition = { - key: 'bluetooth-menu', - settingsKey: 'module-bluetooth-menu', - section: 'dock-panel', - title: _('Bluetooth Menu'), - subtitle: _('Shows battery level and animated icons in the Bluetooth Quick Settings panel'), - factory: (ctx) => new BluetoothMenu(ctx), -}; diff --git a/src/panel/clock/meetingClock/meetingClock.manifest.ts b/src/panel/clock/meetingClock/meetingClock.manifest.ts new file mode 100644 index 0000000..fe4e745 --- /dev/null +++ b/src/panel/clock/meetingClock/meetingClock.manifest.ts @@ -0,0 +1,62 @@ +import { gettext as _ } from 'gettext'; +import type { ModuleManifest } from '~/module.ts'; + +export const manifest: ModuleManifest = { + key: 'meeting-clock', + settingsKey: 'module-meeting-clock', + section: 'dock-panel', + title: _('Meeting Clock'), + subtitle: _('Shows upcoming calendar events next to the clock'), + options: [ + { + key: 'meeting-clock-alerts-enabled', + title: _('Meeting Alerts'), + subtitle: _('Show a notification when a meeting is about to start'), + type: 'switch', + }, + { + key: 'meeting-clock-alert-minutes-before', + title: _('Alert Lead Time (minutes)'), + subtitle: _('Minutes before a meeting starts to show the alert'), + type: 'spin', + min: 0, + max: 60, + }, + { + key: 'meeting-clock-snooze-minutes', + title: _('Snooze Duration (minutes)'), + subtitle: _('Minutes to wait before showing a snoozed alert again'), + type: 'spin', + min: 1, + max: 60, + }, + { + key: 'meeting-clock-alert-events-without-link', + title: _('Alert Events Without Links'), + subtitle: _('Show meeting alerts for calendar events that do not include a join link'), + type: 'switch', + }, + { + key: 'meeting-clock-panel-reveal-interval-minutes', + title: _('Panel Reveal Interval (minutes)'), + subtitle: _('Minutes between automatic Meeting Clock slide reveals in the panel'), + type: 'spin', + min: 1, + max: 60, + }, + { + key: 'meeting-clock-panel-lookahead-minutes', + title: _('Panel Lookahead (minutes)'), + subtitle: _('Maximum minutes before an event starts for it to appear in the panel clock'), + type: 'spin', + min: 0, + max: 1440, + }, + { + key: 'meeting-clock-exclude-all-day-events', + title: _('Hide All-Day Events'), + subtitle: _('Exclude all-day events from the clock and alerts'), + type: 'switch', + }, + ], +}; diff --git a/src/panel/clock/meetingClock/meetingClock.ts b/src/panel/clock/meetingClock/meetingClock.ts index caedb1f..bf710d2 100644 --- a/src/panel/clock/meetingClock/meetingClock.ts +++ b/src/panel/clock/meetingClock/meetingClock.ts @@ -9,9 +9,9 @@ import * as Main from '@girs/gnome-shell/ui/main'; import * as MessageTray from '@girs/gnome-shell/ui/messageTray'; import type { ExtensionContext } from '~/core/context.ts'; +import type { CleanupBag } from '~/core/cleanupBag.ts'; import { logger } from '~/core/logger.ts'; import { Module } from '~/module.ts'; -import type { ModuleDefinition } from '~/module.ts'; import { openClockMenu, type ClockPillRegistration } from '~/shared/clockPill.ts'; import { registerClockPillWidget } from '~/shared/clockPill.ts'; @@ -54,7 +54,7 @@ export class MeetingClock extends Module { private _activeNotificationDestroyId = 0; private _uiAlive = false; private _enabled = false; - private _settingsIds: number[] = []; + private _cleanup: CleanupBag | null = null; private _refreshTimerId = 0; private _labelTimerId = 0; private _alertTimerId = 0; @@ -72,6 +72,7 @@ export class MeetingClock extends Module { override enable(): void { this.disable(); + this._cleanup = this.context.createCleanupBag(); this._enabled = true; this._installClockWidget(); @@ -102,28 +103,30 @@ export class MeetingClock extends Module { this._schedulePanelRevealTimer(); const settings = this.context.settings; - this._settingsIds = [ - settings.connect(`changed::${ALERTS_ENABLED_KEY}`, () => this._scheduleAlerts()), - settings.connect(`changed::${ALERT_MINUTES_KEY}`, () => this._scheduleAlerts()), - settings.connect(`changed::${SNOOZE_MINUTES_KEY}`, () => this._scheduleAlerts()), - settings.connect(`changed::${ALERT_EVENTS_WITHOUT_LINK_KEY}`, () => this._scheduleAlerts()), - settings.connect(`changed::${PANEL_REVEAL_INTERVAL_MINUTES_KEY}`, () => - this._schedulePanelRevealTimer(), - ), - settings.connect(`changed::${PANEL_LOOKAHEAD_MINUTES_KEY}`, () => this._render()), - settings.connect(`changed::${EXCLUDE_ALL_DAY_KEY}`, () => { - this._render(); - this._scheduleAlerts(); - }), - ]; + this._cleanup.connect(settings, `changed::${ALERTS_ENABLED_KEY}`, () => this._scheduleAlerts()); + this._cleanup.connect(settings, `changed::${ALERT_MINUTES_KEY}`, () => this._scheduleAlerts()); + this._cleanup.connect(settings, `changed::${SNOOZE_MINUTES_KEY}`, () => this._scheduleAlerts()); + this._cleanup.connect(settings, `changed::${ALERT_EVENTS_WITHOUT_LINK_KEY}`, () => + this._scheduleAlerts(), + ); + this._cleanup.connect(settings, `changed::${PANEL_REVEAL_INTERVAL_MINUTES_KEY}`, () => + this._schedulePanelRevealTimer(), + ); + this._cleanup.connect(settings, `changed::${PANEL_LOOKAHEAD_MINUTES_KEY}`, () => + this._render(), + ); + this._cleanup.connect(settings, `changed::${EXCLUDE_ALL_DAY_KEY}`, () => { + this._render(); + this._scheduleAlerts(); + }); } override disable(): void { this._enabled = false; this._uiAlive = false; - for (const id of this._settingsIds) this.context.settings.disconnect(id); - this._settingsIds = []; + this._cleanup?.dispose(); + this._cleanup = null; this._clearRefreshTimer(); this._clearLabelTimer(); @@ -572,64 +575,3 @@ export class MeetingClock extends Module { } } } - -export const definition: ModuleDefinition = { - key: 'meeting-clock', - settingsKey: 'module-meeting-clock', - section: 'dock-panel', - title: _('Meeting Clock'), - subtitle: _('Shows upcoming calendar events next to the clock'), - options: [ - { - key: ALERTS_ENABLED_KEY, - title: _('Meeting Alerts'), - subtitle: _('Show a notification when a meeting is about to start'), - type: 'switch', - }, - { - key: ALERT_MINUTES_KEY, - title: _('Alert Lead Time (minutes)'), - subtitle: _('Minutes before a meeting starts to show the alert'), - type: 'spin', - min: 0, - max: 60, - }, - { - key: SNOOZE_MINUTES_KEY, - title: _('Snooze Duration (minutes)'), - subtitle: _('Minutes to wait before showing a snoozed alert again'), - type: 'spin', - min: 1, - max: 60, - }, - { - key: ALERT_EVENTS_WITHOUT_LINK_KEY, - title: _('Alert Events Without Links'), - subtitle: _('Show meeting alerts for calendar events that do not include a join link'), - type: 'switch', - }, - { - key: PANEL_REVEAL_INTERVAL_MINUTES_KEY, - title: _('Panel Reveal Interval (minutes)'), - subtitle: _('Minutes between automatic Meeting Clock slide reveals in the panel'), - type: 'spin', - min: 1, - max: 60, - }, - { - key: PANEL_LOOKAHEAD_MINUTES_KEY, - title: _('Panel Lookahead (minutes)'), - subtitle: _('Maximum minutes before an event starts for it to appear in the panel clock'), - type: 'spin', - min: 0, - max: 1440, - }, - { - key: EXCLUDE_ALL_DAY_KEY, - title: _('Hide All-Day Events'), - subtitle: _('Exclude all-day events from the clock and alerts'), - type: 'switch', - }, - ], - factory: (ctx) => new MeetingClock(ctx), -}; diff --git a/src/panel/clock/weatherClock/weatherClock.manifest.ts b/src/panel/clock/weatherClock/weatherClock.manifest.ts new file mode 100644 index 0000000..5fcb5f1 --- /dev/null +++ b/src/panel/clock/weatherClock/weatherClock.manifest.ts @@ -0,0 +1,18 @@ +import { gettext as _ } from 'gettext'; +import type { ModuleManifest } from '~/module.ts'; + +export const manifest: ModuleManifest = { + key: 'weather-clock', + settingsKey: 'module-weather-clock', + section: 'dock-panel', + title: _('Weather Clock'), + subtitle: _('Shows GNOME Weather next to the clock'), + options: [ + { + key: 'weather-clock-after-clock', + title: _('Show Weather After Clock'), + subtitle: _('Place the weather indicator after the clock instead of before it'), + type: 'switch', + }, + ], +}; diff --git a/src/panel/clock/weatherClock/weatherClock.ts b/src/panel/clock/weatherClock/weatherClock.ts index 06b5621..21930be 100644 --- a/src/panel/clock/weatherClock/weatherClock.ts +++ b/src/panel/clock/weatherClock/weatherClock.ts @@ -9,9 +9,9 @@ import GWeather from 'gi://GWeather'; import * as Main from '@girs/gnome-shell/ui/main'; import type { ExtensionContext } from '~/core/context.ts'; +import type { CleanupBag } from '~/core/cleanupBag.ts'; import { logger } from '~/core/logger.ts'; import { Module } from '~/module.ts'; -import type { ModuleDefinition } from '~/module.ts'; import { registerClockPillWidget, type ClockPillRegistration } from '~/shared/clockPill.ts'; import { @@ -29,11 +29,6 @@ const TEMPERATURE_UNIT_KEY = 'temperature-unit'; const REFRESH_INTERVAL_SECONDS = 600; const MAX_RETRIES = 5; -type SignalRecord = { - obj: { disconnect(id: number): void }; - id: number; -}; - type WeatherClient = { available: boolean; loading: boolean; @@ -65,12 +60,10 @@ export class WeatherClock extends Module { private _label: St.Label | null = null; private _weatherClient: WeatherClient | null = null; private _gweatherSettings: Gio.Settings | null = null; - private _gweatherSettingsId = 0; + private _cleanup: CleanupBag | null = null; private _monitor: Gio.NetworkMonitor | null = null; private _snapshotsBySource = new Map(); private _snapshot: WeatherSnapshot | null = null; - private _settingsIds: number[] = []; - private _signals: SignalRecord[] = []; private _refreshTimerId = 0; private _retryTimerId = 0; private _retryCount = 0; @@ -83,38 +76,29 @@ export class WeatherClock extends Module { override enable(): void { this.disable(); + this._cleanup = this.context.createCleanupBag(); this._enabled = true; this._monitor = Gio.NetworkMonitor.get_default(); this._gweatherSettings = this._createGWeatherSettings(); - this._gweatherSettingsId = - this._gweatherSettings?.connect(`changed::${TEMPERATURE_UNIT_KEY}`, () => + if (this._gweatherSettings) + this._cleanup.connect(this._gweatherSettings, `changed::${TEMPERATURE_UNIT_KEY}`, () => this._onWeatherChanged(), - ) ?? 0; + ); this._installClockWidget(); this._connectWeatherBackend(); this._startRefreshTimer(); - this._settingsIds = [ - this.context.settings.connect(`changed::${AFTER_CLOCK_KEY}`, () => - this._registerClockWidget(), - ), - ]; + this._cleanup.connect(this.context.settings, `changed::${AFTER_CLOCK_KEY}`, () => + this._registerClockWidget(), + ); } override disable(): void { this._enabled = false; this._uiAlive = false; - for (const id of this._settingsIds) this.context.settings.disconnect(id); - this._settingsIds = []; - - for (const signal of this._signals) signal.obj.disconnect(signal.id); - this._signals = []; - - if (this._gweatherSettingsId && this._gweatherSettings) { - this._gweatherSettings.disconnect(this._gweatherSettingsId); - } - this._gweatherSettingsId = 0; + this._cleanup?.dispose(); + this._cleanup = null; this._clearRefreshTimer(); this._clearRetryTimer(); @@ -248,7 +232,7 @@ export class WeatherClock extends Module { signalName: string, callback: (...args: unknown[]) => void, ): void { - this._signals.push({ obj, id: obj.connect(signalName, callback) }); + this._cleanup?.connect(obj, signalName, callback); } private _onConnectivityChanged(): void { @@ -424,20 +408,3 @@ export class WeatherClock extends Module { return Math.floor(Date.now() / 1000); } } - -export const definition: ModuleDefinition = { - key: 'weather-clock', - settingsKey: 'module-weather-clock', - section: 'dock-panel', - title: _('Weather Clock'), - subtitle: _('Shows GNOME Weather next to the clock'), - options: [ - { - key: AFTER_CLOCK_KEY, - title: _('Show Weather After Clock'), - subtitle: _('Place the weather indicator after the clock instead of before it'), - type: 'switch', - }, - ], - factory: (ctx) => new WeatherClock(ctx), -}; diff --git a/src/panel/lockKeyIndicators.manifest.ts b/src/panel/lockKeyIndicators.manifest.ts new file mode 100644 index 0000000..3dcc749 --- /dev/null +++ b/src/panel/lockKeyIndicators.manifest.ts @@ -0,0 +1,10 @@ +import { gettext as _ } from 'gettext'; +import type { ModuleManifest } from '~/module.ts'; + +export const manifest: ModuleManifest = { + key: 'lock-key-indicators', + settingsKey: 'module-lock-key-indicators', + section: 'dock-panel', + title: _('Lock Key Indicators'), + subtitle: _('Shows Caps Lock and Num Lock indicators in the top panel'), +}; diff --git a/src/panel/lockKeyIndicators.ts b/src/panel/lockKeyIndicators.ts index 69c1c29..85ffd1c 100644 --- a/src/panel/lockKeyIndicators.ts +++ b/src/panel/lockKeyIndicators.ts @@ -10,7 +10,6 @@ import * as PanelMenu from '@girs/gnome-shell/ui/panelMenu'; import type { ExtensionContext } from '~/core/context.ts'; import { logger } from '~/core/logger.ts'; import { Module } from '~/module.ts'; -import type { ModuleDefinition } from '~/module.ts'; const LOG_PREFIX = 'LockKeyIndicators'; const STATUS_AREA_ID = 'aurora-lock-key-indicators'; @@ -111,12 +110,3 @@ export class LockKeyIndicators extends Module { this._button.visible = capsActive || numActive; } } - -export const definition: ModuleDefinition = { - key: 'lock-key-indicators', - settingsKey: 'module-lock-key-indicators', - section: 'dock-panel', - title: _('Lock Key Indicators'), - subtitle: _('Shows Caps Lock and Num Lock indicators in the top panel'), - factory: (ctx) => new LockKeyIndicators(ctx), -}; diff --git a/src/panel/lowBatteryPercentage.manifest.ts b/src/panel/lowBatteryPercentage.manifest.ts new file mode 100644 index 0000000..5dca73d --- /dev/null +++ b/src/panel/lowBatteryPercentage.manifest.ts @@ -0,0 +1,10 @@ +import { gettext as _ } from 'gettext'; +import type { ModuleManifest } from '~/module.ts'; + +export const manifest: ModuleManifest = { + key: 'low-battery-percentage', + settingsKey: 'module-low-battery-percentage', + section: 'dock-panel', + title: _('Low Battery Percentage'), + subtitle: _('Shows battery percentage in the panel while below 20%'), +}; diff --git a/src/panel/lowBatteryPercentage.ts b/src/panel/lowBatteryPercentage.ts index b365326..9ff85c3 100644 --- a/src/panel/lowBatteryPercentage.ts +++ b/src/panel/lowBatteryPercentage.ts @@ -7,7 +7,6 @@ import type { ExtensionContext } from '~/core/context.ts'; import { logger } from '~/core/logger.ts'; import { Module } from '~/module.ts'; import type { SettingsManager } from '~/core/settings.ts'; -import type { ModuleDefinition } from '~/module.ts'; const LOG_PREFIX = 'LowBatteryPercentage'; const LOW_BATTERY_PERCENT = 20; @@ -136,12 +135,3 @@ export class LowBatteryPercentage extends Module { this._managedBatteryPercentage = false; } } - -export const definition: ModuleDefinition = { - key: 'low-battery-percentage', - settingsKey: 'module-low-battery-percentage', - section: 'dock-panel', - title: _('Low Battery Percentage'), - subtitle: _('Shows battery percentage in the panel while below 20%'), - factory: (ctx) => new LowBatteryPercentage(ctx), -}; diff --git a/src/panel/volumeMixer/volumeMixer.manifest.ts b/src/panel/volumeMixer/volumeMixer.manifest.ts new file mode 100644 index 0000000..811f3bf --- /dev/null +++ b/src/panel/volumeMixer/volumeMixer.manifest.ts @@ -0,0 +1,10 @@ +import { gettext as _ } from 'gettext'; +import type { ModuleManifest } from '~/module.ts'; + +export const manifest: ModuleManifest = { + key: 'volume-mixer', + settingsKey: 'module-volume-mixer', + section: 'dock-panel', + title: _('Volume Mixer'), + subtitle: _('Per-application volume control in Quick Settings'), +}; diff --git a/src/panel/volumeMixer/volumeMixer.ts b/src/panel/volumeMixer/volumeMixer.ts index 224bb43..083d139 100644 --- a/src/panel/volumeMixer/volumeMixer.ts +++ b/src/panel/volumeMixer/volumeMixer.ts @@ -12,7 +12,6 @@ import * as PopupMenu from '@girs/gnome-shell/ui/popupMenu'; import type { ExtensionContext } from '~/core/context.ts'; import { logger } from '~/core/logger.ts'; import { Module } from '~/module.ts'; -import type { ModuleDefinition } from '~/module.ts'; import { attachToQuickSettings } from '~/shared/quickSettings.ts'; import { VolumeMixerPanel } from '~/panel/volumeMixer/mixerPanel.ts'; import { loadIcon } from '~/shared/icons.ts'; @@ -171,12 +170,3 @@ export class VolumeMixer extends Module { }); } } - -export const definition: ModuleDefinition = { - key: 'volume-mixer', - settingsKey: 'module-volume-mixer', - section: 'dock-panel', - title: _('Volume Mixer'), - subtitle: _('Per-application volume control in Quick Settings'), - factory: (ctx) => new VolumeMixer(ctx), -}; diff --git a/src/patches/appSearchTooltip.manifest.ts b/src/patches/appSearchTooltip.manifest.ts new file mode 100644 index 0000000..fe5498e --- /dev/null +++ b/src/patches/appSearchTooltip.manifest.ts @@ -0,0 +1,10 @@ +import { gettext as _ } from 'gettext'; +import type { ModuleManifest } from '~/module.ts'; + +export const manifest: ModuleManifest = { + key: 'app-search-tooltip', + settingsKey: 'module-app-search-tooltip', + section: 'appearance', + title: _('App Search Tooltip'), + subtitle: _('Shows app name on hover in the overview search results'), +}; diff --git a/src/patches/appSearchTooltip.ts b/src/patches/appSearchTooltip.ts index 977b509..7e01793 100644 --- a/src/patches/appSearchTooltip.ts +++ b/src/patches/appSearchTooltip.ts @@ -5,7 +5,6 @@ import * as Main from '@girs/gnome-shell/ui/main'; import * as Search from '@girs/gnome-shell/ui/search'; import type { ExtensionContext } from '~/core/context.ts'; import { Module } from '~/module.ts'; -import type { ModuleDefinition } from '~/module.ts'; const SHOW_DELAY_MS = 300; @@ -169,12 +168,3 @@ export class AppSearchTooltip extends Module { return null; } } - -export const definition: ModuleDefinition = { - key: 'app-search-tooltip', - settingsKey: 'module-app-search-tooltip', - section: 'appearance', - title: _('App Search Tooltip'), - subtitle: _('Shows app name on hover in the overview search results'), - factory: (ctx) => new AppSearchTooltip(ctx), -}; diff --git a/src/patches/focusLaunchedWindows.manifest.ts b/src/patches/focusLaunchedWindows.manifest.ts new file mode 100644 index 0000000..3345c62 --- /dev/null +++ b/src/patches/focusLaunchedWindows.manifest.ts @@ -0,0 +1,10 @@ +import { gettext as _ } from 'gettext'; +import type { ModuleManifest } from '~/module.ts'; + +export const manifest: ModuleManifest = { + key: 'focus-launched-windows', + settingsKey: 'module-focus-launched-windows', + section: 'behavior', + title: _('Focus Launched Windows'), + subtitle: _('Focuses newly launched windows instead of showing window-ready notifications'), +}; diff --git a/src/patches/focusLaunchedWindows.ts b/src/patches/focusLaunchedWindows.ts index 3a11456..b286ebc 100644 --- a/src/patches/focusLaunchedWindows.ts +++ b/src/patches/focusLaunchedWindows.ts @@ -4,7 +4,6 @@ import * as Main from '@girs/gnome-shell/ui/main'; import type { ExtensionContext } from '~/core/context.ts'; import { Module } from '~/module.ts'; -import type { ModuleDefinition } from '~/module.ts'; export class FocusLaunchedWindows extends Module { private _demandsAttentionId = 0; @@ -30,12 +29,3 @@ export class FocusLaunchedWindows extends Module { } } } - -export const definition: ModuleDefinition = { - key: 'focus-launched-windows', - settingsKey: 'module-focus-launched-windows', - section: 'behavior', - title: _('Focus Launched Windows'), - subtitle: _('Focuses newly launched windows instead of showing window-ready notifications'), - factory: (ctx) => new FocusLaunchedWindows(ctx), -}; diff --git a/src/patches/iconWeave.manifest.ts b/src/patches/iconWeave.manifest.ts new file mode 100644 index 0000000..c724a36 --- /dev/null +++ b/src/patches/iconWeave.manifest.ts @@ -0,0 +1,10 @@ +import { gettext as _ } from 'gettext'; +import type { ModuleManifest } from '~/module.ts'; + +export const manifest: ModuleManifest = { + key: 'icon-weave', + settingsKey: 'module-icon-weave', + section: 'appearance', + title: _('Icon Weave'), + subtitle: _('Automatically fixes missing app icons using an in-memory approach'), +}; diff --git a/src/patches/iconWeave.ts b/src/patches/iconWeave.ts index 6cedf99..2221426 100644 --- a/src/patches/iconWeave.ts +++ b/src/patches/iconWeave.ts @@ -6,7 +6,7 @@ import Meta from '@girs/meta-18'; import type { ExtensionContext } from '~/core/context.ts'; import { logger } from '~/core/logger.ts'; import { Module } from '~/module.ts'; -import type { ModuleDefinition } from '~/module.ts'; +import { normalize, scoreIconWeaveCandidate } from '~/patches/iconWeaveScoring.ts'; const WINDOW_INSPECT_DELAY_MS = 500; const MIN_MATCH_SCORE = 50; @@ -524,7 +524,7 @@ export class IconWeave extends Module { if (!steamMatch) return false; const gameId = steamMatch[1]; - const nWm = this._normalize(wmClass); + const nWm = normalize(wmClass); if (nWm === `steamapp${gameId}`) return true; @@ -540,79 +540,9 @@ export class IconWeave extends Module { private _scoreCandidate(app: any, wmClass: string, appId: string, title: string): number { const desktopId = (app.get_id() ?? '').toLowerCase().replace(/\.desktop$/, ''); const appName = String(app.get_name() ?? '').toLowerCase(); - const shortId = desktopId.split('.').pop() ?? desktopId; - - // Prevent subprocess IDs (e.g. steam_app_1234) matching a parent (steam.desktop) - if ( - wmClass && - (wmClass.startsWith(`${desktopId}_`) || - wmClass.startsWith(`${shortId}_`) || - wmClass.startsWith(`${desktopId}-`) || - wmClass.startsWith(`${shortId}-`)) - ) - return 0; if (this._isSteamGame(app, wmClass)) return 99; - let score = 0; - - const words = appName.split(/[^a-z0-9]/).filter((w: string) => w.length > 0); - const abbreviation = words.map((w: string) => w[0]).join(''); - - const nWm = this._normalize(wmClass); - const nAppName = this._normalize(appName); - const nDesktopId = this._normalize(desktopId); - const nShortId = this._normalize(shortId); - - if (wmClass) { - const wm = wmClass.toLowerCase(); - if (desktopId === wm) score = Math.max(score, 93); - if (desktopId.includes(wm) && wm.length >= 3) score = Math.max(score, 80); - if (wm.includes(desktopId) && desktopId.length >= 3) score = Math.max(score, 70); - if (shortId && wm.includes(shortId) && shortId.length >= 3) score = Math.max(score, 66); - if (appName === wm) score = Math.max(score, 85); - if (appName.includes(wm) && wm.length >= 3) score = Math.max(score, 60); - if (wm.includes(appName) && appName.length >= 3) score = Math.max(score, 55); - - if (nWm === abbreviation && abbreviation.length >= 2) { - score = Math.max(score, 88); - } - - // Check normalized includes - if (nAppName.includes(nWm) && nWm.length >= 3) score = Math.max(score, 62); - if (nDesktopId.includes(nWm) && nWm.length >= 3) score = Math.max(score, 61); - } - - if (appId) { - const aId = appId.toLowerCase(); - const nAId = this._normalize(appId); - if (desktopId.includes(aId) && aId.length >= 3) score = Math.max(score, 75); - if (nAId === abbreviation && abbreviation.length >= 2) score = Math.max(score, 88); - } - - const tNorm = this._normalize(title); - - if (tNorm && tNorm.length >= 3) { - if (tNorm === nDesktopId) score = Math.max(score, 98); - if (tNorm === nAppName) score = Math.max(score, 95); - if (tNorm === nShortId) score = Math.max(score, 94); - if (nAppName.includes(tNorm)) score = Math.max(score, 65); - if (tNorm.includes(nDesktopId)) score = Math.max(score, 68); - } - - return score; - } - - private _normalize(str: string): string { - return (str || '').toLowerCase().replace(/[^a-z0-9]/g, ''); + return scoreIconWeaveCandidate({ desktopId, appName, wmClass, appId, title }); } } - -export const definition: ModuleDefinition = { - key: 'icon-weave', - settingsKey: 'module-icon-weave', - section: 'appearance', - title: _('Icon Weave'), - subtitle: _('Automatically fixes missing app icons using an in-memory approach'), - factory: (ctx) => new IconWeave(ctx), -}; diff --git a/src/patches/iconWeaveScoring.ts b/src/patches/iconWeaveScoring.ts new file mode 100644 index 0000000..5942c33 --- /dev/null +++ b/src/patches/iconWeaveScoring.ts @@ -0,0 +1,95 @@ +export type IconWeaveScoreInput = { + desktopId: string; + appName: string; + wmClass: string; + appId: string; + title: string; +}; + +const SHORT_ID_MIN_COVERAGE = 0.45; + +export function scoreIconWeaveCandidate(input: IconWeaveScoreInput): number { + const desktopId = input.desktopId.toLowerCase().replace(/\.desktop$/, ''); + const appName = input.appName.toLowerCase(); + const shortId = desktopId.split('.').pop() ?? desktopId; + + if (isSubprocessClass(wmClassLower(input.wmClass), desktopId, shortId)) return 0; + + let score = 0; + + const words = appName.split(/[^a-z0-9]/).filter((w) => w.length > 0); + const abbreviation = words.map((w) => w[0]).join(''); + + const nWm = normalize(input.wmClass); + const nAppName = normalize(appName); + const nDesktopId = normalize(desktopId); + const nShortId = normalize(shortId); + + if (input.wmClass) { + const wm = wmClassLower(input.wmClass); + if (desktopId === wm) score = Math.max(score, 93); + if (desktopId.includes(wm) && wm.length >= 3) score = Math.max(score, 80); + if (wm.includes(desktopId) && desktopId.length >= 3) score = Math.max(score, 70); + if (isSpecificShortIdMatch(shortId, wm)) score = Math.max(score, 66); + if (appName === wm) score = Math.max(score, 85); + if (appName.includes(wm) && wm.length >= 3) score = Math.max(score, 60); + if (wm.includes(appName) && appName.length >= 3) score = Math.max(score, 55); + + if (nWm === abbreviation && abbreviation.length >= 2) { + score = Math.max(score, 88); + } + + if (nAppName.includes(nWm) && nWm.length >= 3) score = Math.max(score, 62); + if (nDesktopId.includes(nWm) && nWm.length >= 3) score = Math.max(score, 61); + } + + if (input.appId) { + const aId = input.appId.toLowerCase(); + const nAId = normalize(input.appId); + if (desktopId.includes(aId) && aId.length >= 3) score = Math.max(score, 75); + if (nAId === abbreviation && abbreviation.length >= 2) score = Math.max(score, 88); + } + + const tNorm = normalize(input.title); + + if (tNorm && tNorm.length >= 3) { + if (tNorm === nDesktopId) score = Math.max(score, 98); + if (tNorm === nAppName) score = Math.max(score, 95); + if (tNorm === nShortId) score = Math.max(score, 94); + if (nAppName.includes(tNorm)) score = Math.max(score, 65); + if (tNorm.includes(nDesktopId)) score = Math.max(score, 68); + } + + return score; +} + +export function normalize(str: string): string { + return (str || '').toLowerCase().replace(/[^a-z0-9]/g, ''); +} + +function wmClassLower(wmClass: string): string { + return wmClass.toLowerCase(); +} + +function isSubprocessClass(wmClass: string, desktopId: string, shortId: string): boolean { + return ( + wmClass.length > 0 && + (wmClass.startsWith(`${desktopId}_`) || + wmClass.startsWith(`${shortId}_`) || + wmClass.startsWith(`${desktopId}-`) || + wmClass.startsWith(`${shortId}-`)) + ); +} + +function isSpecificShortIdMatch(shortId: string, wmClass: string): boolean { + const nShortId = normalize(shortId); + const nWm = normalize(wmClass); + + if (nShortId.length < 3 || !nWm.includes(nShortId)) return false; + if (nShortId === nWm) return true; + + const coverage = nShortId.length / nWm.length; + if (coverage >= SHORT_ID_MIN_COVERAGE) return true; + + return nShortId.length >= 5 && (nWm.startsWith(nShortId) || nWm.endsWith(nShortId)); +} diff --git a/src/patches/noOverview.manifest.ts b/src/patches/noOverview.manifest.ts new file mode 100644 index 0000000..66efa3c --- /dev/null +++ b/src/patches/noOverview.manifest.ts @@ -0,0 +1,10 @@ +import { gettext as _ } from 'gettext'; +import type { ModuleManifest } from '~/module.ts'; + +export const manifest: ModuleManifest = { + key: 'no-overview', + settingsKey: 'module-no-overview', + section: 'behavior', + title: _('Skip Overview on Login'), + subtitle: _('Goes directly to the desktop when GNOME Shell starts'), +}; diff --git a/src/patches/noOverview.ts b/src/patches/noOverview.ts index b39eb03..d05eccc 100644 --- a/src/patches/noOverview.ts +++ b/src/patches/noOverview.ts @@ -4,7 +4,6 @@ import * as Main from '@girs/gnome-shell/ui/main'; import type { ExtensionContext } from '~/core/context.ts'; import { Module } from '~/module.ts'; -import type { ModuleDefinition } from '~/module.ts'; /** * NoOverview Module @@ -41,12 +40,3 @@ export class NoOverview extends Module { } } } - -export const definition: ModuleDefinition = { - key: 'no-overview', - settingsKey: 'module-no-overview', - section: 'behavior', - title: _('No Overview'), - subtitle: _('Disables the overview at startup'), - factory: (ctx) => new NoOverview(ctx), -}; diff --git a/src/patches/pipOnTop.manifest.ts b/src/patches/pipOnTop.manifest.ts new file mode 100644 index 0000000..ee932c2 --- /dev/null +++ b/src/patches/pipOnTop.manifest.ts @@ -0,0 +1,10 @@ +import { gettext as _ } from 'gettext'; +import type { ModuleManifest } from '~/module.ts'; + +export const manifest: ModuleManifest = { + key: 'pip-on-top', + settingsKey: 'module-pip-on-top', + section: 'behavior', + title: _('Pip On Top'), + subtitle: _('Keeps Picture-in-Picture windows always on top'), +}; diff --git a/src/patches/pipOnTop.ts b/src/patches/pipOnTop.ts index 2bf78ba..cf8b757 100644 --- a/src/patches/pipOnTop.ts +++ b/src/patches/pipOnTop.ts @@ -3,7 +3,6 @@ import Meta from '@girs/meta-18'; import type { ExtensionContext } from '~/core/context.ts'; import { Module } from '~/module.ts'; -import type { ModuleDefinition } from '~/module.ts'; const PIP_TITLES = ['Picture-in-Picture', 'Picture in picture', 'Picture-in-picture']; @@ -111,12 +110,3 @@ export class PipOnTop extends Module { } } } - -export const definition: ModuleDefinition = { - key: 'pip-on-top', - settingsKey: 'module-pip-on-top', - section: 'behavior', - title: _('Pip On Top'), - subtitle: _('Keeps Picture-in-Picture windows always on top'), - factory: (ctx) => new PipOnTop(ctx), -}; diff --git a/src/patches/velaVpnQuickSettings.manifest.ts b/src/patches/velaVpnQuickSettings.manifest.ts new file mode 100644 index 0000000..25678b0 --- /dev/null +++ b/src/patches/velaVpnQuickSettings.manifest.ts @@ -0,0 +1,18 @@ +import { gettext as _ } from 'gettext'; +import type { ModuleManifest } from '~/module.ts'; + +export const manifest: ModuleManifest = { + key: 'vela-vpn-quick-settings', + settingsKey: 'module-vela-vpn-quick-settings', + section: 'behavior', + title: _('Vela VPN Quick Settings'), + subtitle: _('Routes VPN Quick Settings activation through Vela'), + options: [ + { + key: 'vela-vpn-quick-settings-shell-fallback', + title: _('Use GNOME Shell Fallback'), + subtitle: _('Use GNOME Shell only when the Vela D-Bus service or control API is unavailable'), + type: 'switch', + }, + ], +}; diff --git a/src/patches/velaVpnQuickSettings.ts b/src/patches/velaVpnQuickSettings.ts new file mode 100644 index 0000000..e11fdcb --- /dev/null +++ b/src/patches/velaVpnQuickSettings.ts @@ -0,0 +1,153 @@ +import { gettext as _ } from 'gettext'; + +import Gio from '@girs/gio-2.0'; +import GLib from '@girs/glib-2.0'; +import * as Main from '@girs/gnome-shell/ui/main'; + +import { logger } from '~/core/logger.ts'; +import type { ExtensionContext } from '~/core/context.ts'; +import { Module } from '~/module.ts'; + +const LOG_PREFIX = 'VelaVpnQuickSettings'; +const VELA_BUS_NAME = 'org.luminusos.Vela.Agent.Vpn1'; +const VELA_OBJECT_PATH = '/org/luminusos/Vela/Agent/Vpn1'; +const VELA_INTERFACE = 'org.luminusos.Vela.Agent.Vpn1'; +const SHELL_FALLBACK_KEY = 'vela-vpn-quick-settings-shell-fallback'; + +Gio._promisify(Gio.DBusConnection.prototype, 'call'); + +export class VelaVpnQuickSettings extends Module { + private _vpnToggle: any = null; + private _originalActivateConnection: any = null; + private _originalDeactivateConnection: any = null; + private _retryId = 0; + + constructor(context: ExtensionContext) { + super(context); + } + + override enable(): void { + this._patchWhenAvailable(); + } + + override disable(): void { + if (this._retryId > 0) { + GLib.source_remove(this._retryId); + this._retryId = 0; + } + + if (this._vpnToggle) { + if (this._originalActivateConnection) + this._vpnToggle.activateConnection = this._originalActivateConnection; + if (this._originalDeactivateConnection) + this._vpnToggle.deactivateConnection = this._originalDeactivateConnection; + } + + this._vpnToggle = null; + this._originalActivateConnection = null; + this._originalDeactivateConnection = null; + } + + private _patchWhenAvailable(): void { + const toggle = this._getVpnToggle(); + if (!toggle) { + if (this._retryId === 0) { + this._retryId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 500, () => { + this._retryId = 0; + this._patchWhenAvailable(); + return GLib.SOURCE_REMOVE; + }); + } + return; + } + + if (this._vpnToggle === toggle) return; + + this.disable(); + this._vpnToggle = toggle; + this._originalActivateConnection = toggle.activateConnection; + this._originalDeactivateConnection = toggle.deactivateConnection; + + toggle.activateConnection = (connection: any) => { + this._setConnectionActive(connection, true, () => { + this._originalActivateConnection?.call(this._vpnToggle, connection); + }); + }; + toggle.deactivateConnection = (activeConnection: any) => { + const connection = activeConnection?.connection ?? activeConnection?.get_connection?.(); + this._setConnectionActive(connection, false, () => { + this._originalDeactivateConnection?.call(this._vpnToggle, activeConnection); + }); + }; + + logger.info('Routing VPN Quick Settings activation through Vela', { prefix: LOG_PREFIX }); + } + + private _setConnectionActive(connection: any, active: boolean, fallback?: () => void): void { + const path = connection?.get_path?.(); + if (!path) { + logger.warn('Cannot route VPN activation without a NetworkManager connection path', { + prefix: LOG_PREFIX, + }); + if (this.context.settings.getBoolean(SHELL_FALLBACK_KEY)) fallback?.(); + return; + } + + this._callVelaSetConnectionActive(path, active).catch((error) => { + const remoteErrorName = this._getRemoteDbusErrorName(error); + logger.warn( + `Vela VPN control API failed (${remoteErrorName ?? 'local error'}): ${String(error)}`, + { prefix: LOG_PREFIX }, + ); + if (!this.context.settings.getBoolean(SHELL_FALLBACK_KEY)) return; + if (!this._shouldFallbackToShell(remoteErrorName)) return; + + logger.info(`Using GNOME Shell fallback after ${remoteErrorName}`, { prefix: LOG_PREFIX }); + fallback?.(); + }); + } + + private async _callVelaSetConnectionActive( + connectionPath: string, + active: boolean, + ): Promise { + await (Gio.DBus.session as any).call( + VELA_BUS_NAME, + VELA_OBJECT_PATH, + VELA_INTERFACE, + 'SetConnectionActive', + new GLib.Variant('(ob)', [connectionPath, active]), + null, + Gio.DBusCallFlags.NONE, + -1, + null, + ); + } + + private _getRemoteDbusErrorName(error: unknown): string | null { + if (!(error instanceof GLib.Error)) return null; + return Gio.DBusError.get_remote_error(error); + } + + private _shouldFallbackToShell(remoteErrorName: string | null): boolean { + switch (remoteErrorName) { + case 'org.freedesktop.DBus.Error.ServiceUnknown': + case 'org.freedesktop.DBus.Error.NameHasNoOwner': + case 'org.freedesktop.DBus.Error.UnknownMethod': + case 'org.freedesktop.DBus.Error.UnknownObject': + case 'org.freedesktop.DBus.Error.UnknownInterface': + return true; + default: + return false; + } + } + + private _getNetworkIndicator(): any { + const statusArea = (Main.panel as any).statusArea; + return statusArea.quickSettings?._network ?? statusArea.aggregateMenu?._network ?? null; + } + + private _getVpnToggle(): any { + return this._getNetworkIndicator()?._vpnToggle ?? null; + } +} diff --git a/src/patches/xwaylandIndicator.manifest.ts b/src/patches/xwaylandIndicator.manifest.ts new file mode 100644 index 0000000..ea39d11 --- /dev/null +++ b/src/patches/xwaylandIndicator.manifest.ts @@ -0,0 +1,10 @@ +import { gettext as _ } from 'gettext'; +import type { ModuleManifest } from '~/module.ts'; + +export const manifest: ModuleManifest = { + key: 'xwayland-indicator', + settingsKey: 'module-xwayland-indicator', + section: 'behavior', + title: _('XWayland Indicator'), + subtitle: _('Shows an X11 badge on XWayland apps in the Alt+Tab switcher'), +}; diff --git a/src/patches/xwaylandIndicator.ts b/src/patches/xwaylandIndicator.ts index 2eaab80..0bd088c 100644 --- a/src/patches/xwaylandIndicator.ts +++ b/src/patches/xwaylandIndicator.ts @@ -7,7 +7,6 @@ import * as AltTab from '@girs/gnome-shell/ui/altTab'; import type { ExtensionContext } from '~/core/context.ts'; import { loadIcon } from '~/shared/icons.ts'; import { Module } from '~/module.ts'; -import type { ModuleDefinition } from '~/module.ts'; export class XwaylandIndicator extends Module { private _origAppPopupInit: ((...args: unknown[]) => void) | null = null; @@ -111,12 +110,3 @@ export class XwaylandIndicator extends Module { wrapper.add_child(badge); } } - -export const definition: ModuleDefinition = { - key: 'xwayland-indicator', - settingsKey: 'module-xwayland-indicator', - section: 'behavior', - title: _('XWayland Indicator'), - subtitle: _('Shows an X11 badge on XWayland apps in the Alt+Tab switcher'), - factory: (ctx) => new XwaylandIndicator(ctx), -}; diff --git a/src/prefs.ts b/src/prefs.ts index 216995f..a82a953 100644 --- a/src/prefs.ts +++ b/src/prefs.ts @@ -8,11 +8,11 @@ import Gtk from '@girs/gtk-4.0'; import { ExtensionPreferences, gettext as _ } from '@girs/gnome-shell/extensions/prefs'; import { - getModuleMetadata, + getModuleCatalog, getSections, - type ModuleMetadata, + type ModuleManifest, type ModuleOption, -} from '~/prefsMetadata.ts'; +} from '~/moduleCatalog.ts'; const OTHER_SECTION_ID = '__other__'; const LOGO_FILENAME = 'aurora-shell-logo.svg'; @@ -28,7 +28,7 @@ export default class AuroraShellPreferences extends ExtensionPreferences { icon_name: 'dialog-information-symbolic', }); - const modules = getModuleMetadata(); + const modules = getModuleCatalog(); const sections = [...getSections(), { id: OTHER_SECTION_ID, title: _('Other') }]; const knownIds = new Set(getSections().map((s) => s.id)); @@ -115,7 +115,7 @@ export default class AuroraShellPreferences extends ExtensionPreferences { } } - private _buildModuleRow(def: ModuleMetadata, settings: Gio.Settings): Adw.PreferencesRow { + private _buildModuleRow(def: ModuleManifest, settings: Gio.Settings): Adw.PreferencesRow { if (def.options && def.options.length > 0) { return this._buildExpanderRow(def, settings); } @@ -128,7 +128,7 @@ export default class AuroraShellPreferences extends ExtensionPreferences { return row; } - private _buildExpanderRow(def: ModuleMetadata, settings: Gio.Settings): Adw.ExpanderRow { + private _buildExpanderRow(def: ModuleManifest, settings: Gio.Settings): Adw.ExpanderRow { const expander = new Adw.ExpanderRow({ title: def.title, subtitle: def.subtitle, diff --git a/src/prefsMetadata.ts b/src/prefsMetadata.ts deleted file mode 100644 index 11b4be0..0000000 --- a/src/prefsMetadata.ts +++ /dev/null @@ -1,432 +0,0 @@ -import { gettext as _ } from 'gettext'; - -import type { ModuleMetadata } from '~/module.ts'; - -export type { ModuleOption, ModuleOptionChoice, ModuleMetadata } from '~/module.ts'; - -/** - * Preferences sections, in display order. - * - * Each module declares a `section` id matching one of these; `prefs.ts` renders - * one `Adw.PreferencesGroup` per section and places modules under it. To add a - * new section, append an entry here and reference its `id` from a module's - * `definition` (and this file's mirror). `tests/unit/registry.test.ts` enforces - * that every module's `section` is a known id. - */ -export type ModuleSection = { id: string; title: string }; - -export function getSections(): ModuleSection[] { - return [ - { id: 'dock-panel', title: _('Dock & Panel') }, - { id: 'appearance', title: _('Appearance') }, - { id: 'behavior', title: _('Behavior') }, - { id: 'privacy-clipboard', title: _('Privacy & Clipboard') }, - ]; -} - -/** - * Metadata mirror for the preferences UI. - * - * Prefs runs in `gnome-extensions-app` (GTK/Adw) — NOT inside gnome-shell — so - * it cannot statically import anything that resolves to `resource:///org/gnome/shell/*`. - * Because module source files import shell internals (Main, Search, AltTab, etc.), - * they cannot be imported here. This file is a hand-maintained mirror of the - * metadata portion of each module's `definition` export. - * - * The registry ↔ prefsMetadata parity is enforced by `tests/unit/registry.test.ts`. - * If you add a module, update both this file and the module's own `definition`. - */ -export function getModuleMetadata(): ModuleMetadata[] { - return [ - { - key: 'no-overview', - settingsKey: 'module-no-overview', - section: 'behavior', - title: _('Skip Overview on Login'), - subtitle: _('Goes directly to the desktop when GNOME Shell starts'), - }, - { - key: 'pip-on-top', - settingsKey: 'module-pip-on-top', - section: 'behavior', - title: _('Pip On Top'), - subtitle: _('Keeps Picture-in-Picture windows always on top'), - }, - { - key: 'focus-launched-windows', - settingsKey: 'module-focus-launched-windows', - section: 'behavior', - title: _('Focus Launched Windows'), - subtitle: _('Focuses newly launched windows instead of showing window-ready notifications'), - }, - { - key: 'theme-changer', - settingsKey: 'module-theme-changer', - section: 'appearance', - title: _('Theme Changer'), - subtitle: _('Monitors and synchronizes GNOME color scheme'), - }, - { - key: 'dock', - settingsKey: 'module-dock', - section: 'dock-panel', - title: _('Dock'), - subtitle: _('Custom dock with auto-hide and intellihide features'), - options: [ - { - key: 'dock-always-show', - title: _('Always Show Dock'), - subtitle: _('Keep dock permanently visible and shrink windows so they never overlap it'), - type: 'switch', - }, - { - key: 'dock-show-trash', - title: _('Show Trash Icon'), - subtitle: _('Show a trash can in the dock; click to open it, right-click to empty it'), - type: 'switch', - }, - { - key: 'dock-show-external-storage', - title: _('Show External Storage'), - subtitle: _('Show removable drives in the dock when they are connected'), - type: 'switch', - }, - ], - }, - { - key: 'aurora-menu', - settingsKey: 'module-aurora-menu', - section: 'dock-panel', - title: _('Aurora Menu'), - subtitle: _('Aurora panel menu with recent items and useful shortcuts'), - options: [ - { - key: 'aurora-menu-icon', - title: _('Menu Icon'), - subtitle: _('Choose the icon shown in the top panel'), - type: 'icon-select', - choices: [ - { - value: 'aurora', - title: _('Aurora Shell'), - iconName: 'aurora-shell-menu-symbolic', - }, - { - value: 'gnome', - title: _('GNOME'), - iconName: 'start-here-symbolic', - }, - { - value: 'luminus', - title: _('Luminus OS'), - iconName: 'luminus-os-symbolic', - }, - ], - }, - { - key: 'aurora-menu-hide-activities', - title: _('Hide Activities Button'), - subtitle: _('Hide the Activities button while Aurora Menu is enabled'), - type: 'switch', - }, - { - key: 'aurora-menu-show-about', - title: _('Show About This PC'), - subtitle: _('Show the About This PC item in Aurora Menu'), - type: 'switch', - }, - { - key: 'aurora-menu-show-home', - title: _('Show Home Folder'), - subtitle: _('Show the Home Folder item in Aurora Menu'), - type: 'switch', - }, - { - key: 'aurora-menu-show-downloads', - title: _('Show Downloads'), - subtitle: _('Show the Downloads item in Aurora Menu'), - type: 'switch', - }, - { - key: 'aurora-menu-show-recent-items', - title: _('Show Recent Items'), - subtitle: _('Show the Recent Items submenu in Aurora Menu'), - type: 'switch', - }, - { - key: 'aurora-menu-show-settings', - title: _('Show System Settings'), - subtitle: _('Show the System Settings item in Aurora Menu'), - type: 'switch', - }, - { - key: 'aurora-menu-show-software', - title: _('Show Software'), - subtitle: _('Show the Software item in Aurora Menu'), - type: 'switch', - }, - { - key: 'aurora-menu-show-extensions', - title: _('Show Extensions'), - subtitle: _('Show the Extensions item in Aurora Menu'), - type: 'switch', - }, - { - key: 'aurora-menu-app-store-command', - title: _('Software Command'), - subtitle: _('Command used by the Software menu item'), - type: 'entry', - }, - { - key: 'aurora-menu-custom-items', - title: _('Custom Menu Commands'), - subtitle: _('One command per line, using “Label | command”'), - type: 'command-list', - }, - ], - }, - { - key: 'volume-mixer', - settingsKey: 'module-volume-mixer', - section: 'dock-panel', - title: _('Volume Mixer'), - subtitle: _('Per-application volume control in Quick Settings'), - }, - { - key: 'low-battery-percentage', - settingsKey: 'module-low-battery-percentage', - section: 'dock-panel', - title: _('Low Battery Percentage'), - subtitle: _('Shows battery percentage in the panel while below 20%'), - }, - { - key: 'lock-key-indicators', - settingsKey: 'module-lock-key-indicators', - section: 'dock-panel', - title: _('Lock Key Indicators'), - subtitle: _('Shows Caps Lock and Num Lock indicators in the top panel'), - }, - { - key: 'xwayland-indicator', - settingsKey: 'module-xwayland-indicator', - section: 'behavior', - title: _('XWayland Indicator'), - subtitle: _('Shows an X11 badge on XWayland apps in the Alt+Tab switcher'), - }, - { - key: 'privacy', - settingsKey: 'module-privacy', - section: 'privacy-clipboard', - title: _('Privacy'), - subtitle: _('Screen sharing privacy features'), - options: [ - { - key: 'privacy-dnd-on-share', - title: _('DND on Screen Share'), - subtitle: _('Automatically enables Do Not Disturb mode when screen sharing'), - type: 'switch', - }, - { - key: 'privacy-panel', - title: _('Privacy Panel'), - subtitle: _( - 'Hides panel content during screen sharing; shows only the sharing indicator', - ), - type: 'switch', - }, - ], - }, - { - key: 'icon-weave', - settingsKey: 'module-icon-weave', - section: 'appearance', - title: _('Icon Weave'), - subtitle: _('Automatically fixes missing app icons using an in-memory approach'), - }, - { - key: 'app-search-tooltip', - settingsKey: 'module-app-search-tooltip', - section: 'appearance', - title: _('App Search Tooltip'), - subtitle: _('Shows app name on hover in the overview search results'), - }, - { - key: 'auto-theme-switcher', - settingsKey: 'module-auto-theme-switcher', - section: 'appearance', - title: _('Auto Theme Switcher'), - subtitle: _('Automatically switches between light and dark theme based on time'), - options: [ - { - hourKey: 'auto-theme-switcher-light-hours', - minuteKey: 'auto-theme-switcher-light-minutes', - title: _('Light Time'), - subtitle: _('Time to switch to light theme (HH:MM)'), - type: 'time', - }, - { - hourKey: 'auto-theme-switcher-dark-hours', - minuteKey: 'auto-theme-switcher-dark-minutes', - title: _('Dark Time'), - subtitle: _('Time to switch to dark theme (HH:MM)'), - type: 'time', - }, - ], - }, - { - key: 'bluetooth-menu', - settingsKey: 'module-bluetooth-menu', - section: 'dock-panel', - title: _('Bluetooth Menu'), - subtitle: _('Shows battery level and animated icons in the Bluetooth Quick Settings panel'), - }, - { - key: 'weather-clock', - settingsKey: 'module-weather-clock', - section: 'dock-panel', - title: _('Weather Clock'), - subtitle: _('Shows GNOME Weather next to the clock'), - options: [ - { - key: 'weather-clock-after-clock', - title: _('Show Weather After Clock'), - subtitle: _('Place the weather indicator after the clock instead of before it'), - type: 'switch', - }, - ], - }, - { - key: 'meeting-clock', - settingsKey: 'module-meeting-clock', - section: 'dock-panel', - title: _('Meeting Clock'), - subtitle: _('Shows upcoming calendar events next to the clock'), - options: [ - { - key: 'meeting-clock-alerts-enabled', - title: _('Meeting Alerts'), - subtitle: _('Show a notification when a meeting is about to start'), - type: 'switch', - }, - { - key: 'meeting-clock-alert-minutes-before', - title: _('Alert Lead Time (minutes)'), - subtitle: _('Minutes before a meeting starts to show the alert'), - type: 'spin', - min: 0, - max: 60, - }, - { - key: 'meeting-clock-snooze-minutes', - title: _('Snooze Duration (minutes)'), - subtitle: _('Minutes to wait before showing a snoozed alert again'), - type: 'spin', - min: 1, - max: 60, - }, - { - key: 'meeting-clock-alert-events-without-link', - title: _('Alert Events Without Links'), - subtitle: _('Show meeting alerts for calendar events that do not include a join link'), - type: 'switch', - }, - { - key: 'meeting-clock-panel-reveal-interval-minutes', - title: _('Panel Reveal Interval (minutes)'), - subtitle: _('Minutes between automatic Meeting Clock slide reveals in the panel'), - type: 'spin', - min: 1, - max: 60, - }, - { - key: 'meeting-clock-panel-lookahead-minutes', - title: _('Panel Lookahead (minutes)'), - subtitle: _('Maximum minutes before an event starts for it to appear in the panel clock'), - type: 'spin', - min: 0, - max: 1440, - }, - { - key: 'meeting-clock-exclude-all-day-events', - title: _('Hide All-Day Events'), - subtitle: _('Exclude all-day events from the clock and alerts'), - type: 'switch', - }, - ], - }, - { - key: 'tray-icons', - settingsKey: 'module-tray-icons', - section: 'dock-panel', - title: _('Tray Icons'), - subtitle: _('System tray with SNI and background app icons'), - options: [ - { - key: 'tray-icons-limit', - title: _('Visible Icon Limit'), - subtitle: _('Maximum number of icons shown before the expand button appears'), - type: 'spin', - min: 1, - max: 20, - }, - { - key: 'tray-icons-icon-size', - title: _('Icon Size'), - subtitle: _('Tray icon size in pixels (14–24)'), - type: 'spin', - min: 14, - max: 24, - }, - { - key: 'tray-icons-attention-timeout', - title: _('Attention Auto-Collapse (seconds)'), - subtitle: _('Seconds before the tray collapses after a notification icon appears'), - type: 'spin', - min: 1, - max: 30, - }, - { - key: 'tray-icons-dedup-bg-apps', - title: _('Hide Background App When Tray Icon Present'), - subtitle: _('Remove the background app icon when the same app has an SNI tray icon'), - type: 'switch', - }, - { - key: 'tray-icons-hide-bg-quick-settings', - title: _('Hide Background Apps from Quick Settings'), - subtitle: _('Hide the Background Apps section from the Quick Settings dropdown'), - type: 'switch', - }, - { - key: 'tray-icons-recolor-symbolic-pixmaps', - title: _('Recolor Symbolic Tray Icons'), - subtitle: _('Automatically recolor monochrome SNI icons to match the panel theme'), - type: 'switch', - }, - ], - }, - { - key: 'clipboard-history', - settingsKey: 'module-clipboard-history', - section: 'privacy-clipboard', - title: _('Clipboard History'), - subtitle: _('Searchable clipboard history with pinning and keyboard navigation'), - options: [ - { - key: 'clipboard-history-shortcut', - title: _('Open Shortcut'), - subtitle: _('Keyboard shortcut to open the clipboard history panel'), - type: 'shortcut', - }, - { - key: 'clipboard-history-poll-interval', - title: _('Poll Interval (ms)'), - subtitle: _('How often to check the clipboard for changes'), - type: 'spin', - min: 250, - max: 5000, - }, - ], - }, - ]; -} diff --git a/src/privacy/privacy.manifest.ts b/src/privacy/privacy.manifest.ts new file mode 100644 index 0000000..e66cee7 --- /dev/null +++ b/src/privacy/privacy.manifest.ts @@ -0,0 +1,24 @@ +import { gettext as _ } from 'gettext'; +import type { ModuleManifest } from '~/module.ts'; + +export const manifest: ModuleManifest = { + key: 'privacy', + settingsKey: 'module-privacy', + section: 'privacy-clipboard', + title: _('Privacy'), + subtitle: _('Screen sharing privacy features'), + options: [ + { + key: 'privacy-dnd-on-share', + title: _('DND on Screen Share'), + subtitle: _('Automatically enables Do Not Disturb mode when screen sharing'), + type: 'switch', + }, + { + key: 'privacy-panel', + title: _('Privacy Panel'), + subtitle: _('Hides panel content during screen sharing; shows only the sharing indicator'), + type: 'switch', + }, + ], +}; diff --git a/src/privacy/privacy.ts b/src/privacy/privacy.ts index 5f4ac9a..ba6395b 100644 --- a/src/privacy/privacy.ts +++ b/src/privacy/privacy.ts @@ -2,7 +2,6 @@ import { gettext as _ } from 'gettext'; import type { ExtensionContext } from '~/core/context.ts'; import { Module } from '~/module.ts'; -import type { ModuleDefinition } from '~/module.ts'; import { DndOnShare } from '~/privacy/dndOnShare.ts'; import { PrivacyPanel } from '~/privacy/privacyPanel.ts'; @@ -65,26 +64,3 @@ export class PrivacyModule extends Module { } } } - -export const definition: ModuleDefinition = { - key: 'privacy', - settingsKey: 'module-privacy', - section: 'privacy-clipboard', - title: _('Privacy'), - subtitle: _('Screen sharing privacy features'), - options: [ - { - key: 'privacy-dnd-on-share', - title: _('DND on Screen Share'), - subtitle: _('Automatically enables Do Not Disturb mode when screen sharing'), - type: 'switch', - }, - { - key: 'privacy-panel', - title: _('Privacy Panel'), - subtitle: _('Hides panel content during screen sharing; shows only the sharing indicator'), - type: 'switch', - }, - ], - factory: (ctx) => new PrivacyModule(ctx), -}; diff --git a/src/registry.ts b/src/registry.ts index ba174ad..37ffbc6 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -1,47 +1,65 @@ -import { definition as noOverview } from '~/patches/noOverview.ts'; -import { definition as pipOnTop } from '~/patches/pipOnTop.ts'; -import { definition as focusLaunchedWindows } from '~/patches/focusLaunchedWindows.ts'; -import { definition as themeChanger } from '~/theme/themeChanger.ts'; -import { definition as dock } from '~/dock/dock.ts'; -import { definition as auroraMenu } from '~/panel/auroraMenu.ts'; -import { definition as volumeMixer } from '~/panel/volumeMixer/volumeMixer.ts'; -import { definition as lowBatteryPercentage } from '~/panel/lowBatteryPercentage.ts'; -import { definition as lockKeyIndicators } from '~/panel/lockKeyIndicators.ts'; -import { definition as xwaylandIndicator } from '~/patches/xwaylandIndicator.ts'; -import { definition as privacy } from '~/privacy/privacy.ts'; -import { definition as iconWeave } from '~/patches/iconWeave.ts'; -import { definition as appSearchTooltip } from '~/patches/appSearchTooltip.ts'; -import { definition as autoThemeSwitcher } from '~/theme/autoThemeSwitcher.ts'; -import { definition as bluetoothMenu } from '~/panel/bluetoothMenu/bluetoothMenu.ts'; -import { definition as weatherClock } from '~/panel/clock/weatherClock/weatherClock.ts'; -import { definition as meetingClock } from '~/panel/clock/meetingClock/meetingClock.ts'; -import { definition as trayIcons } from '~/desktop/trayIcons/trayIcons.ts'; -import { definition as clipboardHistory } from '~/clipboard/clipboardHistory.ts'; +import { getModuleCatalog } from '~/moduleCatalog.ts'; +import { NoOverview } from '~/patches/noOverview.ts'; +import { PipOnTop } from '~/patches/pipOnTop.ts'; +import { FocusLaunchedWindows } from '~/patches/focusLaunchedWindows.ts'; +import { ThemeChanger } from '~/theme/themeChanger.ts'; +import { Dock } from '~/dock/dock.ts'; +import { AuroraMenu } from '~/panel/auroraMenu.ts'; +import { VolumeMixer } from '~/panel/volumeMixer/volumeMixer.ts'; +import { LowBatteryPercentage } from '~/panel/lowBatteryPercentage.ts'; +import { LockKeyIndicators } from '~/panel/lockKeyIndicators.ts'; +import { XwaylandIndicator } from '~/patches/xwaylandIndicator.ts'; +import { PrivacyModule } from '~/privacy/privacy.ts'; +import { IconWeave } from '~/patches/iconWeave.ts'; +import { AppSearchTooltip } from '~/patches/appSearchTooltip.ts'; +import { VelaVpnQuickSettings } from '~/patches/velaVpnQuickSettings.ts'; +import { AutoThemeSwitcher } from '~/theme/autoThemeSwitcher.ts'; +import { BluetoothMenu } from '~/panel/bluetoothMenu/bluetoothMenu.ts'; +import { WeatherClock } from '~/panel/clock/weatherClock/weatherClock.ts'; +import { MeetingClock } from '~/panel/clock/meetingClock/meetingClock.ts'; +import { TrayIcons } from '~/desktop/trayIcons/trayIcons.ts'; +import { ClipboardHistory } from '~/clipboard/clipboardHistory.ts'; -import type { ModuleDefinition } from '~/module.ts'; +import type { ModuleDefinition, ModuleManifest } from '~/module.ts'; -export type { ModuleOption, ModuleMetadata, ModuleDefinition } from '~/module.ts'; +const factories = { + 'no-overview': (context) => new NoOverview(context), + 'pip-on-top': (context) => new PipOnTop(context), + 'focus-launched-windows': (context) => new FocusLaunchedWindows(context), + 'theme-changer': (context) => new ThemeChanger(context), + dock: (context) => new Dock(context), + 'aurora-menu': (context) => new AuroraMenu(context), + 'volume-mixer': (context) => new VolumeMixer(context), + 'low-battery-percentage': (context) => new LowBatteryPercentage(context), + 'lock-key-indicators': (context) => new LockKeyIndicators(context), + 'xwayland-indicator': (context) => new XwaylandIndicator(context), + privacy: (context) => new PrivacyModule(context), + 'icon-weave': (context) => new IconWeave(context), + 'app-search-tooltip': (context) => new AppSearchTooltip(context), + 'vela-vpn-quick-settings': (context) => new VelaVpnQuickSettings(context), + 'auto-theme-switcher': (context) => new AutoThemeSwitcher(context), + 'bluetooth-menu': (context) => new BluetoothMenu(context), + 'weather-clock': (context) => new WeatherClock(context), + 'meeting-clock': (context) => new MeetingClock(context), + 'tray-icons': (context) => new TrayIcons(context), + 'clipboard-history': (context) => new ClipboardHistory(context), +} satisfies Record; export function getModuleRegistry(): ModuleDefinition[] { - return [ - noOverview, - pipOnTop, - focusLaunchedWindows, - themeChanger, - dock, - auroraMenu, - volumeMixer, - lowBatteryPercentage, - lockKeyIndicators, - xwaylandIndicator, - privacy, - iconWeave, - appSearchTooltip, - autoThemeSwitcher, - bluetoothMenu, - weatherClock, - meetingClock, - trayIcons, - clipboardHistory, - ]; + return getModuleCatalog().map((manifest) => ({ + manifest, + factory: getFactory(manifest), + })); } + +function getFactory(manifest: ModuleManifest): ModuleDefinition['factory'] { + const factory = factories[manifest.key as keyof typeof factories]; + if (!factory) throw new Error(`No runtime factory registered for module ${manifest.key}`); + return factory; +} + +export function getRegisteredFactoryKeys(): readonly string[] { + return Object.keys(factories); +} + +export type { ModuleDefinition } from '~/module.ts'; diff --git a/src/shared/ui/dash.ts b/src/shared/ui/dash.ts index 7da9756..e447623 100644 --- a/src/shared/ui/dash.ts +++ b/src/shared/ui/dash.ts @@ -17,13 +17,14 @@ import { type ExternalStorageIconInstance, type ExternalStorageItem, } from '~/dock/externalStorageIcon.ts'; +import { + boundsContainPoint, + boundsEqual, + calculateDashPlacement, + type DashBounds, +} from '~/shared/ui/dashLayout.ts'; -export interface DashBounds { - x: number; - y: number; - width: number; - height: number; -} +export type { DashBounds } from '~/shared/ui/dashLayout.ts'; type TargetBoxListener = (bounds: DashBounds | null) => void; @@ -273,9 +274,9 @@ export class AuroraDash extends Dash { containsStagePoint(x: number, y: number): boolean { return ( - this._boundsContainPoint(this._getActorStageBounds(this._container), x, y) || - this._boundsContainPoint(this._getActorStageBounds(this), x, y) || - this._boundsContainPoint(this._targetBox, x, y) + boundsContainPoint(this._getActorStageBounds(this._container), x, y) || + boundsContainPoint(this._getActorStageBounds(this), x, y) || + boundsContainPoint(this._targetBox, x, y) ); } @@ -418,14 +419,10 @@ export class AuroraDash extends Dash { const width = Math.min(Math.max(prefW, 0), workArea.width); const [, prefH] = this.get_preferred_height(width || workArea.width); - const height = Math.min(Math.max(prefH, 0), workArea.height); - - const marginBottom = this._getMarginBottom(); - const x = workArea.x + Math.round((workArea.width - width) / 2); - const y = Math.max(workArea.y, workArea.y + workArea.height - height - marginBottom); + const placement = calculateDashPlacement(workArea, prefW, prefH, this._getMarginBottom()); - this._container.set_size(width, height); - this._container.set_position(x, y); + this._container.set_size(placement.width, placement.height); + this._container.set_position(placement.x, placement.y); this._queueTargetBoxUpdate(); } catch (_e) { if (!this._isDestroyed) throw _e; @@ -675,16 +672,6 @@ export class AuroraDash extends Dash { return { x, y, width, height }; } - private _boundsContainPoint(bounds: DashBounds | null, x: number, y: number): boolean { - if (!bounds) return false; - return ( - x >= bounds.x && - x <= bounds.x + bounds.width && - y >= bounds.y && - y <= bounds.y + bounds.height - ); - } - // Stock Dash._init() connects item-drag-* / window-drag-* via bare // connect() (no disconnect on destroy), so signals keep firing after the // GObject is disposed. Each override is just a disposed-guard; resize is @@ -1191,7 +1178,7 @@ export class AuroraDash extends Dash { height: size.height, }; - if (!AuroraDash._boundsEqual(this._targetBox, bounds)) { + if (!boundsEqual(this._targetBox, bounds)) { this._targetBox = bounds; this._targetBoxListener?.(this._targetBox); } @@ -1232,10 +1219,4 @@ export class AuroraDash extends Dash { return GLib.SOURCE_REMOVE; }); } - - private static _boundsEqual(a: DashBounds | null, b: DashBounds | null): boolean { - if (a === b) return true; - if (!a || !b) return false; - return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height; - } } diff --git a/src/shared/ui/dashLayout.ts b/src/shared/ui/dashLayout.ts new file mode 100644 index 0000000..07e911f --- /dev/null +++ b/src/shared/ui/dashLayout.ts @@ -0,0 +1,37 @@ +export interface DashBounds { + x: number; + y: number; + width: number; + height: number; +} + +export type DashPlacement = DashBounds; + +export function boundsContainPoint(bounds: DashBounds | null, x: number, y: number): boolean { + if (!bounds) return false; + return ( + x >= bounds.x && x <= bounds.x + bounds.width && y >= bounds.y && y <= bounds.y + bounds.height + ); +} + +export function boundsEqual(a: DashBounds | null, b: DashBounds | null): boolean { + if (a === b) return true; + if (!a || !b) return false; + return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height; +} + +export function calculateDashPlacement( + workArea: DashBounds, + preferredWidth: number, + preferredHeight: number, + marginBottom: number, +): DashPlacement { + const width = Math.min(Math.max(preferredWidth, 0), workArea.width); + const height = Math.min(Math.max(preferredHeight, 0), workArea.height); + return { + x: workArea.x + Math.round((workArea.width - width) / 2), + y: Math.max(workArea.y, workArea.y + workArea.height - height - marginBottom), + width, + height, + }; +} diff --git a/src/theme/autoThemeSwitcher.manifest.ts b/src/theme/autoThemeSwitcher.manifest.ts new file mode 100644 index 0000000..ad950c9 --- /dev/null +++ b/src/theme/autoThemeSwitcher.manifest.ts @@ -0,0 +1,26 @@ +import { gettext as _ } from 'gettext'; +import type { ModuleManifest } from '~/module.ts'; + +export const manifest: ModuleManifest = { + key: 'auto-theme-switcher', + settingsKey: 'module-auto-theme-switcher', + section: 'appearance', + title: _('Auto Theme Switcher'), + subtitle: _('Automatically switches between light and dark theme based on time'), + options: [ + { + hourKey: 'auto-theme-switcher-light-hours', + minuteKey: 'auto-theme-switcher-light-minutes', + title: _('Light Time'), + subtitle: _('Time to switch to light theme (HH:MM)'), + type: 'time', + }, + { + hourKey: 'auto-theme-switcher-dark-hours', + minuteKey: 'auto-theme-switcher-dark-minutes', + title: _('Dark Time'), + subtitle: _('Time to switch to dark theme (HH:MM)'), + type: 'time', + }, + ], +}; diff --git a/src/theme/autoThemeSwitcher.ts b/src/theme/autoThemeSwitcher.ts index c1b4d8d..4a2ca04 100644 --- a/src/theme/autoThemeSwitcher.ts +++ b/src/theme/autoThemeSwitcher.ts @@ -7,7 +7,6 @@ import type { ExtensionContext } from '~/core/context.ts'; import { logger } from '~/core/logger.ts'; import { Module } from '~/module.ts'; import type { SettingsManager } from '~/core/settings.ts'; -import type { ModuleDefinition } from '~/module.ts'; /** * AutoThemeSwitcher Module @@ -123,28 +122,3 @@ export class AutoThemeSwitcher extends Module { }); } } - -export const definition: ModuleDefinition = { - key: 'auto-theme-switcher', - settingsKey: 'module-auto-theme-switcher', - section: 'appearance', - title: _('Auto Theme Switcher'), - subtitle: _('Automatically switches between light and dark theme based on time'), - options: [ - { - hourKey: 'auto-theme-switcher-light-hours', - minuteKey: 'auto-theme-switcher-light-minutes', - title: _('Light Time'), - subtitle: _('Time to switch to light theme (HH:MM)'), - type: 'time', - }, - { - hourKey: 'auto-theme-switcher-dark-hours', - minuteKey: 'auto-theme-switcher-dark-minutes', - title: _('Dark Time'), - subtitle: _('Time to switch to dark theme (HH:MM)'), - type: 'time', - }, - ], - factory: (ctx) => new AutoThemeSwitcher(ctx), -}; diff --git a/src/theme/themeChanger.manifest.ts b/src/theme/themeChanger.manifest.ts new file mode 100644 index 0000000..1bd06d5 --- /dev/null +++ b/src/theme/themeChanger.manifest.ts @@ -0,0 +1,10 @@ +import { gettext as _ } from 'gettext'; +import type { ModuleManifest } from '~/module.ts'; + +export const manifest: ModuleManifest = { + key: 'theme-changer', + settingsKey: 'module-theme-changer', + section: 'appearance', + title: _('Theme Changer'), + subtitle: _('Monitors and synchronizes GNOME color scheme'), +}; diff --git a/src/theme/themeChanger.ts b/src/theme/themeChanger.ts index d87503c..76cd3dc 100644 --- a/src/theme/themeChanger.ts +++ b/src/theme/themeChanger.ts @@ -5,7 +5,6 @@ import type { ExtensionContext } from '~/core/context.ts'; import { logger } from '~/core/logger.ts'; import { Module } from '~/module.ts'; import type { SettingsManager } from '~/core/settings.ts'; -import type { ModuleDefinition } from '~/module.ts'; const LOG_PREFIX = 'ThemeChanger'; @@ -69,12 +68,3 @@ export class ThemeChanger extends Module { this._settings = null; } } - -export const definition: ModuleDefinition = { - key: 'theme-changer', - settingsKey: 'module-theme-changer', - section: 'appearance', - title: _('Theme Changer'), - subtitle: _('Monitors and synchronizes GNOME color scheme'), - factory: (ctx) => new ThemeChanger(ctx), -}; diff --git a/tests/shell/auroraVelaVpnQuickSettings.js b/tests/shell/auroraVelaVpnQuickSettings.js new file mode 100644 index 0000000..e4e86f6 --- /dev/null +++ b/tests/shell/auroraVelaVpnQuickSettings.js @@ -0,0 +1,83 @@ +/* eslint camelcase: ["error", { properties: "never", allow: ["^script_"] }] */ + +/** + * Aurora Shell — Vela VPN fallback integration test + * + * The test session has no Vela Agent bus owner, so calls to its real D-Bus + * name fail with ServiceUnknown. Verify that this known-safe condition only + * reaches GNOME Shell when the opt-in fallback setting is enabled. + */ + +import * as Scripting from 'resource:///org/gnome/shell/ui/scripting.js'; +import { EXTENSION_UUID, getAuroraSettings, waitForExtension } from './testUtils.js'; + +const MODULE_KEY = 'vela-vpn-quick-settings'; +const MODULE_SETTING = 'module-vela-vpn-quick-settings'; +const FALLBACK_SETTING = 'vela-vpn-quick-settings-shell-fallback'; + +export var METRICS = {}; + +/** @returns {void} */ +export function init() { + Scripting.defineScriptEvent( + 'fallbackPolicyOk', + 'Vela VPN fallback is opt-in and handles ServiceUnknown', + ); +} + +/** @returns {Promise} */ +export async function run() { + let extension = await waitForExtension(EXTENSION_UUID); + const settings = getAuroraSettings(); + const originalModuleEnabled = settings.get_boolean(MODULE_SETTING); + const originalFallbackEnabled = settings.get_boolean(FALLBACK_SETTING); + + try { + const defaultValue = settings.get_default_value(FALLBACK_SETTING)?.unpack(); + if (defaultValue !== false) + throw new Error('Vela VPN Shell fallback must be disabled by default'); + + settings.set_boolean(MODULE_SETTING, true); + await Scripting.waitLeisure(); + await Scripting.sleep(200); + + extension = await waitForExtension(EXTENSION_UUID); + const module = extension.stateObj?._modules?.get(MODULE_KEY); + if (!module) throw new Error('Vela VPN Quick Settings module is not active'); + + const connection = { + get_path: () => '/org/freedesktop/NetworkManager/Settings/999999', + }; + let fallbackCalls = 0; + const fallback = () => fallbackCalls++; + + settings.set_boolean(FALLBACK_SETTING, false); + module._setConnectionActive(connection, true, fallback); + await Scripting.sleep(500); + if (fallbackCalls !== 0) + throw new Error('GNOME Shell fallback ran while the setting was disabled'); + + settings.set_boolean(FALLBACK_SETTING, true); + module._setConnectionActive(connection, true, fallback); + await Scripting.sleep(500); + if (fallbackCalls !== 1) + throw new Error(`Expected one fallback for ServiceUnknown, got ${fallbackCalls}`); + + Scripting.scriptEvent('fallbackPolicyOk'); + } finally { + settings.set_boolean(FALLBACK_SETTING, originalFallbackEnabled); + settings.set_boolean(MODULE_SETTING, originalModuleEnabled); + } +} + +let _fallbackPolicyOk = false; + +/** @returns {void} */ +export function script_fallbackPolicyOk() { + _fallbackPolicyOk = true; +} + +/** @returns {void} */ +export function finish() { + if (!_fallbackPolicyOk) throw new Error('Vela VPN fallback policy test did not complete'); +} diff --git a/tests/unit/auroraMenuState.test.ts b/tests/unit/auroraMenuState.test.ts new file mode 100644 index 0000000..258c253 --- /dev/null +++ b/tests/unit/auroraMenuState.test.ts @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { decodeXml, parseCustomCommand, truncateMiddle } from '../../src/panel/auroraMenuState.ts'; + +test('Aurora Menu state — parses label and command around the first separator', () => { + assert.deepEqual(parseCustomCommand('Terminal | foot --working-directory=/tmp|logs'), { + label: 'Terminal', + command: 'foot --working-directory=/tmp|logs', + }); + assert.equal(parseCustomCommand('missing separator'), null); + assert.equal(parseCustomCommand(' | missing label'), null); + assert.equal(parseCustomCommand('missing command | '), null); +}); + +test('Aurora Menu state — decodes XML entities in recent item labels', () => { + assert.equal(decodeXml('A & B <C> "D" 'E''), 'A & B "D" \'E\''); +}); + +test('Aurora Menu state — truncates the middle while preserving both edges', () => { + assert.equal(truncateMiddle('abcdefghij', 7), 'abc…hij'); + assert.equal(truncateMiddle('short', 7), 'short'); +}); diff --git a/tests/unit/cleanupBag.test.ts b/tests/unit/cleanupBag.test.ts new file mode 100644 index 0000000..e649f8c --- /dev/null +++ b/tests/unit/cleanupBag.test.ts @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { CleanupBag } from '../../src/core/cleanupBag.ts'; + +test('CleanupBag — runs cleanup in reverse order and is idempotent', () => { + const calls: number[] = []; + const bag = new CleanupBag(); + bag.add(() => calls.push(1)); + bag.add(() => calls.push(2)); + bag.add(() => calls.push(3)); + bag.dispose(); + bag.dispose(); + assert.deepEqual(calls, [3, 2, 1]); +}); + +test('CleanupBag — disconnects signals', () => { + const disconnected: number[] = []; + const target = { + connect: () => 42, + disconnect: (id: number) => disconnected.push(id), + }; + const bag = new CleanupBag(); + assert.equal( + bag.connect(target, 'changed', () => undefined), + 42, + ); + bag.dispose(); + assert.deepEqual(disconnected, [42]); +}); + +test('CleanupBag — pairs connectObject with disconnectObject', () => { + const calls: string[] = []; + const owner = {}; + const target = { + disconnectObject: (value: object) => { + assert.equal(value, owner); + calls.push('disconnect'); + }, + }; + const bag = new CleanupBag(); + bag.connectObject(target, owner, () => calls.push('connect')); + bag.dispose(); + assert.deepEqual(calls, ['connect', 'disconnect']); +}); + +test('CleanupBag — removes sources and D-Bus watches', () => { + const calls: string[] = []; + const bag = new CleanupBag(); + bag.source(7, (id) => calls.push(`source:${id}`)); + bag.watch(9, (id) => calls.push(`watch:${id}`)); + bag.dispose(); + assert.deepEqual(calls, ['watch:9', 'source:7']); +}); + +test('CleanupBag — cleanup added after disposal runs immediately', () => { + let cleaned = false; + const bag = new CleanupBag(); + bag.dispose(); + bag.add(() => (cleaned = true)); + assert.equal(cleaned, true); +}); diff --git a/tests/unit/dashLayout.test.ts b/tests/unit/dashLayout.test.ts new file mode 100644 index 0000000..fb683c5 --- /dev/null +++ b/tests/unit/dashLayout.test.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { + boundsContainPoint, + boundsEqual, + calculateDashPlacement, +} from '../../src/shared/ui/dashLayout.ts'; + +const bounds = { x: 100, y: 50, width: 800, height: 600 }; + +test('dash layout — centers and bottom-aligns inside the work area', () => { + assert.deepEqual(calculateDashPlacement(bounds, 300, 80, 12), { + x: 350, + y: 558, + width: 300, + height: 80, + }); +}); + +test('dash layout — clamps oversized or negative preferred dimensions', () => { + assert.deepEqual(calculateDashPlacement(bounds, 1200, -20, 0), { + x: 100, + y: 650, + width: 800, + height: 0, + }); +}); + +test('dash layout — point containment includes edges', () => { + assert.equal(boundsContainPoint(bounds, 100, 50), true); + assert.equal(boundsContainPoint(bounds, 900, 650), true); + assert.equal(boundsContainPoint(bounds, 99, 50), false); + assert.equal(boundsContainPoint(null, 100, 50), false); +}); + +test('dash layout — structural bounds equality handles null', () => { + assert.equal(boundsEqual(bounds, { ...bounds }), true); + assert.equal(boundsEqual(bounds, { ...bounds, width: 799 }), false); + assert.equal(boundsEqual(null, null), true); + assert.equal(boundsEqual(bounds, null), false); +}); diff --git a/tests/unit/deviceRuntime.test.ts b/tests/unit/deviceRuntime.test.ts new file mode 100644 index 0000000..d95bd5a --- /dev/null +++ b/tests/unit/deviceRuntime.test.ts @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { + activeDisplayRoles, + classifyDevice, + classifyInputMode, + classifyOrientation, + createDeviceSnapshot, + sameDeviceSnapshot, + type InputPresence, + type MonitorInput, +} from '../../src/device/runtime.ts'; + +const input = (values: Partial = {}): InputPresence => ({ + touch: false, + pointer: false, + keyboard: false, + ...values, +}); + +const monitor = (values: Partial = {}): MonitorInput => ({ + index: 0, + x: 0, + y: 0, + width: 1920, + height: 1080, + scale: 1, + isBuiltin: true, + ...values, +}); + +test('device runtime — orientation is based on logical dimensions', () => { + assert.equal(classifyOrientation(1080, 1920), 'portrait'); + assert.equal(classifyOrientation(1920, 1080), 'landscape'); + assert.equal(classifyOrientation(800, 800), 'square'); + assert.equal(classifyOrientation(0, 800), 'unknown'); +}); + +test('device runtime — input mode reports mixed devices honestly', () => { + assert.equal(classifyInputMode(input({ touch: true })), 'touch'); + assert.equal(classifyInputMode(input({ pointer: true })), 'pointer'); + assert.equal(classifyInputMode(input({ keyboard: true })), 'keyboard'); + assert.equal(classifyInputMode(input({ touch: true, keyboard: true })), 'mixed'); + assert.equal(classifyInputMode(input()), 'unknown'); +}); + +test('device runtime — classifies phone, tablet, laptop, desktop and unknown', () => { + assert.equal( + classifyDevice([monitor({ width: 412, height: 892 })], input({ touch: true })), + 'phone', + ); + assert.equal( + classifyDevice([monitor({ width: 800, height: 1280 })], input({ touch: true })), + 'tablet', + ); + assert.equal(classifyDevice([monitor()], input({ keyboard: true })), 'laptop'); + assert.equal( + classifyDevice([monitor({ isBuiltin: false })], input({ pointer: true })), + 'desktop', + ); + assert.equal(classifyDevice([], input()), 'unknown'); +}); + +test('device runtime — mixed phone topology assigns mobile internal and desktop external roles', () => { + const snapshot = createDeviceSnapshot( + [ + monitor({ width: 412, height: 892 }), + monitor({ index: 1, x: 412, width: 1920, height: 1080, isBuiltin: false }), + ], + input({ touch: true, pointer: true, keyboard: true }), + new Set(['touch']), + ); + assert.equal(snapshot.deviceClass, 'phone'); + assert.deepEqual( + snapshot.monitors.map((item) => item.role), + ['mobile', 'desktop'], + ); + assert.deepEqual([...activeDisplayRoles(snapshot)].sort(), ['desktop', 'mobile']); +}); + +test('device runtime — mobile-only topology retains desktop fallback for current modules', () => { + const snapshot = createDeviceSnapshot( + [monitor({ width: 412, height: 892 })], + input({ touch: true }), + new Set(['touch']), + ); + assert.deepEqual([...activeDisplayRoles(snapshot)].sort(), ['desktop', 'mobile']); + assert.deepEqual([...activeDisplayRoles(snapshot, false)], ['mobile']); +}); + +test('device runtime — equality detects meaningful topology changes only', () => { + const first = createDeviceSnapshot([monitor()], input({ keyboard: true }), new Set()); + const same = createDeviceSnapshot([monitor()], input({ keyboard: true }), new Set()); + const changed = createDeviceSnapshot( + [monitor({ width: 1080, height: 1920 })], + input({ keyboard: true }), + new Set(), + ); + assert.equal(sameDeviceSnapshot(first, same), true); + assert.equal(sameDeviceSnapshot(first, changed), false); +}); diff --git a/tests/unit/iconWeaveScoring.test.ts b/tests/unit/iconWeaveScoring.test.ts new file mode 100644 index 0000000..1351d27 --- /dev/null +++ b/tests/unit/iconWeaveScoring.test.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { scoreIconWeaveCandidate } from '../../src/patches/iconWeaveScoring.ts'; + +const MIN_MATCH_SCORE = 50; + +test('IconWeave scoring rejects helper classes that only share a generic short token', () => { + const score = scoreIconWeaveCandidate({ + desktopId: 'io.ente.auth', + appName: 'Ente Auth', + wmClass: 'nm-openconnect-auth-dialog', + appId: '', + title: 'Authentication Required', + }); + + assert.equal(score, 0); +}); + +test('IconWeave scoring keeps exact identity matches strong', () => { + const score = scoreIconWeaveCandidate({ + desktopId: 'io.ente.auth', + appName: 'Ente Auth', + wmClass: 'io.ente.auth', + appId: '', + title: '', + }); + + assert.ok(score >= MIN_MATCH_SCORE); +}); + +test('IconWeave scoring keeps compact short-id variants matchable', () => { + const score = scoreIconWeaveCandidate({ + desktopId: 'com.discordapp.Discord', + appName: 'Discord', + wmClass: 'discordcanary', + appId: '', + title: '', + }); + + assert.ok(score >= MIN_MATCH_SCORE); +}); diff --git a/tests/unit/moduleManager.test.ts b/tests/unit/moduleManager.test.ts new file mode 100644 index 0000000..258da7b --- /dev/null +++ b/tests/unit/moduleManager.test.ts @@ -0,0 +1,157 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import type { ExtensionContext } from '../../src/core/context.ts'; +import type { DeviceChangeListener, DeviceService } from '../../src/device/device.ts'; +import { createDeviceSnapshot, type DeviceSnapshot } from '../../src/device/runtime.ts'; +import type { Module, ModuleDefinition, ModuleManifest } from '../../src/module.ts'; +import { ModuleManager } from '../../src/moduleManager.ts'; + +class FakeSettings { + values = new Map(); + listeners = new Map void>(); + nextId = 1; + + getBoolean(key: string): boolean { + return this.values.get(key) ?? false; + } + + connect(_signal: string, callback: () => void): number { + const id = this.nextId++; + this.listeners.set(id, callback); + return id; + } + + disconnect(id: number): void { + this.listeners.delete(id); + } + + set(key: string, value: boolean): void { + this.values.set(key, value); + for (const callback of this.listeners.values()) callback(); + } +} + +class FakeDevice implements DeviceService { + private listeners = new Set(); + constructor(public current: DeviceSnapshot) {} + hasCapability(capability: Parameters[0]): boolean { + return this.current.capabilities.has(capability); + } + refresh(): DeviceSnapshot { + return this.current; + } + subscribeChanged(listener: DeviceChangeListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + set(snapshot: DeviceSnapshot): void { + this.current = snapshot; + for (const listener of this.listeners) listener(snapshot); + } + destroy(): void { + this.listeners.clear(); + } +} + +const desktop = createDeviceSnapshot( + [{ index: 0, x: 0, y: 0, width: 1920, height: 1080, scale: 1, isBuiltin: false }], + { touch: false, pointer: true, keyboard: true }, + new Set(), +); + +function manifest(runtime?: ModuleManifest['runtime']): ModuleManifest { + return { + key: 'sample', + settingsKey: 'module-sample', + section: 'behavior', + title: 'Sample', + subtitle: 'Sample', + runtime, + }; +} + +function setup(definition: ModuleDefinition): { + manager: ModuleManager; + settings: FakeSettings; + device: FakeDevice; + errors: string[]; +} { + const settings = new FakeSettings(); + const device = new FakeDevice(desktop); + const errors: string[] = []; + const context = { settings, device } as unknown as ExtensionContext; + const manager = new ModuleManager([definition], context, { + debug: () => undefined, + error: (message) => errors.push(message), + }); + return { manager, settings, device, errors }; +} + +test('ModuleManager — follows settings and tears modules down', () => { + let enables = 0; + let disables = 0; + const item: Module = { enable: () => enables++, disable: () => disables++ } as Module; + const state = setup({ manifest: manifest(), factory: () => item }); + state.settings.values.set('module-sample', true); + state.manager.start(); + assert.equal(enables, 1); + state.settings.set('module-sample', false); + assert.equal(disables, 1); + state.manager.stop(); + assert.equal(state.settings.listeners.size, 0); +}); + +test('ModuleManager — discards and cleans a module whose enable fails', () => { + let disables = 0; + const item: Module = { + enable: () => { + throw new Error('boom'); + }, + disable: () => disables++, + } as Module; + const state = setup({ manifest: manifest(), factory: () => item }); + state.settings.values.set('module-sample', true); + state.manager.start(); + assert.equal(state.manager.getModule('sample'), null); + assert.equal(disables, 1); + assert.equal(state.errors.length, 1); + state.manager.stop(); +}); + +test('ModuleManager — reconciles capability changes', () => { + let enables = 0; + let disables = 0; + const state = setup({ + manifest: manifest({ requires: ['touch'] }), + factory: () => ({ enable: () => enables++, disable: () => disables++ }) as Module, + }); + state.settings.values.set('module-sample', true); + state.manager.start(); + assert.equal(enables, 0); + state.device.set({ ...desktop, capabilities: new Set(['touch']) }); + assert.equal(enables, 1); + state.device.set(desktop); + assert.equal(disables, 1); + state.manager.stop(); +}); + +test('ModuleManager — stop is idempotent and disables in reverse order', () => { + const order: string[] = []; + const definitions: ModuleDefinition[] = ['first', 'second'].map((key) => ({ + manifest: { ...manifest(), key, settingsKey: `module-${key}` }, + factory: () => ({ enable: () => undefined, disable: () => order.push(key) }) as Module, + })); + const settings = new FakeSettings(); + settings.values.set('module-first', true); + settings.values.set('module-second', true); + const context = { settings, device: new FakeDevice(desktop) } as unknown as ExtensionContext; + const manager = new ModuleManager(definitions, context, { + debug: () => undefined, + error: () => undefined, + }); + manager.start(); + manager.stop(); + manager.stop(); + assert.deepEqual(order, ['second', 'first']); +}); diff --git a/tests/unit/registry.test.ts b/tests/unit/registry.test.ts index 5f9ab36..5caf38c 100644 --- a/tests/unit/registry.test.ts +++ b/tests/unit/registry.test.ts @@ -1,288 +1,207 @@ -/** - * Regression tests — Module registry consistency - * - * Adding a new module requires edits in: - * 1. semantic source folder — `definition` export (metadata + factory, co-located with class) - * 2. src/registry.ts — one import line + one entry in getModuleRegistry() - * 3. src/prefsMetadata.ts — metadata entry (prefs runs in a different process and - * cannot statically import modules that reference shell internals) - * 4. data/schemas/… — GSettings key (covered by schema.test.ts) - * - * These tests parse the TypeScript source as text (no GJS runtime needed) and - * cross-check that registry, prefsMetadata, module definitions, and the schema - * are in sync, so a half-finished module addition is caught immediately in CI. - */ - -import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; -import { resolve, dirname } from 'node:path'; +import { dirname, resolve } from 'node:path'; +import { test } from 'node:test'; import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); -// --------------------------------------------------------------------------- -// Source parsers -// --------------------------------------------------------------------------- - -/** Extract { key, settingsKey } pairs from any source file. */ -function parseEntriesFromSource(src: string): { key: string; settingsKey: string }[] { - const entries: { key: string; settingsKey: string }[] = []; - const blockRe = /key:\s*'([^']+)',\s*settingsKey:\s*'([^']+)'/g; - let m; - while ((m = blockRe.exec(src)) !== null) entries.push({ key: m[1], settingsKey: m[2] }); - return entries; -} - -/** Extract { key, section } pairs from any source file. */ -function parseSectionsFromSource(src: string): { key: string; section: string }[] { - const entries: { key: string; section: string }[] = []; - const blockRe = /key:\s*'([^']+)',\s*settingsKey:\s*'[^']+',\s*section:\s*'([^']+)'/g; - let m; - while ((m = blockRe.exec(src)) !== null) entries.push({ key: m[1], section: m[2] }); - return entries; +type CatalogEntry = { + key: string; + settingsKey: string; + section: string; + runtimeRoles: string[]; +}; + +function sourceFile(relativePath: string): ts.SourceFile { + const path = resolve(root, relativePath); + return ts.createSourceFile( + path, + readFileSync(path, 'utf8'), + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); } -/** Collect { key, section } pairs from every module definition. */ -function collectSectionsFromModuleFiles(): { key: string; section: string }[] { - const out: { key: string; section: string }[] = []; - for (const file of parseRegistryDefinitionFiles()) { - const src = readFileSync(file, 'utf-8'); - if (src.includes('export const definition')) out.push(...parseSectionsFromSource(src)); +function property(object: ts.ObjectLiteralExpression, name: string): ts.Expression | undefined { + for (const item of object.properties) { + if (!ts.isPropertyAssignment(item)) continue; + const itemName = + ts.isIdentifier(item.name) || ts.isStringLiteral(item.name) ? item.name.text : ''; + if (itemName === name) return item.initializer; } - return out; -} - -/** Parse the section ids declared in prefsMetadata.ts `getSections()`. */ -function parseKnownSectionIds(): string[] { - const src = readFileSync(resolve(root, 'src/prefsMetadata.ts'), 'utf-8'); - const block = src.match(/getSections\(\)[\s\S]*?return\s*\[([\s\S]*?)\];/); - if (!block) throw new Error('Could not locate getSections() return array'); - const ids: string[] = []; - const idRe = /id:\s*'([^']+)'/g; - let m; - while ((m = idRe.exec(block[1])) !== null) ids.push(m[1]); - return ids; + return undefined; } -/** Parse the module entries from registry.ts (full ModuleDefinition, includes factory). */ -function parseRegistryEntries(): { key: string; settingsKey: string }[] { - // registry.ts aggregates via imports; parse each module file's `definition` - // block referenced by the registry's returned array to derive keys. - return collectEntriesFromModuleFiles(); +function stringProperty(object: ts.ObjectLiteralExpression, name: string): string { + const value = property(object, name); + assert.ok(value && ts.isStringLiteral(value), `${name} must be a string literal`); + return value.text; } -/** Resolve the module definition files imported by registry.ts. */ -function parseRegistryDefinitionFiles(): string[] { - const src = readFileSync(resolve(root, 'src/registry.ts'), 'utf-8'); - const files: string[] = []; - const importRe = /import\s*\{\s*definition\s+as\s+\w+\s*\}\s*from\s*'([^']+)'/g; - let m; - while ((m = importRe.exec(src)) !== null) { - files.push(resolve(root, m[1].replace(/^~\//, 'src/'))); +function catalog(): CatalogEntry[] { + const catalogSource = sourceFile('src/moduleCatalog.ts'); + const imports = new Map(); + for (const statement of catalogSource.statements) { + if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) + continue; + const bindings = statement.importClause?.namedBindings; + if (!bindings || !ts.isNamedImports(bindings)) continue; + for (const element of bindings.elements) { + if (element.propertyName?.text === 'manifest') + imports.set(element.name.text, element.name.text); + } } - return files; -} -/** Collect `definition` blocks from the files imported by registry.ts. */ -function collectEntriesFromModuleFiles(): { key: string; settingsKey: string }[] { - const entries: { key: string; settingsKey: string }[] = []; - for (const file of parseRegistryDefinitionFiles()) { - const src = readFileSync(file, 'utf-8'); - if (!src.includes('export const definition')) continue; - entries.push(...parseEntriesFromSource(src)); + const paths = new Map(); + for (const statement of catalogSource.statements) { + if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) + continue; + const bindings = statement.importClause?.namedBindings; + if (!bindings || !ts.isNamedImports(bindings)) continue; + for (const element of bindings.elements) { + if (element.propertyName?.text === 'manifest') + paths.set(element.name.text, statement.moduleSpecifier.text.replace(/^~\//, 'src/')); + } } - return entries; -} -/** Parse `getModuleMetadata()` entries from prefsMetadata.ts. */ -function parsePrefsMetadataEntries(): { key: string; settingsKey: string }[] { - const src = readFileSync(resolve(root, 'src/prefsMetadata.ts'), 'utf-8'); - return parseEntriesFromSource(src); -} - -/** Parse registry.ts returned array order — keys in emission order. */ -function parseRegistryOrder(): string[] { - const src = readFileSync(resolve(root, 'src/registry.ts'), 'utf-8'); - const importRe = /import\s*\{\s*definition\s+as\s+(\w+)\s*\}\s*from\s*'[^']+'/g; - const importedAliases = new Set(); - let m; - while ((m = importRe.exec(src)) !== null) importedAliases.add(m[1]); - - const returnMatch = src.match(/return\s*\[([\s\S]*?)\];/); - if (!returnMatch) throw new Error('Could not locate registry return array'); - const returnBody = returnMatch[1]; - const order: string[] = []; - const aliasRe = /\b(\w+)\b/g; - let a; - while ((a = aliasRe.exec(returnBody)) !== null) { - if (importedAliases.has(a[1])) order.push(a[1]); + let order: string[] = []; + for (const statement of catalogSource.statements) { + if (!ts.isVariableStatement(statement)) continue; + for (const declaration of statement.declarationList.declarations) { + if (declaration.name.getText() !== 'MODULE_CATALOG') continue; + assert.ok(declaration.initializer && ts.isArrayLiteralExpression(declaration.initializer)); + order = declaration.initializer.elements.map((element) => { + assert.ok( + ts.isIdentifier(element), + 'catalog entries must be imported manifest identifiers', + ); + return element.text; + }); + } } - return order; -} -/** Parse key order from prefsMetadata.ts. */ -function parsePrefsMetadataOrder(): string[] { - return parsePrefsMetadataEntries().map((e) => e.key); + return order.map((alias) => { + const manifestPath = paths.get(alias); + assert.ok(manifestPath && imports.has(alias), `catalog alias ${alias} has no manifest import`); + const manifestSource = sourceFile(manifestPath); + let object: ts.ObjectLiteralExpression | undefined; + for (const statement of manifestSource.statements) { + if (!ts.isVariableStatement(statement)) continue; + for (const declaration of statement.declarationList.declarations) { + if ( + declaration.name.getText() === 'manifest' && + ts.isObjectLiteralExpression(declaration.initializer) + ) + object = declaration.initializer; + } + } + assert.ok(object, `${manifestPath} must export a manifest object`); + const runtime = property(object, 'runtime'); + const roles = + runtime && ts.isObjectLiteralExpression(runtime) ? property(runtime, 'roles') : undefined; + return { + key: stringProperty(object, 'key'), + settingsKey: stringProperty(object, 'settingsKey'), + section: stringProperty(object, 'section'), + runtimeRoles: + roles && ts.isArrayLiteralExpression(roles) + ? roles.elements.map((role) => { + assert.ok(ts.isStringLiteral(role)); + return role.text; + }) + : [], + }; + }); } -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -const registryEntries = parseRegistryEntries(); -const prefsEntries = parsePrefsMetadataEntries(); -const registryKeys = registryEntries.map((e) => e.key); -const registrySettingsKeys = registryEntries.map((e) => e.settingsKey); -const prefsKeys = prefsEntries.map((e) => e.key); - -test('registry — at least one module is registered', () => { - assert.ok(registryEntries.length > 0, 'Registry has no entries'); -}); - -test('registry — module keys are unique', () => { - assert.strictEqual( - new Set(registryKeys).size, - registryKeys.length, - `Duplicate module keys: ${registryKeys.filter((k, i) => registryKeys.indexOf(k) !== i)}`, - ); -}); - -test('registry — settingsKeys are unique', () => { - assert.strictEqual( - new Set(registrySettingsKeys).size, - registrySettingsKeys.length, - `Duplicate settingsKeys: ${registrySettingsKeys.filter((k, i) => registrySettingsKeys.indexOf(k) !== i)}`, - ); -}); - -test('registry — all settingsKeys follow the "module-" prefix convention', () => { - for (const { settingsKey } of registryEntries) { - assert.match( - settingsKey, - /^module-/, - `settingsKey "${settingsKey}" does not start with "module-"`, - ); - } -}); - -test('registry ↔ prefsMetadata — every module key is mirrored in prefsMetadata', () => { - for (const key of registryKeys) { - assert.ok( - prefsKeys.includes(key), - `Module "${key}" is missing from src/prefsMetadata.ts (prefs UI will skip it)`, - ); - } -}); - -test('registry ↔ prefsMetadata — every prefsMetadata key has a module definition', () => { - for (const key of prefsKeys) { - assert.ok( - registryKeys.includes(key), - `prefsMetadata key "${key}" has no corresponding module definition`, - ); +function factoryKeys(): string[] { + const registry = sourceFile('src/registry.ts'); + for (const statement of registry.statements) { + if (!ts.isVariableStatement(statement)) continue; + for (const declaration of statement.declarationList.declarations) { + if (declaration.name.getText() !== 'factories') continue; + const expression = ts.isSatisfiesExpression(declaration.initializer) + ? declaration.initializer.expression + : declaration.initializer; + assert.ok(ts.isObjectLiteralExpression(expression)); + return expression.properties.map((item) => { + assert.ok(ts.isPropertyAssignment(item)); + assert.ok(ts.isIdentifier(item.name) || ts.isStringLiteral(item.name)); + return item.name.text; + }); + } } -}); + throw new Error('registry factories object not found'); +} -test('registry ↔ prefsMetadata — settingsKeys match for the same module key', () => { - const regMap = new Map(registryEntries.map((e) => [e.key, e.settingsKey])); - for (const { key, settingsKey } of prefsEntries) { - assert.strictEqual( - regMap.get(key), - settingsKey, - `prefsMetadata key "${key}" has settingsKey "${settingsKey}" but registry has "${regMap.get(key)}"`, - ); - } -}); +const entries = catalog(); +const keys = entries.map((entry) => entry.key); +const settingsKeys = entries.map((entry) => entry.settingsKey); -test('registry ↔ prefsMetadata — presentation order is identical', () => { - // Order matters: prefs UI renders modules in prefsMetadata order; the runtime - // iterates registry order. If they diverge the visible/enable sequences drift. - const regOrder = parseRegistryOrder(); - const prefsOrder = parsePrefsMetadataOrder(); - // Registry order comes from import-alias names (camelCase); prefs order comes - // from kebab-case keys. Normalise both to the kebab-case key via registry map. - const aliasToKey = new Map(); - const src = readFileSync(resolve(root, 'src/registry.ts'), 'utf-8'); - const importRe = /import\s*\{\s*definition\s+as\s+(\w+)\s*\}\s*from\s*'([^']+)'/g; - let m; - while ((m = importRe.exec(src)) !== null) { - const alias = m[1]; - const path = m[2]; - const moduleFile = resolve( - root, - path.replace('~', 'src').replace(/\.ts$/, '.ts'), - ); - const modSrc = readFileSync(moduleFile, 'utf-8'); - const keyMatch = modSrc.match(/key:\s*'([^']+)'/); - if (keyMatch) aliasToKey.set(alias, keyMatch[1]); - } - const regOrderKeys = regOrder.map((a) => aliasToKey.get(a)!).filter(Boolean); - assert.deepStrictEqual( - prefsOrder, - regOrderKeys, - 'prefsMetadata order must match registry.ts import/return order', - ); +test('catalog — ids and settings keys are unique', () => { + assert.equal(new Set(keys).size, keys.length); + assert.equal(new Set(settingsKeys).size, settingsKeys.length); }); -test('registry — every module declares a section known to getSections()', () => { - const known = new Set(parseKnownSectionIds()); - assert.ok(known.size > 0, 'getSections() returned no sections'); - for (const { key, section } of collectSectionsFromModuleFiles()) { - assert.ok(section, `Module "${key}" is missing a section`); - assert.ok(known.has(section), `Module "${key}" references unknown section "${section}"`); - } +test('catalog ↔ registry — every manifest has exactly one factory', () => { + assert.deepEqual([...factoryKeys()].sort(), [...keys].sort()); }); -test('registry ↔ prefsMetadata — section matches for the same module key', () => { - const regSections = new Map(collectSectionsFromModuleFiles().map((e) => [e.key, e.section])); - const prefsSrc = readFileSync(resolve(root, 'src/prefsMetadata.ts'), 'utf-8'); - for (const { key, section } of parseSectionsFromSource(prefsSrc)) { - assert.strictEqual( - regSections.get(key), - section, - `prefsMetadata key "${key}" has section "${section}" but registry has "${regSections.get(key)}"`, - ); +test('catalog — every section is declared by moduleCatalog', () => { + const catalogSource = sourceFile('src/moduleCatalog.ts'); + const sections = new Set(); + function visit(node: ts.Node): void { + if ( + ts.isPropertyAssignment(node) && + node.name.getText() === 'id' && + ts.isStringLiteral(node.initializer) + ) + sections.add(node.initializer.text); + ts.forEachChild(node, visit); } + visit(catalogSource); + for (const entry of entries) assert.ok(sections.has(entry.section), entry.key); }); -test('registry ↔ schema — every settingsKey is declared in the schema XML', () => { - const schemaXml = readFileSync( - resolve(root, 'data/schemas/org.gnome.shell.extensions.aurora-shell.gschema.xml'), - 'utf-8', +test('prefs — consumes moduleCatalog directly', () => { + const prefs = sourceFile('src/prefs.ts'); + const importsCatalog = prefs.statements.some( + (statement) => + ts.isImportDeclaration(statement) && + ts.isStringLiteral(statement.moduleSpecifier) && + statement.moduleSpecifier.text === '~/moduleCatalog.ts', ); - for (const { settingsKey } of registryEntries) { - assert.ok( - schemaXml.includes(`name="${settingsKey}"`), - `settingsKey "${settingsKey}" is not declared in the GSettings schema`, - ); - } + assert.equal(importsCatalog, true); }); -test('registry — tray icons is desktop-only', () => { - const src = readFileSync(resolve(root, 'src/desktop/trayIcons/trayIcons.ts'), 'utf-8'); - assert.match( - src, - /runtime:\s*\{\s*targets:\s*\[\s*'desktop'\s*\]\s*\}/, - 'Tray Icons must stay desktop-only; Aurora has no mobile tray icons', - ); +test('catalog — desktop module baseline is preserved', () => { + assert.deepEqual(keys, [ + 'no-overview', + 'pip-on-top', + 'focus-launched-windows', + 'theme-changer', + 'dock', + 'aurora-menu', + 'volume-mixer', + 'low-battery-percentage', + 'lock-key-indicators', + 'xwayland-indicator', + 'privacy', + 'icon-weave', + 'app-search-tooltip', + 'vela-vpn-quick-settings', + 'auto-theme-switcher', + 'bluetooth-menu', + 'weather-clock', + 'meeting-clock', + 'tray-icons', + 'clipboard-history', + ]); }); -test('registry — every registry import resolves to a module file that exports a definition', () => { - const src = readFileSync(resolve(root, 'src/registry.ts'), 'utf-8'); - const importRe = /import\s*\{\s*definition\s+as\s+\w+\s*\}\s*from\s*'([^']+)'/g; - let m; - let count = 0; - while ((m = importRe.exec(src)) !== null) { - const path = m[1]; - const moduleFile = resolve(root, path.replace('~', 'src')); - const modSrc = readFileSync(moduleFile, 'utf-8'); - assert.match( - modSrc, - /export const definition:\s*ModuleDefinition/, - `Module file ${path} must export \`definition: ModuleDefinition\``, - ); - count++; - } - assert.ok(count > 0, 'registry.ts has no `import { definition as … }` entries'); +test('catalog — shared is not a device/display role', () => { + for (const entry of entries) assert.ok(!entry.runtimeRoles.includes('shared'), entry.key); }); diff --git a/tests/unit/runtime.test.ts b/tests/unit/runtime.test.ts index 7df0a0e..b8cb9f4 100644 --- a/tests/unit/runtime.test.ts +++ b/tests/unit/runtime.test.ts @@ -1,9 +1,9 @@ -import { test } from 'node:test'; import assert from 'node:assert/strict'; +import { test } from 'node:test'; -import { moduleSupportsRuntime, type ModuleDefinition } from '../../src/module.ts'; +import { moduleSupportsRuntime, type ModuleManifest } from '../../src/module.ts'; -function definition(runtime?: ModuleDefinition['runtime']): ModuleDefinition { +function manifest(runtime?: ModuleManifest['runtime']): ModuleManifest { return { key: 'test-module', settingsKey: 'module-test-module', @@ -11,26 +11,23 @@ function definition(runtime?: ModuleDefinition['runtime']): ModuleDefinition { title: 'Test Module', subtitle: 'Runtime test module', runtime, - factory: () => { - throw new Error('factory should not be called'); - }, }; } -test('runtime — modules default to desktop only', () => { - const def = definition(); - assert.equal(moduleSupportsRuntime(def, 'desktop', new Set()), true); - assert.equal(moduleSupportsRuntime(def, 'mobile', new Set()), false); +test('runtime — modules default to desktop role', () => { + const item = manifest(); + assert.equal(moduleSupportsRuntime(item, new Set(['desktop']), new Set()), true); + assert.equal(moduleSupportsRuntime(item, new Set(['mobile']), new Set()), false); }); -test('runtime — shared modules support every target', () => { - const def = definition({ targets: ['shared'] }); - assert.equal(moduleSupportsRuntime(def, 'desktop', new Set()), true); - assert.equal(moduleSupportsRuntime(def, 'mobile', new Set()), true); +test('runtime — a manifest supports both roles explicitly', () => { + const item = manifest({ roles: ['desktop', 'mobile'] }); + assert.equal(moduleSupportsRuntime(item, new Set(['desktop']), new Set()), true); + assert.equal(moduleSupportsRuntime(item, new Set(['mobile']), new Set()), true); }); test('runtime — required capabilities must be present', () => { - const def = definition({ targets: ['desktop'], requires: ['backlight'] }); - assert.equal(moduleSupportsRuntime(def, 'desktop', new Set()), false); - assert.equal(moduleSupportsRuntime(def, 'desktop', new Set(['backlight'])), true); + const item = manifest({ roles: ['desktop'], requires: ['backlight'] }); + assert.equal(moduleSupportsRuntime(item, new Set(['desktop']), new Set()), false); + assert.equal(moduleSupportsRuntime(item, new Set(['desktop']), new Set(['backlight'])), true); }); diff --git a/tests/unit/schema.test.ts b/tests/unit/schema.test.ts index f2b70a2..6377b6b 100644 --- a/tests/unit/schema.test.ts +++ b/tests/unit/schema.test.ts @@ -1,131 +1,222 @@ -/** - * Unit tests — GSettings schema XML - * - * Ensures the GSettings schema is internally consistent and contains an entry - * for every module key defined in registry.ts. These are regression tests: - * adding a new module requires touching registry.ts, extension.ts AND the - * schema — these tests will fail fast if any of the three is forgotten. - */ - -import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import { resolve, dirname } from 'node:path'; +import { dirname, resolve } from 'node:path'; +import { test } from 'node:test'; import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; +import { readFileSync } from 'node:fs'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); - -const SCHEMA_FILE = resolve( +const schemaId = 'org.gnome.shell.extensions.aurora-shell'; +const schemaFile = resolve( root, - 'data/schemas/org.gnome.shell.extensions.aurora-shell.gschema.xml' + 'data/schemas/org.gnome.shell.extensions.aurora-shell.gschema.xml', ); -const SCHEMA_ID = 'org.gnome.shell.extensions.aurora-shell'; - -// The canonical list of module settings keys; must stay in sync with registry.ts. -const EXPECTED_MODULE_KEYS = [ - 'module-no-overview', - 'module-pip-on-top', - 'module-theme-changer', - 'module-dock', - 'module-volume-mixer', - 'module-xwayland-indicator', - 'module-icon-weave', - 'module-app-search-tooltip', - 'module-privacy', - 'module-auto-theme-switcher', - 'module-bluetooth-menu', - 'module-weather-clock', - 'module-meeting-clock', - 'module-tray-icons', - 'module-clipboard-history', -] as const; - -const OPT_IN_MODULE_KEYS = new Set(['module-auto-theme-switcher']); - -const schemaXml = readFileSync(SCHEMA_FILE, 'utf-8'); - -test('schema — file is valid XML and contains the correct schema id', () => { - assert.ok(schemaXml.startsWith(' { - for (const key of EXPECTED_MODULE_KEYS) { - assert.ok( - schemaXml.includes(`name="${key}"`), - `Schema is missing key: "${key}"` - ); +function catalogSettingsKeys(): string[] { + const catalogPath = resolve(root, 'src/moduleCatalog.ts'); + const catalog = ts.createSourceFile( + catalogPath, + readFileSync(catalogPath, 'utf8'), + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); + const manifestPaths: string[] = []; + for (const statement of catalog.statements) { + if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) + continue; + const bindings = statement.importClause?.namedBindings; + if ( + bindings && + ts.isNamedImports(bindings) && + bindings.elements.some((element) => element.propertyName?.text === 'manifest') + ) + manifestPaths.push(statement.moduleSpecifier.text.replace(/^~\//, 'src/')); } -}); -test('schema — every module key is boolean type', () => { - const keyRe = / { + const absolute = resolve(root, path); + const source = ts.createSourceFile( + absolute, + readFileSync(absolute, 'utf8'), + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, ); - } -}); + const keys: string[] = []; + let manifestObject: ts.ObjectLiteralExpression | undefined; + for (const statement of source.statements) { + if (!ts.isVariableStatement(statement)) continue; + for (const declaration of statement.declarationList.declarations) { + if ( + declaration.name.getText() === 'manifest' && + ts.isObjectLiteralExpression(declaration.initializer) + ) + manifestObject = declaration.initializer; + } + } + assert.ok(manifestObject, `${path} has no manifest object`); + function visit(node: ts.Node): void { + if ( + ts.isPropertyAssignment(node) && + ['settingsKey', 'hourKey', 'minuteKey'].includes(node.name.getText()) && + ts.isStringLiteral(node.initializer) + ) + keys.push(node.initializer.text); + if ( + ts.isPropertyAssignment(node) && + node.name.getText() === 'key' && + node.parent !== manifestObject && + ts.isStringLiteral(node.initializer) + ) + keys.push(node.initializer.text); + if ( + ts.isPropertyAssignment(node) && + node.name.getText() === 'internalSettings' && + ts.isArrayLiteralExpression(node.initializer) + ) + for (const element of node.initializer.elements) { + assert.ok(ts.isStringLiteral(element), `${path} internal settings must be literals`); + keys.push(element.text); + } + ts.forEachChild(node, visit); + } + visit(manifestObject); + assert.ok(keys.length > 0, `${path} has no literal settings keys`); + return keys; + }); +} -test('schema — every module key defaults to true', () => { - // Grab each block and verify true. - // Opt-in modules (OPT_IN_MODULE_KEYS) are exempt — they default to false because - // they require elevated privileges or explicit user consent to activate. - const blockRe = /]*>[\s\S]*?<\/key>/g; - let match; - while ((match = blockRe.exec(schemaXml)) !== null) { - const block = match[0]; - const keyName = match[1]; - if (OPT_IN_MODULE_KEYS.has(keyName)) continue; - const defaultMatch = block.match(/(.*?)<\/default>/); - assert.ok(defaultMatch, `Key "${keyName}" has no element`); - assert.strictEqual( - defaultMatch![1].trim(), - 'true', - `Key "${keyName}" must default to true` - ); +type XmlElement = { name: string; attributes: Map }; + +function parseXmlElements(xml: string): XmlElement[] { + const elements: XmlElement[] = []; + const stack: string[] = []; + let cursor = 0; + + const skipSpace = (): void => { + while (cursor < xml.length && ' \t\r\n'.includes(xml[cursor]!)) cursor++; + }; + const readName = (): string => { + const start = cursor; + while (cursor < xml.length && !' \t\r\n=/>'.includes(xml[cursor]!)) cursor++; + return xml.slice(start, cursor); + }; + + while ((cursor = xml.indexOf('<', cursor)) >= 0) { + if (xml.startsWith('', cursor + 4); + assert.notEqual(cursor, -1, 'unterminated XML comment'); + cursor += 3; + continue; + } + if (xml[cursor + 1] === '?' || xml[cursor + 1] === '!') { + cursor = xml.indexOf('>', cursor + 2); + assert.notEqual(cursor, -1, 'unterminated XML declaration'); + cursor++; + continue; + } + + cursor++; + if (xml[cursor] === '/') { + cursor++; + const name = readName(); + assert.equal(stack.pop(), name, `mismatched closing XML tag ${name}`); + cursor = xml.indexOf('>', cursor); + assert.notEqual(cursor, -1, `unterminated closing XML tag ${name}`); + cursor++; + continue; + } + + const name = readName(); + assert.ok(name, 'XML element name is required'); + const attributes = new Map(); + let selfClosing = false; + while (cursor < xml.length) { + skipSpace(); + if (xml[cursor] === '>') { + cursor++; + break; + } + if (xml[cursor] === '/' && xml[cursor + 1] === '>') { + selfClosing = true; + cursor += 2; + break; + } + const attributeName = readName(); + assert.ok(attributeName, `invalid attribute in ${name}`); + skipSpace(); + assert.equal(xml[cursor], '=', `attribute ${attributeName} must have a value`); + cursor++; + skipSpace(); + const quote = xml[cursor]; + assert.ok(quote === '"' || quote === "'", `attribute ${attributeName} must be quoted`); + const valueStart = ++cursor; + cursor = xml.indexOf(quote, cursor); + assert.notEqual(cursor, -1, `unterminated attribute ${attributeName}`); + attributes.set(attributeName, xml.slice(valueStart, cursor)); + cursor++; + } + elements.push({ name, attributes }); + if (!selfClosing) stack.push(name); } + + assert.deepEqual(stack, [], 'unclosed XML elements'); + return elements; +} + +function compiledSchemaKeys(): string[] { + const elements = parseXmlElements(readFileSync(schemaFile, 'utf8')); + const schema = elements.find( + (element) => element.name === 'schema' && element.attributes.get('id') === schemaId, + ); + assert.ok(schema, `schema ${schemaId} is missing`); + return elements + .filter((element) => element.name === 'key') + .map((element) => { + const name = element.attributes.get('name'); + assert.ok(name, 'schema key must have a name'); + return name; + }); +} + +function schemaDefault(key: string): string { + const xml = readFileSync(schemaFile, 'utf8'); + const keyMarker = `name="${key}"`; + const keyMarkerIndex = xml.indexOf(keyMarker); + assert.notEqual(keyMarkerIndex, -1, `schema key ${key} is missing`); + + const keyStart = xml.lastIndexOf('', keyMarkerIndex); + assert.ok(keyStart >= 0 && keyEnd > keyStart, `invalid schema key element for ${key}`); + + const defaultStartTag = ''; + const defaultStart = xml.indexOf(defaultStartTag, keyStart); + const defaultEnd = xml.indexOf('', defaultStart); + assert.ok( + defaultStart > keyStart && defaultEnd > defaultStart && defaultEnd < keyEnd, + `schema key ${key} has no default`, + ); + return xml.slice(defaultStart + defaultStartTag.length, defaultEnd).trim(); +} + +test('schema — is structurally valid and contains every catalog module key', () => { + const schemaKeys = new Set(compiledSchemaKeys()); + const moduleKeys = catalogSettingsKeys(); + for (const key of moduleKeys) assert.ok(schemaKeys.has(key), key); + assert.equal(moduleKeys.length, new Set(moduleKeys).size); }); -test('schema — no duplicate key names', () => { - const nameRe = /(); - let match; - while ((match = nameRe.exec(schemaXml)) !== null) { - assert.ok(!seen.has(match[1]), `Duplicate schema key: "${match[1]}"`); - seen.add(match[1]); - } +test('schema — has no module switches absent from the catalog', () => { + const moduleKeys = new Set(catalogSettingsKeys().filter((key) => key.startsWith('module-'))); + const schemaModuleKeys = compiledSchemaKeys().filter((key) => key.startsWith('module-')); + assert.deepEqual([...schemaModuleKeys].sort(), [...moduleKeys].sort()); }); -test('schema — every key has a non-empty summary', () => { - const blockRe = /]*>[\s\S]*?<\/key>/g; - let match; - while ((match = blockRe.exec(schemaXml)) !== null) { - const block = match[0]; - const keyName = match[1]; - const summaryMatch = block.match(/(.*?)<\/summary>/); - assert.ok(summaryMatch, `Key "${keyName}" is missing a `); - assert.ok(summaryMatch![1].trim().length > 0, `Key "${keyName}" has an empty `); - } +test('schema ↔ catalog — every declared module and option setting is synchronized', () => { + assert.deepEqual([...compiledSchemaKeys()].sort(), [...catalogSettingsKeys()].sort()); }); -test('schema — auto-theme-switcher option keys exist with correct types and defaults', () => { - const optionKeys: Array<{ name: string; type: string; defaultValue: string }> = [ - { name: 'auto-theme-switcher-light-hours', type: 'i', defaultValue: '6' }, - { name: 'auto-theme-switcher-light-minutes', type: 'i', defaultValue: '0' }, - { name: 'auto-theme-switcher-dark-hours', type: 'i', defaultValue: '20' }, - { name: 'auto-theme-switcher-dark-minutes', type: 'i', defaultValue: '0' }, - ]; - - for (const { name, type, defaultValue } of optionKeys) { - assert.ok(schemaXml.includes(`name="${name}"`), `Schema missing key: "${name}"`); - const blockRe = new RegExp(`]*>[\\s\\S]*?<\\/key>`); - const block = schemaXml.match(blockRe); - assert.ok(block, `Could not extract block for key "${name}"`); - assert.ok(block![0].includes(`type="${type}"`), `Key "${name}" must be type "${type}"`); - assert.ok(block![0].includes(`${defaultValue}`), `Key "${name}" must default to ${defaultValue}`); - } +test('schema — Vela Shell fallback is disabled by default', () => { + assert.equal(schemaDefault('vela-vpn-quick-settings-shell-fallback'), 'false'); }); From b1461413250027458659f82732b163a68b41d533 Mon Sep 17 00:00:00 2001 From: Leandro Rodrigues Date: Sun, 12 Jul 2026 09:27:00 -0300 Subject: [PATCH 2/6] feat: refactor dev tool UI components and improve modularity - Introduced new utility functions in devToolUi.ts for creating UI components such as action buttons, summary panels, and action rows. - Updated DockDevTool, GeneralDevTool, MeetingClockDevTool, TrayIconsDevTool, WeatherClockDevTool to utilize the new UI component functions, reducing code duplication and enhancing maintainability. - Refactored device detection logic in device.ts for improved clarity and encapsulation of functionality. - Added unit tests for app identity handling in appIdentity.test.ts to ensure correct behavior of app ID candidate generation. --- src/clipboard/clipboardItem.ts | 92 ++++---- src/clipboard/clipboardMonitor.ts | 16 +- src/clipboard/clipboardStore.ts | 80 +++---- src/desktop/trayIcons/appIdentity.ts | 19 ++ src/desktop/trayIcons/sniHost.ts | 14 +- src/desktop/trayIcons/trayIcons.ts | 31 +-- src/dev/clipboardHistoryDevTool.ts | 92 ++------ src/dev/devToolUi.ts | 68 ++++++ src/dev/dockDevTool.ts | 90 ++------ src/dev/generalDevTool.ts | 64 +----- src/dev/meetingClockDevTool.ts | 92 ++------ src/dev/trayIconsDevTool.ts | 83 ++----- src/dev/weatherClockDevTool.ts | 90 ++------ src/device/device.ts | 269 ++++++++++++----------- src/panel/auroraMenu.ts | 113 +++++----- src/panel/bluetoothMenu/bluetoothMenu.ts | 6 +- tests/unit/appIdentity.test.ts | 25 +++ 17 files changed, 538 insertions(+), 706 deletions(-) create mode 100644 src/desktop/trayIcons/appIdentity.ts create mode 100644 src/dev/devToolUi.ts create mode 100644 tests/unit/appIdentity.test.ts diff --git a/src/clipboard/clipboardItem.ts b/src/clipboard/clipboardItem.ts index 7e10e65..c7f13b7 100644 --- a/src/clipboard/clipboardItem.ts +++ b/src/clipboard/clipboardItem.ts @@ -176,10 +176,10 @@ export class ClipboardItem extends St.Button { if (entry.kind === 'image') { this._initImageCard(entry); } else { - const url = _parseUrl(entry.text); + const url = this._parseUrl(entry.text); if (url) { this._initLinkCard(entry.text.trim(), url); - } else if (_isCode(entry.text)) { + } else if (this._isCode(entry.text)) { this._initCodeCard(entry); } else { this._initTextCard(entry); @@ -324,7 +324,7 @@ export class ClipboardItem extends St.Button { } if (meta.description && this._linkDescription) { - this._linkDescription.text = _truncate(meta.description, MAX_DESCRIPTION_CHARS); + this._linkDescription.text = this._truncate(meta.description, MAX_DESCRIPTION_CHARS); this._linkDescription.visible = true; } @@ -415,7 +415,7 @@ export class ClipboardItem extends St.Button { }); const label = new St.Label({ - text: _truncate(entry.text.replace(/\s+/g, ' ').trim(), MAX_LABEL_CHARS), + text: this._truncate(entry.text.replace(/\s+/g, ' ').trim(), MAX_LABEL_CHARS), style_class: 'aurora-clipboard-item-label', x_expand: true, x_align: Clutter.ActorAlign.FILL, @@ -485,52 +485,52 @@ export class ClipboardItem extends St.Button { this._menu.destroy(); this._menu = null; } -} -function _parseUrl(text: string): { host: string; path: string } | null { - const trimmed = text.trim(); - if (trimmed.includes('\n') || trimmed.includes(' ') || trimmed.length > 2048) return null; - if (!trimmed.startsWith('http://') && !trimmed.startsWith('https://')) return null; - - try { - const withoutScheme = trimmed.replace(/^https?:\/\//, ''); - const slashIdx = withoutScheme.indexOf('/'); - const host = slashIdx === -1 ? withoutScheme : withoutScheme.slice(0, slashIdx); - const rawPath = slashIdx === -1 ? '' : withoutScheme.slice(slashIdx); - const path = rawPath.split('?')[0]!; - - if (!host || !host.includes('.')) return null; - return { host, path }; - } catch { - return null; + private _parseUrl(text: string): { host: string; path: string } | null { + const trimmed = text.trim(); + if (trimmed.includes('\n') || trimmed.includes(' ') || trimmed.length > 2048) return null; + if (!trimmed.startsWith('http://') && !trimmed.startsWith('https://')) return null; + + try { + const withoutScheme = trimmed.replace(/^https?:\/\//, ''); + const slashIdx = withoutScheme.indexOf('/'); + const host = slashIdx === -1 ? withoutScheme : withoutScheme.slice(0, slashIdx); + const rawPath = slashIdx === -1 ? '' : withoutScheme.slice(slashIdx); + const path = rawPath.split('?')[0]!; + + if (!host || !host.includes('.')) return null; + return { host, path }; + } catch { + return null; + } } -} -function _isCode(text: string): boolean { - const lines = text.split('\n'); - if (lines.length < 2) return false; - - let score = 0; - for (const line of lines) { - const trimmed = line.trim(); - if (/^\s{2,}/.test(line)) score++; - if (/[{};]\s*$/.test(line)) score++; - if (/\\$/.test(trimmed)) score++; - if (/^\s*(\/\/|#|\/\*|\*)/.test(line)) score++; - if (/^(curl|wget|git|npm|yarn|pnpm|just|docker|kubectl|ssh|sudo)\b/.test(trimmed)) score += 2; - if (/^-[A-Za-z]/.test(trimmed)) score++; - if (/^(https?:\/\/|\/[\w.-]+|\w+=)/.test(trimmed)) score++; - if ( - /^\s*(function|class|def|import|export|const|let|var|return|if|else|for|while|try|catch|async|await|public|private|protected)\b/.test( - line, + private _isCode(text: string): boolean { + const lines = text.split('\n'); + if (lines.length < 2) return false; + + let score = 0; + for (const line of lines) { + const trimmed = line.trim(); + if (/^\s{2,}/.test(line)) score++; + if (/[{};]\s*$/.test(line)) score++; + if (/\\$/.test(trimmed)) score++; + if (/^\s*(\/\/|#|\/\*|\*)/.test(line)) score++; + if (/^(curl|wget|git|npm|yarn|pnpm|just|docker|kubectl|ssh|sudo)\b/.test(trimmed)) score += 2; + if (/^-[A-Za-z]/.test(trimmed)) score++; + if (/^(https?:\/\/|\/[\w.-]+|\w+=)/.test(trimmed)) score++; + if ( + /^\s*(function|class|def|import|export|const|let|var|return|if|else|for|while|try|catch|async|await|public|private|protected)\b/.test( + line, + ) ) - ) - score += 2; - } + score += 2; + } - return score >= 3; -} + return score >= 3; + } -function _truncate(text: string, maxChars: number): string { - return text.length > maxChars ? text.slice(0, maxChars) + '…' : text; + private _truncate(text: string, maxChars: number): string { + return text.length > maxChars ? text.slice(0, maxChars) + '…' : text; + } } diff --git a/src/clipboard/clipboardMonitor.ts b/src/clipboard/clipboardMonitor.ts index e05214d..c46bd2e 100644 --- a/src/clipboard/clipboardMonitor.ts +++ b/src/clipboard/clipboardMonitor.ts @@ -57,7 +57,9 @@ export class ClipboardMonitor { private _tick(): void { const clipboard = St.Clipboard.get_default(); - const imageMimeType = _findImageMimeType(clipboard.get_mimetypes(St.ClipboardType.CLIPBOARD)); + const imageMimeType = this._findImageMimeType( + clipboard.get_mimetypes(St.ClipboardType.CLIPBOARD), + ); if (imageMimeType) { clipboard.get_content(St.ClipboardType.CLIPBOARD, imageMimeType, (_clipboard, bytes) => { @@ -86,14 +88,14 @@ export class ClipboardMonitor { }, ); } -} -function _findImageMimeType(mimeTypes: string[]): string | null { - for (const preferred of IMAGE_MIME_TYPES) { - if (mimeTypes.includes(preferred)) return preferred; - } + private _findImageMimeType(mimeTypes: string[]): string | null { + for (const preferred of IMAGE_MIME_TYPES) { + if (mimeTypes.includes(preferred)) return preferred; + } - return mimeTypes.find((mimeType) => mimeType.startsWith('image/')) ?? null; + return mimeTypes.find((mimeType) => mimeType.startsWith('image/')) ?? null; + } } export function fingerprintBytes(bytes: GLib.Bytes): string { diff --git a/src/clipboard/clipboardStore.ts b/src/clipboard/clipboardStore.ts index 571c68f..7b6020b 100644 --- a/src/clipboard/clipboardStore.ts +++ b/src/clipboard/clipboardStore.ts @@ -134,7 +134,7 @@ export class ClipboardStore { if (payload.bytes.get_size() === 0) return false; try { - _validateImageBytes(payload.mimeType, payload.bytes); + this._validateImageBytes(payload.mimeType, payload.bytes); } catch (e) { logger.warn( `Rejected invalid clipboard image: ${payload.mimeType}, ${payload.bytes.get_size()} bytes`, @@ -155,7 +155,7 @@ export class ClipboardStore { } const id = String(this._nextId++); - const filePath = this._mediaDir + '/' + id + _extensionForMimeType(payload.mimeType); + const filePath = this._mediaDir + '/' + id + this._extensionForMimeType(payload.mimeType); await this._writeImage(filePath, payload.bytes); const entry: ClipboardEntry = { @@ -238,13 +238,13 @@ export class ClipboardStore { filterPinned(query: string): ClipboardEntry[] { if (!query) return this._pinned; const q = query.toLowerCase(); - return this._pinned.filter((e) => _searchText(e).includes(q)); + return this._pinned.filter((e) => this._searchText(e).includes(q)); } filterHistory(query: string): ClipboardEntry[] { if (!query) return this._history; const q = query.toLowerCase(); - return this._history.filter((e) => _searchText(e).includes(q)); + return this._history.filter((e) => this._searchText(e).includes(q)); } private _moveToFront(entry: ClipboardEntry): void { @@ -279,7 +279,7 @@ export class ClipboardStore { if (entry.kind !== 'image') return true; try { - _validateImageFile(entry); + this._validateImageFile(entry); return true; } catch (e) { removed++; @@ -408,50 +408,50 @@ export class ClipboardStore { // Runtime files are session-scoped; deletion here is best effort. } } -} -function _extensionForMimeType(mimeType: string): string { - if (mimeType === 'image/jpeg' || mimeType === 'image/jpg') return '.jpg'; - if (mimeType === 'image/webp') return '.webp'; - if (mimeType === 'image/gif') return '.gif'; - if (mimeType === 'image/bmp') return '.bmp'; - if (mimeType === 'image/tiff') return '.tiff'; - return '.png'; -} + private _extensionForMimeType(mimeType: string): string { + if (mimeType === 'image/jpeg' || mimeType === 'image/jpg') return '.jpg'; + if (mimeType === 'image/webp') return '.webp'; + if (mimeType === 'image/gif') return '.gif'; + if (mimeType === 'image/bmp') return '.bmp'; + if (mimeType === 'image/tiff') return '.tiff'; + return '.png'; + } -function _validateImageBytes(mimeType: string, bytes: GLib.Bytes): void { - let loader: GdkPixbuf.PixbufLoader | null = null; + private _validateImageBytes(mimeType: string, bytes: GLib.Bytes): void { + let loader: GdkPixbuf.PixbufLoader | null = null; - try { - loader = GdkPixbuf.PixbufLoader.new_with_mime_type(mimeType); - loader.write_bytes(bytes); - loader.close(); + try { + loader = GdkPixbuf.PixbufLoader.new_with_mime_type(mimeType); + loader.write_bytes(bytes); + loader.close(); - if (!loader.get_pixbuf() && !loader.get_animation()) { - throw new Error('Image decoder did not produce a pixbuf or animation'); - } - } catch (e) { - if (loader) { - try { - loader.close(); - } catch (_closeError) { - // The original decoder error is the useful one. + if (!loader.get_pixbuf() && !loader.get_animation()) { + throw new Error('Image decoder did not produce a pixbuf or animation'); } + } catch (e) { + if (loader) { + try { + loader.close(); + } catch (_closeError) { + // The original decoder error is the useful one. + } + } + throw e; } - throw e; } -} -function _validateImageFile(entry: ClipboardEntry): void { - if (!entry.filePath) throw new Error('Image entry has no file path'); + private _validateImageFile(entry: ClipboardEntry): void { + if (!entry.filePath) throw new Error('Image entry has no file path'); - const file = Gio.File.new_for_path(entry.filePath); - if (!file.query_exists(null)) throw new Error('Image file is missing'); + const file = Gio.File.new_for_path(entry.filePath); + if (!file.query_exists(null)) throw new Error('Image file is missing'); - GdkPixbuf.Pixbuf.new_from_file(entry.filePath); -} + GdkPixbuf.Pixbuf.new_from_file(entry.filePath); + } -function _searchText(entry: ClipboardEntry): string { - if (entry.kind === 'image') return 'image imagem picture photo foto'; - return entry.text.toLowerCase(); + private _searchText(entry: ClipboardEntry): string { + if (entry.kind === 'image') return 'image imagem picture photo foto'; + return entry.text.toLowerCase(); + } } diff --git a/src/desktop/trayIcons/appIdentity.ts b/src/desktop/trayIcons/appIdentity.ts new file mode 100644 index 0000000..d1ca7fc --- /dev/null +++ b/src/desktop/trayIcons/appIdentity.ts @@ -0,0 +1,19 @@ +/** + * Normalizes desktop application identifiers for matching Shell apps, SNI + * entries and background-app records. Both suffixed and unsuffixed forms are + * retained because GNOME APIs do not consistently use the `.desktop` suffix. + */ +export function appIdCandidates(appIds: readonly string[]): Set { + const candidates = new Set(); + + for (const appId of appIds) { + let candidate = appId.toLowerCase(); + while (candidate) { + candidates.add(candidate); + if (!candidate.endsWith('.desktop')) break; + candidate = candidate.slice(0, -'.desktop'.length); + } + } + + return candidates; +} diff --git a/src/desktop/trayIcons/sniHost.ts b/src/desktop/trayIcons/sniHost.ts index 49781fb..28f76a2 100644 --- a/src/desktop/trayIcons/sniHost.ts +++ b/src/desktop/trayIcons/sniHost.ts @@ -7,6 +7,7 @@ import St from '@girs/st-18'; import type { TrayItem, TrayItemStatus } from './trayState.ts'; import type { SniWatcher } from './sniWatcher.ts'; +import { appIdCandidates } from './appIdentity.ts'; import { logger } from '~/core/logger.ts'; const SNI_ITEM_XML = ` @@ -543,7 +544,7 @@ export class SniHost { // Used when the app doesn't own a D-Bus well-known name matching its app ID // (common for Flatpak apps that register SNI under a unique bus name). hasSniForAppId(appId: string): boolean { - const appIds = this._appIdCandidates(appId); + const appIds = appIdCandidates([appId]); const appComponents = new Set( [...appIds] .map((candidate) => candidate.split('.').at(-1) ?? candidate) @@ -576,17 +577,6 @@ export class SniHost { return false; } - private _appIdCandidates(appId: string): Set { - const candidates = new Set(); - let candidate = appId.toLowerCase(); - while (candidate) { - candidates.add(candidate); - if (!candidate.endsWith('.desktop')) break; - candidate = candidate.slice(0, -'.desktop'.length); - } - return candidates; - } - private _isSpecificAppComponent(component: string): boolean { return component.length >= 4 && !GENERIC_APP_ID_COMPONENTS.has(component); } diff --git a/src/desktop/trayIcons/trayIcons.ts b/src/desktop/trayIcons/trayIcons.ts index 35c3836..c640086 100644 --- a/src/desktop/trayIcons/trayIcons.ts +++ b/src/desktop/trayIcons/trayIcons.ts @@ -17,7 +17,9 @@ import type { CleanupBag } from '~/core/cleanupBag.ts'; import { logger } from '~/core/logger.ts'; import { Module } from '~/module.ts'; import type { SettingsManager } from '~/core/settings.ts'; +import { getQuickSettingsGrid } from '~/shared/quickSettings.ts'; +import { appIdCandidates } from './appIdentity.ts'; import { TrayContainer } from './trayContainer.ts'; import { BackgroundAppsSource } from './backgroundAppsSource.ts'; import { SniWatcher } from './sniWatcher.ts'; @@ -225,8 +227,8 @@ export class TrayIcons extends Module { if (covered) return true; } - const appIdCandidates = this._appIdCandidates(appId, app); - for (const candidate of appIdCandidates) { + const candidates = appIdCandidates([appId, app.get_id()]); + for (const candidate of candidates) { if (candidate === appId) continue; const candidateOwner = await this._getUniqueName(candidate); if (!candidateOwner) continue; @@ -244,7 +246,7 @@ export class TrayIcons extends Module { if (coveredByPid) return true; const coveredByMetadata = - [...appIdCandidates].some((candidate) => this._sniHost?.hasSniForAppId(candidate)) ?? false; + [...candidates].some((candidate) => this._sniHost?.hasSniForAppId(candidate)) ?? false; logger.debug(`SNI covers ${appId}? owner=none, metadata-match=${coveredByMetadata}`, { prefix: LOG_PREFIX, }); @@ -252,7 +254,7 @@ export class TrayIcons extends Module { } private async _sniCoversAppPid(appId: string, app: Shell.App): Promise { - const appIdCandidates = this._appIdCandidates(appId, app); + const candidates = appIdCandidates([appId, app.get_id()]); const appPids = app.get_pids?.() ?? []; if (appPids.length === 0) { logger.debug(`SNI covers ${appId}? pid-match=false app-pids=[]`, { prefix: LOG_PREFIX }); @@ -267,7 +269,7 @@ export class TrayIcons extends Module { const ancestorMatch = directMatch ? true : await this._pidHasAncestor(sniPid, appPidSet); const trackerMatch = this._trackedPidMatchesApp(sniPid, appId, app); const flatpakAppId = await this._getFlatpakAppId(sniPid); - const flatpakMatch = flatpakAppId ? appIdCandidates.has(flatpakAppId.toLowerCase()) : false; + const flatpakMatch = flatpakAppId ? candidates.has(flatpakAppId.toLowerCase()) : false; const covered = ancestorMatch || trackerMatch || flatpakMatch; logger.debug( `SNI covers ${appId}? sni-bus=${busName} sni-pid=${sniPid} flatpak=${flatpakAppId ?? 'none'} app-pids=[${appPids.join(', ')}] pid-match=${covered}`, @@ -301,7 +303,7 @@ export class TrayIcons extends Module { const trackedApp = Shell.WindowTracker.get_default().get_app_from_pid(pid); if (!trackedApp) return false; const trackedId = trackedApp.get_id(); - return this._appIdCandidates(appId, app).has(trackedId.toLowerCase()); + return appIdCandidates([appId, app.get_id()]).has(trackedId.toLowerCase()); } catch { return false; } @@ -330,19 +332,6 @@ export class TrayIcons extends Module { return Number.parseInt(match[1]!, 10); } - private _appIdCandidates(appId: string, app: Shell.App): Set { - const candidates = new Set(); - for (const rawCandidate of [appId, app.get_id()]) { - let candidate = rawCandidate.toLowerCase(); - while (candidate) { - candidates.add(candidate); - if (!candidate.endsWith('.desktop')) break; - candidate = candidate.slice(0, -'.desktop'.length); - } - } - return candidates; - } - private async _removeBgItemsCoveredBySni(): Promise { logger.debug(`Dedup: bg items: [${[...this._bgItemAppIds.keys()].join(', ')}]`, { prefix: LOG_PREFIX, @@ -406,7 +395,7 @@ export class TrayIcons extends Module { ); if (backgroundAppsItem) return backgroundAppsItem; - return this._findBgAppsQuickSettingsToggleInActor(quickSettings?.menu?._grid); + return this._findBgAppsQuickSettingsToggleInActor(getQuickSettingsGrid()); } private _findBgAppsQuickSettingsToggleInActor(actor: any): any { @@ -426,7 +415,7 @@ export class TrayIcons extends Module { } private _watchBgAppsQuickSettingsGrid(): void { - const grid = (Main.panel.statusArea.quickSettings as any)?.menu?._grid; + const grid = getQuickSettingsGrid(); if (!grid) return; if (this._bgAppsGrid === grid && this._bgAppsGridChildAddedId) return; diff --git a/src/dev/clipboardHistoryDevTool.ts b/src/dev/clipboardHistoryDevTool.ts index acfa2d9..38b440a 100644 --- a/src/dev/clipboardHistoryDevTool.ts +++ b/src/dev/clipboardHistoryDevTool.ts @@ -1,10 +1,16 @@ import '@girs/gjs'; import GLib from '@girs/glib-2.0'; -import St from '@girs/st-18'; +import type St from '@girs/st-18'; import { ClipboardHistory } from '~/clipboard/clipboardHistory.ts'; import { fingerprintBytes } from '~/clipboard/clipboardMonitor.ts'; +import { + createDevToolActionButton, + createDevToolActionRow, + createDevToolModulePanel, + createDevToolSummary, +} from '~/dev/devToolUi.ts'; import type { Module } from '~/module.ts'; const RANDOM_MESSAGES = [ @@ -38,35 +44,17 @@ export class ClipboardHistoryDevTool { buildPanel(): St.Widget { const clipboard = this._getClipboardHistory(); - const panel = new St.BoxLayout({ - vertical: true, - style_class: 'aurora-devtool-module-panel', - }); - - const summary = new St.BoxLayout({ - style_class: 'aurora-devtool-summary', - }); - summary.add_child( - new St.Icon({ - icon_name: this.iconName, - icon_size: 18, - style_class: 'aurora-devtool-summary-icon', - }), - ); - summary.add_child( - new St.Label({ - text: clipboard ? `${clipboard.entryCount} history entries` : 'Clipboard History disabled', - style_class: 'aurora-devtool-summary-label', - x_expand: true, - }), + const panel = createDevToolModulePanel(); + panel.add_child( + createDevToolSummary( + this.iconName, + clipboard ? `${clipboard.entryCount} history entries` : 'Clipboard History disabled', + ), ); - panel.add_child(summary); - const primaryRow = new St.BoxLayout({ - style_class: 'aurora-devtool-action-row', - }); + const primaryRow = createDevToolActionRow(); primaryRow.add_child( - this._createActionButton( + createDevToolActionButton( 'document-open-symbolic', 'Open Panel', () => this.openPanel(), @@ -74,7 +62,7 @@ export class ClipboardHistoryDevTool { ), ); primaryRow.add_child( - this._createActionButton( + createDevToolActionButton( 'list-add-symbolic', 'Add Message', () => this.addRandomMessage(), @@ -83,11 +71,9 @@ export class ClipboardHistoryDevTool { ); panel.add_child(primaryRow); - const secondaryRow = new St.BoxLayout({ - style_class: 'aurora-devtool-action-row', - }); + const secondaryRow = createDevToolActionRow(); secondaryRow.add_child( - this._createActionButton( + createDevToolActionButton( 'format-justify-fill-symbolic', 'Add 5 Messages', () => this.addRandomMessages(5), @@ -95,7 +81,7 @@ export class ClipboardHistoryDevTool { ), ); secondaryRow.add_child( - this._createActionButton( + createDevToolActionButton( 'user-trash-symbolic', 'Clear History', () => this.clearHistory(), @@ -104,11 +90,9 @@ export class ClipboardHistoryDevTool { ); panel.add_child(secondaryRow); - const sampleRow = new St.BoxLayout({ - style_class: 'aurora-devtool-action-row', - }); + const sampleRow = createDevToolActionRow(); sampleRow.add_child( - this._createActionButton( + createDevToolActionButton( 'image-x-generic-symbolic', 'Add Image', () => void this.addSampleImage(), @@ -116,7 +100,7 @@ export class ClipboardHistoryDevTool { ), ); sampleRow.add_child( - this._createActionButton( + createDevToolActionButton( 'insert-link-symbolic', 'Add Link', () => this.addSampleLink(), @@ -124,7 +108,7 @@ export class ClipboardHistoryDevTool { ), ); sampleRow.add_child( - this._createActionButton( + createDevToolActionButton( 'accessories-text-editor-symbolic', 'Add Code', () => this.addSampleCode(), @@ -219,34 +203,4 @@ export class ClipboardHistoryDevTool { const base = RANDOM_MESSAGES[Math.floor(Math.random() * RANDOM_MESSAGES.length)]!; return `${base} #${Date.now()}-${Math.floor(Math.random() * 1000)}`; } - - private _createActionButton( - iconName: string, - label: string, - onClick: () => void, - disabled = false, - ): St.Button { - const content = new St.BoxLayout({ - style_class: 'aurora-devtool-action-content', - }); - content.add_child( - new St.Icon({ - icon_name: iconName, - icon_size: 16, - }), - ); - content.add_child(new St.Label({ text: label })); - - const button = new St.Button({ - child: content, - style_class: 'button aurora-devtool-action-button', - can_focus: !disabled, - reactive: !disabled, - x_expand: true, - accessible_name: label, - }); - if (disabled) button.opacity = 120; - button.connect('clicked', onClick); - return button; - } } diff --git a/src/dev/devToolUi.ts b/src/dev/devToolUi.ts new file mode 100644 index 0000000..b7e96f5 --- /dev/null +++ b/src/dev/devToolUi.ts @@ -0,0 +1,68 @@ +import St from '@girs/st-18'; + +export function createDevToolModulePanel(): St.BoxLayout { + return new St.BoxLayout({ + vertical: true, + style_class: 'aurora-devtool-module-panel', + }); +} + +export function createDevToolSummary(iconName: string, text: string): St.BoxLayout { + const summary = new St.BoxLayout({ + style_class: 'aurora-devtool-summary', + }); + summary.add_child( + new St.Icon({ + icon_name: iconName, + icon_size: 18, + style_class: 'aurora-devtool-summary-icon', + }), + ); + summary.add_child( + new St.Label({ + text, + style_class: 'aurora-devtool-summary-label', + x_expand: true, + }), + ); + return summary; +} + +export function createDevToolActionRow(): St.BoxLayout { + return new St.BoxLayout({ + style_class: 'aurora-devtool-action-row', + }); +} + +export function createDevToolActionButton( + iconName: string, + label: string, + onClick: () => void, + disabled = false, + active = false, +): St.Button { + const content = new St.BoxLayout({ + style_class: 'aurora-devtool-action-content', + }); + content.add_child( + new St.Icon({ + icon_name: iconName, + icon_size: 16, + }), + ); + content.add_child(new St.Label({ text: label })); + + const button = new St.Button({ + child: content, + style_class: active + ? 'button aurora-devtool-action-button active' + : 'button aurora-devtool-action-button', + can_focus: !disabled, + reactive: !disabled, + x_expand: true, + accessible_name: label, + }); + if (disabled) button.opacity = 120; + button.connect('clicked', onClick); + return button; +} diff --git a/src/dev/dockDevTool.ts b/src/dev/dockDevTool.ts index 78ff0c0..12335f3 100644 --- a/src/dev/dockDevTool.ts +++ b/src/dev/dockDevTool.ts @@ -6,6 +6,12 @@ import Clutter from '@girs/clutter-18'; import type { Module } from '~/module.ts'; import { Dock, type ManagedDockBinding } from '~/dock/dock.ts'; import { OverlapStatus } from '~/dock/intellihide.ts'; +import { + createDevToolActionButton, + createDevToolActionRow, + createDevToolModulePanel, + createDevToolSummary, +} from '~/dev/devToolUi.ts'; export class DockDevTool { readonly key = 'dock'; @@ -19,52 +25,32 @@ export class DockDevTool { buildPanel(): St.Widget { const dock = this._getDock(); - const panel = new St.BoxLayout({ - vertical: true, - style_class: 'aurora-devtool-module-panel', - }); - - const summary = new St.BoxLayout({ - style_class: 'aurora-devtool-summary', - }); - summary.add_child( - new St.Icon({ - icon_name: this.iconName, - icon_size: 18, - style_class: 'aurora-devtool-summary-icon', - }), - ); - summary.add_child( - new St.Label({ - text: dock + const panel = createDevToolModulePanel(); + panel.add_child( + createDevToolSummary( + this.iconName, + dock ? `Bindings: ${dock.bindings.length} · Always-show: ${dock.alwaysShow ? 'on' : 'off'}` : 'Dock disabled', - style_class: 'aurora-devtool-summary-label', - x_expand: true, - }), + ), ); - panel.add_child(summary); if (dock) { for (const binding of dock.bindings) panel.add_child(this._buildMonitorPanel(binding)); } - const firstRow = new St.BoxLayout({ - style_class: 'aurora-devtool-action-row', - }); + const firstRow = createDevToolActionRow(); firstRow.add_child( - this._createActionButton('go-up-symbolic', 'Reveal All', () => this.revealAll(), !dock), + createDevToolActionButton('go-up-symbolic', 'Reveal All', () => this.revealAll(), !dock), ); firstRow.add_child( - this._createActionButton('go-down-symbolic', 'Hide All', () => this.hideAll(), !dock), + createDevToolActionButton('go-down-symbolic', 'Hide All', () => this.hideAll(), !dock), ); panel.add_child(firstRow); - const secondRow = new St.BoxLayout({ - style_class: 'aurora-devtool-action-row', - }); + const secondRow = createDevToolActionRow(); secondRow.add_child( - this._createActionButton( + createDevToolActionButton( 'input-touchpad-symbolic', 'Hot Area', () => this.triggerHotArea(), @@ -72,7 +58,7 @@ export class DockDevTool { ), ); secondRow.add_child( - this._createActionButton( + createDevToolActionButton( 'view-pin-symbolic', `Always Show: ${dock?.alwaysShow ? 'On' : 'Off'}`, () => this.toggleAlwaysShow(), @@ -150,21 +136,19 @@ export class DockDevTool { }), ); - const actions = new St.BoxLayout({ - style_class: 'aurora-devtool-action-row', - }); + const actions = createDevToolActionRow(); actions.add_child( - this._createActionButton('go-up-symbolic', 'Show', () => + createDevToolActionButton('go-up-symbolic', 'Show', () => this.showMonitor(binding.monitorIndex), ), ); actions.add_child( - this._createActionButton('go-down-symbolic', 'Hide', () => + createDevToolActionButton('go-down-symbolic', 'Hide', () => this.hideMonitor(binding.monitorIndex), ), ); actions.add_child( - this._createActionButton( + createDevToolActionButton( 'input-touchpad-symbolic', 'Hot Area', () => this.triggerMonitorHotArea(binding.monitorIndex), @@ -193,34 +177,4 @@ export class DockDevTool { const module = this._getModule('dock'); return module instanceof Dock ? module : null; } - - private _createActionButton( - iconName: string, - label: string, - onClick: () => void, - disabled = false, - ): St.Button { - const content = new St.BoxLayout({ - style_class: 'aurora-devtool-action-content', - }); - content.add_child( - new St.Icon({ - icon_name: iconName, - icon_size: 16, - }), - ); - content.add_child(new St.Label({ text: label })); - - const button = new St.Button({ - child: content, - style_class: 'button aurora-devtool-action-button', - can_focus: !disabled, - reactive: !disabled, - x_expand: true, - accessible_name: label, - }); - if (disabled) button.opacity = 120; - button.connect('clicked', onClick); - return button; - } } diff --git a/src/dev/generalDevTool.ts b/src/dev/generalDevTool.ts index 4c3b35a..8a10423 100644 --- a/src/dev/generalDevTool.ts +++ b/src/dev/generalDevTool.ts @@ -1,6 +1,13 @@ import '@girs/gjs'; -import St from '@girs/st-18'; +import type St from '@girs/st-18'; + +import { + createDevToolActionButton, + createDevToolActionRow, + createDevToolModulePanel, + createDevToolSummary, +} from '~/dev/devToolUi.ts'; export class GeneralDevTool { readonly key = 'general'; @@ -10,35 +17,12 @@ export class GeneralDevTool { constructor(private readonly _openPreferences: () => void) {} buildPanel(): St.Widget { - const panel = new St.BoxLayout({ - vertical: true, - style_class: 'aurora-devtool-module-panel', - }); - - const summary = new St.BoxLayout({ - style_class: 'aurora-devtool-summary', - }); - summary.add_child( - new St.Icon({ - icon_name: this.iconName, - icon_size: 18, - style_class: 'aurora-devtool-summary-icon', - }), - ); - summary.add_child( - new St.Label({ - text: 'Extension tools', - style_class: 'aurora-devtool-summary-label', - x_expand: true, - }), - ); - panel.add_child(summary); + const panel = createDevToolModulePanel(); + panel.add_child(createDevToolSummary(this.iconName, 'Extension tools')); - const row = new St.BoxLayout({ - style_class: 'aurora-devtool-action-row', - }); + const row = createDevToolActionRow(); row.add_child( - this._createActionButton('emblem-system-symbolic', 'Open Settings', () => + createDevToolActionButton('emblem-system-symbolic', 'Open Settings', () => this.openPreferences(), ), ); @@ -52,28 +36,4 @@ export class GeneralDevTool { openPreferences(): void { this._openPreferences(); } - - private _createActionButton(iconName: string, label: string, onClick: () => void): St.Button { - const content = new St.BoxLayout({ - style_class: 'aurora-devtool-action-content', - }); - content.add_child( - new St.Icon({ - icon_name: iconName, - icon_size: 16, - }), - ); - content.add_child(new St.Label({ text: label })); - - const button = new St.Button({ - child: content, - style_class: 'button aurora-devtool-action-button', - can_focus: true, - reactive: true, - x_expand: true, - accessible_name: label, - }); - button.connect('clicked', onClick); - return button; - } } diff --git a/src/dev/meetingClockDevTool.ts b/src/dev/meetingClockDevTool.ts index 329283d..d993762 100644 --- a/src/dev/meetingClockDevTool.ts +++ b/src/dev/meetingClockDevTool.ts @@ -1,7 +1,13 @@ import '@girs/gjs'; -import St from '@girs/st-18'; - +import type St from '@girs/st-18'; + +import { + createDevToolActionButton, + createDevToolActionRow, + createDevToolModulePanel, + createDevToolSummary, +} from '~/dev/devToolUi.ts'; import type { Module } from '~/module.ts'; import { MeetingClock } from '~/panel/clock/meetingClock/meetingClock.ts'; import type { MeetingEvent } from '~/panel/clock/meetingClock/meetingClockLogic.ts'; @@ -23,37 +29,19 @@ export class MeetingClockDevTool { buildPanel(): St.Widget { const meetingClock = this._getMeetingClock(); - const panel = new St.BoxLayout({ - vertical: true, - style_class: 'aurora-devtool-module-panel', - }); - - const summary = new St.BoxLayout({ - style_class: 'aurora-devtool-summary', - }); - summary.add_child( - new St.Icon({ - icon_name: this.iconName, - icon_size: 18, - style_class: 'aurora-devtool-summary-icon', - }), - ); - summary.add_child( - new St.Label({ - text: meetingClock + const panel = createDevToolModulePanel(); + panel.add_child( + createDevToolSummary( + this.iconName, + meetingClock ? `${this._events.length} fake meetings, ${meetingClock.eventCount} visible` : 'Meeting Clock disabled', - style_class: 'aurora-devtool-summary-label', - x_expand: true, - }), + ), ); - panel.add_child(summary); - const firstRow = new St.BoxLayout({ - style_class: 'aurora-devtool-action-row', - }); + const firstRow = createDevToolActionRow(); firstRow.add_child( - this._createActionButton( + createDevToolActionButton( 'appointment-new-symbolic', 'Add Soon', () => this.addSoonMeeting(), @@ -61,7 +49,7 @@ export class MeetingClockDevTool { ), ); firstRow.add_child( - this._createActionButton( + createDevToolActionButton( 'media-playback-start-symbolic', 'Add Now', () => this.addCurrentMeeting(), @@ -70,11 +58,9 @@ export class MeetingClockDevTool { ); panel.add_child(firstRow); - const secondRow = new St.BoxLayout({ - style_class: 'aurora-devtool-action-row', - }); + const secondRow = createDevToolActionRow(); secondRow.add_child( - this._createActionButton( + createDevToolActionButton( 'insert-link-symbolic', 'No Link', () => this.addNoLinkMeeting(), @@ -82,7 +68,7 @@ export class MeetingClockDevTool { ), ); secondRow.add_child( - this._createActionButton( + createDevToolActionButton( 'dialog-warning-symbolic', 'Trigger Alert', () => this.triggerAlert(), @@ -91,11 +77,9 @@ export class MeetingClockDevTool { ); panel.add_child(secondRow); - const thirdRow = new St.BoxLayout({ - style_class: 'aurora-devtool-action-row', - }); + const thirdRow = createDevToolActionRow(); thirdRow.add_child( - this._createActionButton( + createDevToolActionButton( 'document-open-symbolic', 'Open Calendar', () => this.openCalendar(), @@ -103,7 +87,7 @@ export class MeetingClockDevTool { ), ); thirdRow.add_child( - this._createActionButton( + createDevToolActionButton( 'user-trash-symbolic', 'Clear Fake', () => this.clearMeetings(), @@ -186,34 +170,4 @@ export class MeetingClockDevTool { this._requestMenuRebuild(); return id; } - - private _createActionButton( - iconName: string, - label: string, - onClick: () => void, - disabled = false, - ): St.Button { - const content = new St.BoxLayout({ - style_class: 'aurora-devtool-action-content', - }); - content.add_child( - new St.Icon({ - icon_name: iconName, - icon_size: 16, - }), - ); - content.add_child(new St.Label({ text: label })); - - const button = new St.Button({ - child: content, - style_class: 'button aurora-devtool-action-button', - can_focus: !disabled, - reactive: !disabled, - x_expand: true, - accessible_name: label, - }); - if (disabled) button.opacity = 120; - button.connect('clicked', onClick); - return button; - } } diff --git a/src/dev/trayIconsDevTool.ts b/src/dev/trayIconsDevTool.ts index 1707496..bb6fded 100644 --- a/src/dev/trayIconsDevTool.ts +++ b/src/dev/trayIconsDevTool.ts @@ -1,8 +1,14 @@ import '@girs/gjs'; -import St from '@girs/st-18'; +import type St from '@girs/st-18'; import * as Main from '@girs/gnome-shell/ui/main'; +import { + createDevToolActionButton, + createDevToolActionRow, + createDevToolModulePanel, + createDevToolSummary, +} from '~/dev/devToolUi.ts'; import type { TrayItem } from '~/desktop/trayIcons/trayState.ts'; const TRAY_ID = 'aurora-tray-icons'; @@ -39,40 +45,22 @@ export class TrayIconsDevTool { const tray = this._getTray(); const hasFakeItems = this._fakeItems.size > 0; - const panel = new St.BoxLayout({ - vertical: true, - style_class: 'aurora-devtool-module-panel', - }); - - const summary = new St.BoxLayout({ - style_class: 'aurora-devtool-summary', - }); - summary.add_child( - new St.Icon({ - icon_name: this.iconName, - icon_size: 18, - style_class: 'aurora-devtool-summary-icon', - }), - ); - summary.add_child( - new St.Label({ - text: tray ? `${this._fakeItems.size} fake icons` : 'Tray unavailable', - style_class: 'aurora-devtool-summary-label', - x_expand: true, - }), + const panel = createDevToolModulePanel(); + panel.add_child( + createDevToolSummary( + this.iconName, + tray ? `${this._fakeItems.size} fake icons` : 'Tray unavailable', + ), ); - panel.add_child(summary); - const primaryRow = new St.BoxLayout({ - style_class: 'aurora-devtool-action-row', - }); + const primaryRow = createDevToolActionRow(); primaryRow.add_child( - this._createActionButton('list-add-symbolic', 'Add Random Icon', () => { + createDevToolActionButton('list-add-symbolic', 'Add Random Icon', () => { this.addRandomFakeIcon(); }), ); primaryRow.add_child( - this._createActionButton( + createDevToolActionButton( 'dialog-warning-symbolic', this._attentionEnabled ? 'Alerts On' : 'Alert Icons', () => this.toggleAttentionOnAll(), @@ -82,11 +70,9 @@ export class TrayIconsDevTool { ); panel.add_child(primaryRow); - const secondaryRow = new St.BoxLayout({ - style_class: 'aurora-devtool-action-row', - }); + const secondaryRow = createDevToolActionRow(); secondaryRow.add_child( - this._createActionButton( + createDevToolActionButton( 'user-trash-symbolic', 'Remove All', () => this.removeAllFakeIcons(), @@ -225,37 +211,4 @@ export class TrayIconsDevTool { return tray; } - - private _createActionButton( - iconName: string, - label: string, - onClick: () => void, - disabled = false, - active = false, - ): St.Button { - const content = new St.BoxLayout({ - style_class: 'aurora-devtool-action-content', - }); - content.add_child( - new St.Icon({ - icon_name: iconName, - icon_size: 16, - }), - ); - content.add_child(new St.Label({ text: label })); - - const button = new St.Button({ - child: content, - style_class: active - ? 'button aurora-devtool-action-button active' - : 'button aurora-devtool-action-button', - can_focus: !disabled, - reactive: !disabled, - x_expand: true, - accessible_name: label, - }); - if (disabled) button.opacity = 120; - button.connect('clicked', onClick); - return button; - } } diff --git a/src/dev/weatherClockDevTool.ts b/src/dev/weatherClockDevTool.ts index 101d6ba..25ff9ef 100644 --- a/src/dev/weatherClockDevTool.ts +++ b/src/dev/weatherClockDevTool.ts @@ -1,7 +1,13 @@ import '@girs/gjs'; -import St from '@girs/st-18'; - +import type St from '@girs/st-18'; + +import { + createDevToolActionButton, + createDevToolActionRow, + createDevToolModulePanel, + createDevToolSummary, +} from '~/dev/devToolUi.ts'; import type { Module } from '~/module.ts'; import { WeatherClock } from '~/panel/clock/weatherClock/weatherClock.ts'; @@ -19,37 +25,19 @@ export class WeatherClockDevTool { buildPanel(): St.Widget { const weatherClock = this._getWeatherClock(); - const panel = new St.BoxLayout({ - vertical: true, - style_class: 'aurora-devtool-module-panel', - }); - - const summary = new St.BoxLayout({ - style_class: 'aurora-devtool-summary', - }); - summary.add_child( - new St.Icon({ - icon_name: this.iconName, - icon_size: 18, - style_class: 'aurora-devtool-summary-icon', - }), - ); - summary.add_child( - new St.Label({ - text: weatherClock + const panel = createDevToolModulePanel(); + panel.add_child( + createDevToolSummary( + this.iconName, + weatherClock ? `Visible: ${weatherClock.isVisible ? 'yes' : 'no'}` : 'Weather Clock disabled', - style_class: 'aurora-devtool-summary-label', - x_expand: true, - }), + ), ); - panel.add_child(summary); - const firstRow = new St.BoxLayout({ - style_class: 'aurora-devtool-action-row', - }); + const firstRow = createDevToolActionRow(); firstRow.add_child( - this._createActionButton( + createDevToolActionButton( 'weather-clear-symbolic', 'Sunny', () => this.showSunny(), @@ -57,7 +45,7 @@ export class WeatherClockDevTool { ), ); firstRow.add_child( - this._createActionButton( + createDevToolActionButton( 'weather-showers-symbolic', 'Rain', () => this.showRain(), @@ -66,11 +54,9 @@ export class WeatherClockDevTool { ); panel.add_child(firstRow); - const secondRow = new St.BoxLayout({ - style_class: 'aurora-devtool-action-row', - }); + const secondRow = createDevToolActionRow(); secondRow.add_child( - this._createActionButton( + createDevToolActionButton( 'network-offline-symbolic', 'Offline', () => this.showOffline(), @@ -79,11 +65,9 @@ export class WeatherClockDevTool { ); panel.add_child(secondRow); - const thirdRow = new St.BoxLayout({ - style_class: 'aurora-devtool-action-row', - }); + const thirdRow = createDevToolActionRow(); thirdRow.add_child( - this._createActionButton( + createDevToolActionButton( 'dialog-warning-symbolic', 'Unavailable', () => this.showUnavailable(), @@ -91,7 +75,7 @@ export class WeatherClockDevTool { ), ); thirdRow.add_child( - this._createActionButton( + createDevToolActionButton( 'user-trash-symbolic', 'Clear Fake', () => this.clearWeather(), @@ -157,34 +141,4 @@ export class WeatherClockDevTool { const module = this._getModule('weather-clock'); return module instanceof WeatherClock ? module : null; } - - private _createActionButton( - iconName: string, - label: string, - onClick: () => void, - disabled = false, - ): St.Button { - const content = new St.BoxLayout({ - style_class: 'aurora-devtool-action-content', - }); - content.add_child( - new St.Icon({ - icon_name: iconName, - icon_size: 16, - }), - ); - content.add_child(new St.Label({ text: label })); - - const button = new St.Button({ - child: content, - style_class: 'button aurora-devtool-action-button', - can_focus: !disabled, - reactive: !disabled, - x_expand: true, - accessible_name: label, - }); - if (disabled) button.opacity = 120; - button.connect('clicked', onClick); - return button; - } } diff --git a/src/device/device.ts b/src/device/device.ts index 7071ac5..1baedd5 100644 --- a/src/device/device.ts +++ b/src/device/device.ts @@ -89,155 +89,166 @@ export class DefaultDeviceService implements DeviceService { } private _detect(): DeviceSnapshot { - const input = detectInputPresence(this._seat.list_devices()); - const capabilities = detectCapabilities(input.touch); - return createDeviceSnapshot(detectMonitors(), input, capabilities); + const input = this._detectInputPresence(this._seat.list_devices()); + const capabilities = this._detectCapabilities(input.touch); + return createDeviceSnapshot(this._detectMonitors(), input, capabilities); } -} - -function detectMonitors(): MonitorInput[] { - const builtinMonitorIndices = detectBuiltinMonitorIndices(); - return (Main.layoutManager.monitors ?? []).map((monitor) => ({ - index: monitor.index, - x: monitor.x, - y: monitor.y, - width: monitor.width, - height: monitor.height, - scale: monitor.geometryScale, - isBuiltin: builtinMonitorIndices.has(monitor.index), - })); -} -function detectInputPresence(devices: readonly Clutter.InputDevice[]): InputPresence { - return { - touch: devices.some( - (device) => device.get_device_type() === Clutter.InputDeviceType.TOUCHSCREEN_DEVICE, - ), - pointer: devices.some( - (device) => device.get_device_type() === Clutter.InputDeviceType.POINTER_DEVICE, - ), - keyboard: devices.some( - (device) => device.get_device_type() === Clutter.InputDeviceType.KEYBOARD_DEVICE, - ), - }; -} + private _detectMonitors(): MonitorInput[] { + const builtinMonitorIndices = this._detectBuiltinMonitorIndices(); + return (Main.layoutManager.monitors ?? []).map((monitor) => ({ + index: monitor.index, + x: monitor.x, + y: monitor.y, + width: monitor.width, + height: monitor.height, + scale: monitor.geometryScale, + isBuiltin: builtinMonitorIndices.has(monitor.index), + })); + } -function detectCapabilities(hasTouch: boolean): ReadonlySet { - const capabilities = new Set(); - if (hasTouch) capabilities.add('touch'); - if (hasBacklight()) capabilities.add('backlight'); - if (hasDBusNameOwner(MODEM_MANAGER_NAME)) capabilities.add('cellular'); + private _detectInputPresence(devices: readonly Clutter.InputDevice[]): InputPresence { + return { + touch: devices.some( + (device) => device.get_device_type() === Clutter.InputDeviceType.TOUCHSCREEN_DEVICE, + ), + pointer: devices.some( + (device) => device.get_device_type() === Clutter.InputDeviceType.POINTER_DEVICE, + ), + keyboard: devices.some( + (device) => device.get_device_type() === Clutter.InputDeviceType.KEYBOARD_DEVICE, + ), + }; + } - const sensorProxy = getSensorProxy(); - if (sensorProxy) { - if (getBooleanProperty(sensorProxy, 'HasAccelerometer')) capabilities.add('accelerometer'); - if (getBooleanProperty(sensorProxy, 'HasAmbientLight')) capabilities.add('light-sensor'); - if (getBooleanProperty(sensorProxy, 'HasProximity')) capabilities.add('proximity-sensor'); + private _detectCapabilities(hasTouch: boolean): ReadonlySet { + const capabilities = new Set(); + if (hasTouch) capabilities.add('touch'); + if (this._hasBacklight()) capabilities.add('backlight'); + if (this._hasDBusNameOwner(MODEM_MANAGER_NAME)) capabilities.add('cellular'); + + const sensorProxy = this._getSensorProxy(); + if (sensorProxy) { + if (this._getBooleanProperty(sensorProxy, 'HasAccelerometer')) + capabilities.add('accelerometer'); + if (this._getBooleanProperty(sensorProxy, 'HasAmbientLight')) + capabilities.add('light-sensor'); + if (this._getBooleanProperty(sensorProxy, 'HasProximity')) + capabilities.add('proximity-sensor'); + } + return capabilities; } - return capabilities; -} -function detectBuiltinMonitorIndices(): ReadonlySet { - try { - const result = Gio.DBus.session.call_sync( - DISPLAY_CONFIG_NAME, - DISPLAY_CONFIG_PATH, - DISPLAY_CONFIG_NAME, - 'GetCurrentState', - null, - null, - Gio.DBusCallFlags.NONE, - 200, - null, - ); - return parseBuiltinMonitorIndices(result?.recursiveUnpack()); - } catch { - return new Set(); + private _detectBuiltinMonitorIndices(): ReadonlySet { + try { + const result = Gio.DBus.session.call_sync( + DISPLAY_CONFIG_NAME, + DISPLAY_CONFIG_PATH, + DISPLAY_CONFIG_NAME, + 'GetCurrentState', + null, + null, + Gio.DBusCallFlags.NONE, + 200, + null, + ); + return this._parseBuiltinMonitorIndices(result?.recursiveUnpack()); + } catch { + return new Set(); + } } -} -function parseBuiltinMonitorIndices(state: unknown): ReadonlySet { - if (!Array.isArray(state) || !Array.isArray(state[1]) || !Array.isArray(state[2])) - return new Set(); + private _parseBuiltinMonitorIndices(state: unknown): ReadonlySet { + if (!Array.isArray(state) || !Array.isArray(state[1]) || !Array.isArray(state[2])) + return new Set(); + + const builtinConnectors = new Set(); + for (const physical of state[1]) { + if (!Array.isArray(physical) || !Array.isArray(physical[0])) continue; + const connector = physical[0][0]; + const properties = physical[2]; + if ( + typeof connector === 'string' && + this._isRecord(properties) && + properties['is-builtin'] === true + ) + builtinConnectors.add(connector); + } - const builtinConnectors = new Set(); - for (const physical of state[1]) { - if (!Array.isArray(physical) || !Array.isArray(physical[0])) continue; - const connector = physical[0][0]; - const properties = physical[2]; - if (typeof connector === 'string' && isRecord(properties) && properties['is-builtin'] === true) - builtinConnectors.add(connector); + const indices = new Set(); + for (const [index, logical] of state[2].entries()) { + if (!Array.isArray(logical) || !Array.isArray(logical[5])) continue; + const containsBuiltin = logical[5].some( + (spec) => Array.isArray(spec) && builtinConnectors.has(spec[0]), + ); + if (containsBuiltin) indices.add(index); + } + return indices; } - const indices = new Set(); - for (const [index, logical] of state[2].entries()) { - if (!Array.isArray(logical) || !Array.isArray(logical[5])) continue; - const containsBuiltin = logical[5].some( - (spec) => Array.isArray(spec) && builtinConnectors.has(spec[0]), - ); - if (containsBuiltin) indices.add(index); + private _isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); } - return indices; -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} -function hasBacklight(): boolean { - try { - const dir = Gio.File.new_for_path('/sys/class/backlight'); - const enumerator = dir.enumerate_children('standard::name', Gio.FileQueryInfoFlags.NONE, null); + private _hasBacklight(): boolean { try { - return enumerator.next_file(null) !== null; - } finally { - enumerator.close(null); + const dir = Gio.File.new_for_path('/sys/class/backlight'); + const enumerator = dir.enumerate_children( + 'standard::name', + Gio.FileQueryInfoFlags.NONE, + null, + ); + try { + return enumerator.next_file(null) !== null; + } finally { + enumerator.close(null); + } + } catch { + return false; } - } catch { - return false; } -} -function getSensorProxy(): Gio.DBusProxy | null { - if (!hasDBusNameOwner(SENSOR_DBUS_NAME)) return null; - try { - return Gio.DBusProxy.new_for_bus_sync( - Gio.BusType.SYSTEM, - Gio.DBusProxyFlags.NONE, - null, - SENSOR_DBUS_NAME, - SENSOR_PATH, - SENSOR_IFACE, - null, - ); - } catch { - return null; + private _getSensorProxy(): Gio.DBusProxy | null { + if (!this._hasDBusNameOwner(SENSOR_DBUS_NAME)) return null; + try { + return Gio.DBusProxy.new_for_bus_sync( + Gio.BusType.SYSTEM, + Gio.DBusProxyFlags.NONE, + null, + SENSOR_DBUS_NAME, + SENSOR_PATH, + SENSOR_IFACE, + null, + ); + } catch { + return null; + } } -} -function getBooleanProperty(proxy: Gio.DBusProxy, propertyName: string): boolean { - try { - return Boolean(proxy.get_cached_property(propertyName)?.unpack()); - } catch { - return false; + private _getBooleanProperty(proxy: Gio.DBusProxy, propertyName: string): boolean { + try { + return Boolean(proxy.get_cached_property(propertyName)?.unpack()); + } catch { + return false; + } } -} -function hasDBusNameOwner(name: string): boolean { - try { - const result = Gio.DBus.system.call_sync( - 'org.freedesktop.DBus', - '/org/freedesktop/DBus', - 'org.freedesktop.DBus', - 'NameHasOwner', - new GLib.Variant('(s)', [name]), - new GLib.VariantType('(b)'), - Gio.DBusCallFlags.NONE, - 200, - null, - ); - return Boolean(result?.get_child_value(0).unpack()); - } catch { - return false; + private _hasDBusNameOwner(name: string): boolean { + try { + const result = Gio.DBus.system.call_sync( + 'org.freedesktop.DBus', + '/org/freedesktop/DBus', + 'org.freedesktop.DBus', + 'NameHasOwner', + new GLib.Variant('(s)', [name]), + new GLib.VariantType('(b)'), + Gio.DBusCallFlags.NONE, + 200, + null, + ); + return Boolean(result?.get_child_value(0).unpack()); + } catch { + return false; + } } } diff --git a/src/panel/auroraMenu.ts b/src/panel/auroraMenu.ts index d4ac4ee..39764c8 100644 --- a/src/panel/auroraMenu.ts +++ b/src/panel/auroraMenu.ts @@ -178,7 +178,7 @@ export class AuroraMenu extends Module { () => this._addCommandIfVisible(menu, SHOW_DOWNLOADS_KEY, { title: _('Downloads'), - argv: ['xdg-open', getDownloadsDirectory() ?? GLib.get_home_dir()], + argv: ['xdg-open', this._getDownloadsDirectory() ?? GLib.get_home_dir()], iconName: 'folder-download-symbolic', }), () => this._addRecentItems(menu), @@ -257,7 +257,7 @@ export class AuroraMenu extends Module { let added = false; for (const command of commands) { - const argv = parseCommandLine(command.command, CUSTOM_ITEMS_KEY); + const argv = this._parseCommandLine(command.command, CUSTOM_ITEMS_KEY); if (argv.length === 0) continue; this._addCommand(menu, { @@ -335,7 +335,7 @@ export class AuroraMenu extends Module { const body = match[3] ?? ''; const title = this._extractRecentTitle(body, uri); - const modified = parseIsoTime(match[2] ?? ''); + const modified = this._parseIsoTime(match[2] ?? ''); items.push({ title, uri, @@ -346,7 +346,7 @@ export class AuroraMenu extends Module { return items.sort((a, b) => b.modified - a.modified).slice(0, RECENT_LIMIT); } catch (e) { - if (isGioError(e, Gio.IOErrorEnum.NOT_FOUND)) return []; + if (this._isGioError(e, Gio.IOErrorEnum.NOT_FOUND)) return []; logger.warn(`Failed to read recent items: ${e}`, { prefix: LOG_PREFIX }); return []; @@ -407,7 +407,7 @@ export class AuroraMenu extends Module { if (!this._panelIcon) return; const requested = this.context.settings.getString(MENU_ICON_KEY); - const iconKey = isMenuIconKey(requested) ? requested : 'aurora'; + const iconKey = this._isMenuIconKey(requested) ? requested : 'aurora'; const icon = MENU_ICONS[iconKey]; this._panelIcon.icon_name = null; @@ -441,7 +441,10 @@ export class AuroraMenu extends Module { if (GLib.find_program_in_path('gnome-extensions-app')) return ['gnome-extensions-app']; if (GLib.find_program_in_path('gnome-shell-extension-prefs')) return ['gnome-shell-extension-prefs']; - if (GLib.find_program_in_path('flatpak') && desktopFileExists(EXTENSION_MANAGER_DESKTOP_ID)) + if ( + GLib.find_program_in_path('flatpak') && + this._desktopFileExists(EXTENSION_MANAGER_DESKTOP_ID) + ) return ['flatpak', 'run', EXTENSION_MANAGER_FLATPAK_ID]; return null; @@ -463,7 +466,7 @@ export class AuroraMenu extends Module { const raw = this.context.settings.getString(key).trim(); if (!raw) return fallback; - const argv = parseCommandLine(raw, key); + const argv = this._parseCommandLine(raw, key); return argv.length > 0 ? argv : fallback; } @@ -504,6 +507,53 @@ export class AuroraMenu extends Module { private _getMenu(): PopupMenu.PopupMenu | null { return (this._button?.menu as PopupMenu.PopupMenu | null | undefined) ?? null; } + + private _parseIsoTime(value: string): number { + const dateTime = GLib.DateTime.new_from_iso8601(value, null); + return dateTime?.to_unix() ?? 0; + } + + private _isGioError(error: unknown, code: number): boolean { + return error instanceof GLib.Error && error.matches(Gio.io_error_quark(), code); + } + + private _getDownloadsDirectory(): string | null { + const path = GLib.get_user_special_dir(GLib.UserDirectory.DIRECTORY_DOWNLOAD); + return path || null; + } + + private _desktopFileExists(desktopId: string): boolean { + return this._getApplicationDataDirs().some((dir) => + GLib.file_test(GLib.build_filenamev([dir, desktopId]), GLib.FileTest.EXISTS), + ); + } + + private _getApplicationDataDirs(): string[] { + return [ + GLib.build_filenamev([GLib.get_user_data_dir(), 'applications']), + GLib.build_filenamev([ + GLib.get_home_dir(), + '.local/share/flatpak/exports/share/applications', + ]), + ...GLib.get_system_data_dirs().map((dir) => GLib.build_filenamev([dir, 'applications'])), + '/var/lib/flatpak/exports/share/applications', + ]; + } + + private _isMenuIconKey(value: string): value is MenuIconKey { + return value in MENU_ICONS; + } + + private _parseCommandLine(raw: string, key: string): string[] { + try { + const [ok, argv] = GLib.shell_parse_argv(raw); + if (ok && argv && argv.length > 0) return argv; + } catch (e) { + logger.warn(`Invalid command in ${key}: ${e}`, { prefix: LOG_PREFIX }); + } + + return []; + } } const MENU_VISIBILITY_KEYS = [ @@ -515,52 +565,3 @@ const MENU_VISIBILITY_KEYS = [ SHOW_SOFTWARE_KEY, SHOW_EXTENSIONS_KEY, ]; - -function parseIsoTime(value: string): number { - const dateTime = GLib.DateTime.new_from_iso8601(value, null); - return dateTime?.to_unix() ?? 0; -} - -function isGioError(error: unknown, code: number): boolean { - return Boolean( - (error as { matches?: (domain: unknown, code: unknown) => boolean })?.matches?.( - Gio.IOErrorEnum, - code, - ), - ); -} - -function getDownloadsDirectory(): string | null { - const path = GLib.get_user_special_dir(GLib.UserDirectory.DIRECTORY_DOWNLOAD); - return path || null; -} - -function desktopFileExists(desktopId: string): boolean { - return getApplicationDataDirs().some((dir) => - GLib.file_test(GLib.build_filenamev([dir, desktopId]), GLib.FileTest.EXISTS), - ); -} - -function getApplicationDataDirs(): string[] { - return [ - GLib.build_filenamev([GLib.get_user_data_dir(), 'applications']), - GLib.build_filenamev([GLib.get_home_dir(), '.local/share/flatpak/exports/share/applications']), - ...GLib.get_system_data_dirs().map((dir) => GLib.build_filenamev([dir, 'applications'])), - '/var/lib/flatpak/exports/share/applications', - ]; -} - -function isMenuIconKey(value: string): value is MenuIconKey { - return value in MENU_ICONS; -} - -function parseCommandLine(raw: string, key: string): string[] { - try { - const [ok, argv] = GLib.shell_parse_argv(raw); - if (ok && argv && argv.length > 0) return argv; - } catch (e) { - logger.warn(`Invalid command in ${key}: ${e}`, { prefix: LOG_PREFIX }); - } - - return []; -} diff --git a/src/panel/bluetoothMenu/bluetoothMenu.ts b/src/panel/bluetoothMenu/bluetoothMenu.ts index 478a80a..dd33d6e 100644 --- a/src/panel/bluetoothMenu/bluetoothMenu.ts +++ b/src/panel/bluetoothMenu/bluetoothMenu.ts @@ -1,12 +1,10 @@ import '@girs/gjs'; import { gettext as _ } from 'gettext'; -import * as Main from '@girs/gnome-shell/ui/main'; - import type { ExtensionContext } from '~/core/context.ts'; import { logger } from '~/core/logger.ts'; import { Module } from '~/module.ts'; -import { attachToQuickSettings } from '~/shared/quickSettings.ts'; +import { attachToQuickSettings, getQuickSettingsGrid } from '~/shared/quickSettings.ts'; import { BluetoothDeviceItemPatcher } from '~/panel/bluetoothMenu/deviceItem.ts'; const LOG_PREFIX = 'BluetoothMenu'; @@ -58,7 +56,7 @@ export class BluetoothMenu extends Module { } private _findBluetoothToggle(): any { - const grid = Main.panel.statusArea.quickSettings?.menu?._grid; + const grid = getQuickSettingsGrid(); if (!grid) return null; for (const child of grid.get_children()) { diff --git a/tests/unit/appIdentity.test.ts b/tests/unit/appIdentity.test.ts new file mode 100644 index 0000000..7043026 --- /dev/null +++ b/tests/unit/appIdentity.test.ts @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { appIdCandidates } from '../../src/desktop/trayIcons/appIdentity.ts'; + +test('app identity — keeps suffixed and unsuffixed lowercase candidates', () => { + assert.deepEqual( + appIdCandidates(['Org.Example.App.desktop']), + new Set(['org.example.app.desktop', 'org.example.app']), + ); +}); + +test('app identity — combines candidates from Shell and background-app IDs', () => { + assert.deepEqual( + appIdCandidates(['org.example.App', 'Com.Vendor.App.desktop']), + new Set(['org.example.app', 'com.vendor.app.desktop', 'com.vendor.app']), + ); +}); + +test('app identity — ignores empty IDs and deduplicates equivalent forms', () => { + assert.deepEqual( + appIdCandidates(['', 'app.desktop', 'APP.desktop']), + new Set(['app.desktop', 'app']), + ); +}); From f8c4838bee867209eb4f3643a1616406910f036d Mon Sep 17 00:00:00 2001 From: Leandro Rodrigues Date: Sun, 12 Jul 2026 16:29:48 -0300 Subject: [PATCH 3/6] chore: update code structure and remove redundant changes --- .dockerignore | 7 + .github/actions/package-extension/action.yml | 33 - .github/actions/setup-yarn/action.yml | 25 - .github/workflows/ci.yml | 135 +- .github/workflows/container-image.yml | 59 + .gitignore | 4 +- AGENTS.md | 15 +- CONTRIBUTING.md | 31 +- Containerfile | 50 + Vagrantfile | 5 +- esbuild.ts | 6 +- eslint.config.ts | 2 +- justfile | 115 +- package.json | 9 +- scripts/build-gjsify.ts | 367 --- scripts/create-toolbox.sh | 26 - scripts/run-gnome-shell.sh | 61 - scripts/run-shell-tests.sh | 96 + scripts/run-toolbox-shell-test.sh | 43 - scripts/run-vagrant-gnome-shell.sh | 3 +- yarn.lock | 2094 +----------------- 21 files changed, 397 insertions(+), 2789 deletions(-) create mode 100644 .dockerignore delete mode 100644 .github/actions/package-extension/action.yml delete mode 100644 .github/actions/setup-yarn/action.yml create mode 100644 .github/workflows/container-image.yml create mode 100644 Containerfile delete mode 100644 scripts/build-gjsify.ts delete mode 100755 scripts/create-toolbox.sh create mode 100755 scripts/run-shell-tests.sh delete mode 100644 scripts/run-toolbox-shell-test.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3aa2470 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +* +!Containerfile +!package.json +!yarn.lock +!.yarnrc.yml +!.yarn/releases/ +!.yarn/releases/** diff --git a/.github/actions/package-extension/action.yml b/.github/actions/package-extension/action.yml deleted file mode 100644 index 9507cad..0000000 --- a/.github/actions/package-extension/action.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Package Extension -description: Install system dependencies, setup Yarn, and package the extension ZIP. - -inputs: - node-version: - description: Node.js version to install. - required: false - default: '22' - apt-packages: - description: Ubuntu packages needed to build the extension. - required: false - default: gettext zip gnome-shell python3-gi gir1.2-glib-2.0 - -runs: - using: composite - steps: - - name: Install just - uses: extractions/setup-just@v4 - - - name: Install system dependencies - shell: bash - run: | - sudo apt-get update - sudo apt-get install -y ${{ inputs.apt-packages }} - - - name: Setup Yarn - uses: ./.github/actions/setup-yarn - with: - node-version: ${{ inputs.node-version }} - - - name: Build extension zip - shell: bash - run: just package diff --git a/.github/actions/setup-yarn/action.yml b/.github/actions/setup-yarn/action.yml deleted file mode 100644 index e74c28e..0000000 --- a/.github/actions/setup-yarn/action.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: Setup Yarn -description: Enable Corepack, setup Node.js, and install Yarn dependencies. - -inputs: - node-version: - description: Node.js version to install. - required: false - default: '22' - -runs: - using: composite - steps: - - name: Enable Corepack - shell: bash - run: corepack enable - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: ${{ inputs.node-version }} - cache: yarn - - - name: Install dependencies - shell: bash - run: yarn install --immutable diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34a9a96..2a72c7a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,9 @@ concurrency: group: ci-${{ github.ref }} cancel-in-progress: true +env: + DEV_IMAGE: ghcr.io/luminusos/aurora-shell-dev:fedora44-gnome50 + jobs: lint: name: Validate @@ -16,14 +19,16 @@ jobs: steps: - uses: actions/checkout@v7 - - name: Setup Yarn - uses: ./.github/actions/setup-yarn - - - name: Install just - uses: extractions/setup-just@v4 + - name: Prepare development image + run: docker pull "$DEV_IMAGE" || docker build --tag "$DEV_IMAGE" . - name: Validate - run: just validate + run: | + docker run --rm \ + --volume "$PWD:/workspace" \ + --workdir /workspace \ + "$DEV_IMAGE" \ + bash -lc 'just deps && just validate' unit-tests: name: Unit & regression tests @@ -31,11 +36,16 @@ jobs: steps: - uses: actions/checkout@v7 - - name: Setup Yarn - uses: ./.github/actions/setup-yarn + - name: Prepare development image + run: docker pull "$DEV_IMAGE" || docker build --tag "$DEV_IMAGE" . - name: Run unit tests - run: yarn test:unit + run: | + docker run --rm \ + --volume "$PWD:/workspace" \ + --workdir /workspace \ + "$DEV_IMAGE" \ + bash -lc 'just deps && just unit-test' build: name: Build @@ -44,8 +54,16 @@ jobs: steps: - uses: actions/checkout@v7 - - name: Package extension - uses: ./.github/actions/package-extension + - name: Prepare development image + run: docker pull "$DEV_IMAGE" || docker build --tag "$DEV_IMAGE" . + + - name: Build extension package + run: | + docker run --rm \ + --volume "$PWD:/workspace" \ + --workdir /workspace \ + "$DEV_IMAGE" \ + bash -lc 'just deps && just package' - name: Upload extension zip uses: actions/upload-artifact@v4 @@ -55,18 +73,9 @@ jobs: retention-days: 1 integration-tests: - name: Integration tests (GNOME ${{ matrix.gnome-version }}) + name: Integration tests (GNOME 50) runs-on: ubuntu-latest needs: [build, unit-tests] - strategy: - fail-fast: false - matrix: - include: - - gnome-version: '50' - container: 'fedora:44' - container: - image: ${{ matrix.container }} - options: --privileged steps: - uses: actions/checkout@v7 @@ -76,68 +85,28 @@ jobs: name: extension-zip path: dist/target/ - - name: Install gnome-shell and test dependencies - run: | - dnf upgrade -y - dnf install -y --setopt=install_weak_deps=False \ - gnome-shell \ - dbus-daemon \ - mesa-dri-drivers \ - xorg-x11-server-Xvfb \ - python3-dbusmock + - name: Prepare development image + run: docker pull "$DEV_IMAGE" || docker build --tag "$DEV_IMAGE" . - name: Run integration tests - env: - LIBGL_ALWAYS_SOFTWARE: '1' - XDG_RUNTIME_DIR: /tmp/xdg-runtime - DISPLAY: ':0' - WAYLAND_DISPLAY: 'gnome-shell-test-display' - # gnome-shell-perf-helper is a GTK4 app that times out (~25 s) trying - # to reach org.freedesktop.host.portal.Registry, which is not - # available in a headless test environment. no-portals skips that - # lookup so the helper connects directly to the Wayland display and - # can exit gnome-shell cleanly. - GDK_DEBUG: 'no-portals' run: | - mkdir -p /tmp/xdg-runtime - chmod 700 /tmp/xdg-runtime - - # Start a system D-Bus so GNOME Shell can reach Gio.DBus.system. - mkdir -p /run/dbus - dbus-daemon --system --fork --nopidfile - - # Mock org.freedesktop.login1 on the system bus. GNOME Shell 50's - # LoginManagerSystemd and EndSessionDialog create a proxy for this - # name synchronously at startup; without it the session crashes with - # "Launch helper exited with unknown return code 1". - python3 -m dbusmock --system --template logind & - sleep 1 - - PASS=0; FAIL=0 - for script in tests/shell/aurora*.js; do - echo "==> $script" - # dbus-update-activation-environment pushes display vars into the - # session bus daemon so that D-Bus-activated services (e.g. - # xdg-desktop-portal-gtk) inherit them and can connect to the - # Wayland compositor. GDK_DEBUG=no-portals is also propagated so - # that gnome-shell-perf-helper (GTK4) skips the portal registry - # lookup that would otherwise time out in a headless environment. - if dbus-run-session bash -c ' - dbus-update-activation-environment \ - DISPLAY WAYLAND_DISPLAY XDG_RUNTIME_DIR LIBGL_ALWAYS_SOFTWARE \ - GDK_DEBUG - exec gnome-shell-test-tool \ - --headless \ - --extension dist/target/aurora-shell@luminusos.github.io.shell-extension.zip \ - "$1" - ' -- "$script"; then - echo " PASS" - PASS=$((PASS + 1)) - else - echo " FAIL" - FAIL=$((FAIL + 1)) - fi - done - echo "" - echo "Results: $PASS passed, $FAIL failed" - [ "$FAIL" -eq 0 ] + docker run --rm --privileged \ + --env DISPLAY=:0 \ + --env GDK_DEBUG=no-portals \ + --env LIBGL_ALWAYS_SOFTWARE=1 \ + --env WAYLAND_DISPLAY=gnome-shell-test-display \ + --env XDG_RUNTIME_DIR=/tmp/xdg-runtime \ + --volume "$PWD:/workspace" \ + --workdir /workspace \ + "$DEV_IMAGE" \ + bash -lc ' + install -d -m 0700 "$XDG_RUNTIME_DIR" + mkdir -p /run/dbus + dbus-daemon --system --fork --nopidfile + python3 -m dbusmock --system --template logind & + logind_pid=$! + trap "kill $logind_pid 2>/dev/null || true" EXIT + sleep 1 + bash scripts/run-shell-tests.sh \ + dist/target/aurora-shell@luminusos.github.io.shell-extension.zip + ' diff --git a/.github/workflows/container-image.yml b/.github/workflows/container-image.yml new file mode 100644 index 0000000..6cdcda1 --- /dev/null +++ b/.github/workflows/container-image.yml @@ -0,0 +1,59 @@ +name: Development container image + +on: + push: + branches: [main] + paths: + - Containerfile + - .dockerignore + - package.json + - yarn.lock + - .yarnrc.yml + - .yarn/releases/** + - .github/workflows/container-image.yml + pull_request: + paths: + - Containerfile + - .dockerignore + - package.json + - yarn.lock + - .yarnrc.yml + - .yarn/releases/** + - .github/workflows/container-image.yml + workflow_dispatch: + +permissions: + contents: read + packages: write + +jobs: + build: + name: Build Fedora 44 / GNOME 50 image + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: docker/setup-qemu-action@v3 + + - uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and publish image + uses: docker/build-push-action@v6 + with: + context: . + file: Containerfile + platforms: linux/amd64,linux/arm64 + push: ${{ github.event_name != 'pull_request' }} + tags: | + ghcr.io/luminusos/aurora-shell-dev:fedora44-gnome50 + ghcr.io/luminusos/aurora-shell-dev:sha-${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore index b136b65..1dd8660 100644 --- a/.gitignore +++ b/.gitignore @@ -12,8 +12,6 @@ node_modules/ # Build output dist/ -dist-gjsify/ -dist-gjsify-probe/ # npm (legacy) package-lock.json @@ -42,4 +40,4 @@ docs/ # IA Stuff .agents/ -skills-lock.json \ No newline at end of file +skills-lock.json diff --git a/AGENTS.md b/AGENTS.md index 0826af1..689851d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,16 +29,14 @@ Do not leave a task incomplete if either command reports errors or failures. ## Commands -- **Install deps:** `just deps` — runs `yarn install`; use once or when updating packages +- **Install deps:** `just deps` — runs `yarn install --immutable`; use once or after changing branches - **Build:** `just build` — compiles TypeScript and SCSS, copies metadata/schemas, and compiles `.mo` files - **Package:** `just package` — packs the extension as a `.zip` in `dist/target/` (depends on `build`) -- **Install:** `just install` — installs the already-packaged `.zip` to GNOME Shell (requires `just package` first) -- **Full install:** `just full-install` — packages + installs in one step -- **All:** `just all` — clean + full-install +- **Install:** `just install` — packages and installs the `.zip` to GNOME Shell - **Uninstall:** `just uninstall` — disables and removes the extension - **Run (host):** `just run` — packages, installs, then launches a devkit GNOME Shell session - **Run (toolbox):** `just toolbox run` — packages/installs on the host, then runs GNOME Shell inside the Fedora toolbox -- **Create toolbox:** `just toolbox create` — create the `aurora-shell-devel` Fedora toolbox +- **Create toolbox:** `just toolbox create` — creates `aurora-shell-devel` from the same Fedora/GNOME image used by CI - **Remove toolbox:** `just toolbox remove` — delete the toolbox - **Validate:** `just validate` — runs tsc, ESLint, Prettier check, and Stylelint - **Shexli:** `just shexli` — packages the extension and runs the extensions.gnome.org static analyzer on the generated ZIP @@ -47,13 +45,13 @@ Do not leave a task incomplete if either command reports errors or failures. - **View logs:** `just logs` — shows recent `aurora` entries from the current boot journal - **Clean:** `just clean` — removes `dist/` - **Deep clean:** `just distclean` — removes `dist/` and `node_modules/` -- **Unit tests:** `just unit-test` — runs unit tests via `yarn test:unit` (vitest) +- **Unit tests:** `just unit-test` — runs unit tests with Node's test runner - **Coverage:** `just coverage` — runs unit tests with coverage report - **Single integration test:** `just test