diff --git a/AGENTS.md b/AGENTS.md index 984ee94..551b2a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,12 +6,17 @@ ## Validation After Changes -After **any** code change, always run these two commands and fix any errors before considering the task done: +After **any** code change, always follow these rules to ensure quality while being efficient: -1. `just validate` — type-checks the TypeScript source without emitting output -2. `just toolbox test-all` — builds and runs all integration tests inside the Fedora toolbox, printing a pass/fail summary +1. **Always run `just validate`** — type-checks the source, lints, and checks formatting. Fix any reported errors. +2. **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`. + * If you made **architectural or cross-cutting changes**, run `just toolbox test-all`. -To read only the relevant output from the test run (pass/fail summary): +**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:" ``` @@ -160,6 +165,23 @@ return [/* …, */ myModule]; - Keep `enable()` and `disable()` symmetric. - **Strictly follow Dependency Injection.** No direct imports of `gi://Shell`, `Main`, etc., inside module domain logic. +## Logging Style + +Prefix every log message with the module name in `[PascalCase]` brackets. Use the global `logger` from `~/core/logger.ts` — never `console.log/warn` or `GLib.log_structured` directly from module code. + +```typescript +import { logger } from '~/core/logger.ts'; + +// Correct +logger.log('[AuroraTray] Item added: ' + id); + +// Wrong +logger.log('[Aurora Shell] [aurora-tray] Item added: ' + id); +console.warn('[aurora-shell] Something failed'); +``` + +The `[Aurora Shell]` prefix is redundant — SYSLOG_IDENTIFIER already routes journal output to the extension. + ## Reading GNOME Shell Source GNOME Shell JS source is embedded in `libshell-XX.so` as a GResource archive. The stylesheet files is `gnome-shell-theme.gresource`. Use `gresource` to read it without needing the source checkout. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6132e75..02bc40b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -151,3 +151,21 @@ All jobs must pass before a PR can be merged. - **Constants:** `UPPER_CASE` - **Symmetry:** Everything connected in `enable()` **must** be disconnected or destroyed in `disable()`. - **Dependency Injection:** Strictly follow DI; do not reach out to globals. + +## Logging Style + +Always prefix log messages with the module name in `[PascalCase]` brackets. Use the global `logger` from `~/core/logger.ts` for all logging — do not call `console.log/warn` or `GLib.log_structured` directly from module code. + +```typescript +import { logger } from '~/core/logger.ts'; + +// Correct +logger.log('[AuroraTray] Item added: ' + id); +logger.warn('[IconWeave] No match found for ' + wmClass); + +// Wrong — redundant prefix, wrong casing, or bypasses logger +logger.log('[Aurora Shell] [aurora-tray] Item added: ' + id); +console.warn('[aurora-shell] Something failed'); +``` + +The `[Aurora Shell]` prefix is redundant: the SYSLOG_IDENTIFIER in structured logs already routes output to the right extension in the journal. 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 e9cb0f7..d5c1899 100644 --- a/data/schemas/org.gnome.shell.extensions.aurora-shell.gschema.xml +++ b/data/schemas/org.gnome.shell.extensions.aurora-shell.gschema.xml @@ -95,5 +95,43 @@ Enable Bluetooth Menu module Enhances the Bluetooth Quick Settings panel with battery levels and animated icons + + true + Enable Tray Icons module + Shows SNI and background app icons in the panel + + + + 4 + Max visible tray icons + Number of icons shown before the expand chevron appears + + + + 18 + Tray icon size in pixels + Size of each tray icon (14–24 px) + + + + 5 + Attention auto-collapse timeout (seconds) + Seconds before auto-collapsing after a NeedsAttention notification + + + true + Hide background app when SNI icon is present + When enabled, background app icons are removed from the tray if the same app has an SNI tray icon + + + true + Hide Background Apps from Quick Settings panel + When enabled, the Background Apps section is hidden from the Quick Settings dropdown + + + true + Recolor symbolic SNI pixmaps + Automatically recolor monochrome SNI tray pixmaps to match the current panel theme + diff --git a/justfile b/justfile index a9a7f1b..6d259e6 100644 --- a/justfile +++ b/justfile @@ -122,7 +122,7 @@ coverage: # Wrapped in dbus-run-session to avoid conflicting with any running GNOME session. # Usage: just test tests/shell/auroraBasic.js test script: package - dbus-run-session gnome-shell-test-tool --headless \ + GSETTINGS_SCHEMA_DIR=/usr/share/glib-2.0/schemas dbus-run-session gnome-shell-test-tool --headless \ --extension dist/target/{{ uuid }}.shell-extension.zip \ {{ script }} diff --git a/metadata.json b/metadata.json index 1309255..76bca9f 100644 --- a/metadata.json +++ b/metadata.json @@ -1,7 +1,7 @@ { "name": "Aurora Shell", "description": "A customizable GNOME Shell extension that enhances the user experience with various modules and features.", - "version": 50.1, + "version-name": "50.1", "uuid": "aurora-shell@luminusos.github.io", "url": "https://github.com/luminusOS/aurora-shell", "settings-schema": "org.gnome.shell.extensions.aurora-shell", diff --git a/package.json b/package.json index 62ab1ec..58cd90e 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,8 @@ "prettier:check": "prettier --check \"src/**/*.{ts,scss}\"", "stylelint": "stylelint \"src/styles/**/*.scss\"", "watch:css": "tsx sass.config.ts --watch", - "test:unit": "node --import tsx/esm --test tests/unit/metadata.test.ts tests/unit/schema.test.ts tests/unit/registry.test.ts tests/unit/monitorTopology.test.ts", - "test:unit:coverage": "node --import tsx/esm --experimental-test-coverage --test tests/unit/metadata.test.ts tests/unit/schema.test.ts tests/unit/registry.test.ts tests/unit/monitorTopology.test.ts" + "test:unit": "node --import tsx/esm --test tests/unit/metadata.test.ts tests/unit/schema.test.ts tests/unit/registry.test.ts tests/unit/monitorTopology.test.ts tests/unit/trayState.test.ts", + "test:unit:coverage": "node --import tsx/esm --experimental-test-coverage --test tests/unit/metadata.test.ts tests/unit/schema.test.ts tests/unit/registry.test.ts tests/unit/monitorTopology.test.ts tests/unit/trayState.test.ts" }, "devDependencies": { "@eslint/js": "^9.39.1", diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh index ea9e976..60dfdbf 100755 --- a/scripts/bump-version.sh +++ b/scripts/bump-version.sh @@ -29,11 +29,12 @@ node -e " const file = '$METADATA_FILE'; const data = JSON.parse(fs.readFileSync(file, 'utf8')); - data.version = '$VERSION'; - + data['version-name'] = '$VERSION'; + delete data['version']; + // Extract major version (e.g., 50.1 -> 50) const majorVersion = '$VERSION'.split('.')[0]; - + // Also optionally update the shell-version array if needed if (!data['shell-version'].includes(majorVersion)) { data['shell-version'].push(majorVersion); diff --git a/src/core/context.ts b/src/core/context.ts index 41e3bfa..363ca03 100644 --- a/src/core/context.ts +++ b/src/core/context.ts @@ -1,5 +1,4 @@ import GObject from '@girs/gobject-2.0'; -import type { Logger } from './logger.ts'; import type { SettingsManager } from './settings.ts'; import type { ShellEnvironment } from './adapters/shell.ts'; @@ -14,7 +13,6 @@ export class AuroraSignals extends GObject.Object {} export interface ExtensionContext { readonly uuid: string; readonly path: string; - readonly logger: Logger; readonly settings: SettingsManager; readonly shell: ShellEnvironment; readonly signals: AuroraSignals; @@ -26,7 +24,6 @@ export class DefaultExtensionContext implements ExtensionContext { constructor( public readonly uuid: string, public readonly path: string, - public readonly logger: Logger, public readonly settings: SettingsManager, public readonly shell: ShellEnvironment, ) { diff --git a/src/core/logger.ts b/src/core/logger.ts index 974dec9..7b00af9 100644 --- a/src/core/logger.ts +++ b/src/core/logger.ts @@ -1,10 +1,19 @@ import GLib from '@girs/glib-2.0'; +export type LogOptions = { + prefix?: string; +}; + export interface Logger { + log(msg: string, options: LogOptions, ...args: any[]): void; log(msg: string, ...args: any[]): void; + debug(msg: string, options: LogOptions, ...args: any[]): void; debug(msg: string, ...args: any[]): void; + info(msg: string, options: LogOptions, ...args: any[]): void; info(msg: string, ...args: any[]): void; + warn(msg: string, options: LogOptions, ...args: any[]): void; warn(msg: string, ...args: any[]): void; + error(msg: string, options: LogOptions, ...args: any[]): void; error(msg: string, ...args: any[]): void; } @@ -17,9 +26,25 @@ export class ConsoleLogger implements Logger { this._uuid = uuid; } - private _fmt(msg: string, args: any[]): string { + private _splitArgs(args: any[]): { options: LogOptions; args: any[] } { + const [first, ...rest] = args; + if (this._isLogOptions(first)) return { options: first, args: rest }; + return { options: {}, args }; + } + + private _isLogOptions(value: unknown): value is LogOptions { + return ( + typeof value === 'object' && + value !== null && + 'prefix' in value && + (value as LogOptions).prefix !== undefined + ); + } + + private _fmt(msg: string, args: any[], options: LogOptions): string { const suffix = args.length ? ` ${args.map((a) => String(a)).join(' ')}` : ''; - return `[${this._prefix}] ${msg}${suffix}`; + const body = `${msg}${suffix}`; + return options.prefix ? `[${options.prefix}] ${body}` : body; } private _emit(level: GLib.LogLevelFlags, msg: string): void { @@ -30,22 +55,41 @@ export class ConsoleLogger implements Logger { } log(msg: string, ...args: any[]): void { - this._emit(GLib.LogLevelFlags.LEVEL_MESSAGE, this._fmt(msg, args)); + const { options, args: rest } = this._splitArgs(args); + this._emit(GLib.LogLevelFlags.LEVEL_MESSAGE, this._fmt(msg, rest, options)); } debug(msg: string, ...args: any[]): void { - this._emit(GLib.LogLevelFlags.LEVEL_DEBUG, this._fmt(msg, args)); + const { options, args: rest } = this._splitArgs(args); + this._emit(GLib.LogLevelFlags.LEVEL_DEBUG, this._fmt(msg, rest, options)); } info(msg: string, ...args: any[]): void { - this._emit(GLib.LogLevelFlags.LEVEL_MESSAGE, this._fmt(msg, args)); + const { options, args: rest } = this._splitArgs(args); + this._emit(GLib.LogLevelFlags.LEVEL_MESSAGE, this._fmt(msg, rest, options)); } warn(msg: string, ...args: any[]): void { - this._emit(GLib.LogLevelFlags.LEVEL_WARNING, this._fmt(msg, args)); + const { options, args: rest } = this._splitArgs(args); + this._emit(GLib.LogLevelFlags.LEVEL_WARNING, this._fmt(msg, rest, options)); } error(msg: string, ...args: any[]): void { - this._emit(GLib.LogLevelFlags.LEVEL_CRITICAL, this._fmt(msg, args)); + const { options, args: rest } = this._splitArgs(args); + this._emit(GLib.LogLevelFlags.LEVEL_CRITICAL, this._fmt(msg, rest, options)); } } + +let _activeLogger: Logger = new ConsoleLogger(); + +export const logger: Logger = { + log: (...args) => _activeLogger.log(...args), + debug: (...args) => _activeLogger.debug(...args), + info: (...args) => _activeLogger.info(...args), + warn: (...args) => _activeLogger.warn(...args), + error: (...args) => _activeLogger.error(...args), +}; + +export function setGlobalLogger(l: Logger): void { + _activeLogger = l; +} diff --git a/src/extension.ts b/src/extension.ts index 0becc04..8f0f81a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -8,10 +8,12 @@ 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 } from '~/core/logger.ts'; +import { ConsoleLogger, setGlobalLogger, logger } from '~/core/logger.ts'; import { GSettingsManager } from '~/core/settings.ts'; import { GnomeShellAdapter } from '~/core/adapters/shell.ts'; +const LOG_PREFIX = 'AuroraShell'; + /** * Aurora Shell Extension * @@ -24,14 +26,14 @@ export default class AuroraShellExtension extends Extension { private _context: ExtensionContext | null = null; override enable(): void { - const logger = new ConsoleLogger('Aurora Shell', this.uuid); - logger.log('Enabling extension'); + const consoleLogger = new ConsoleLogger('Aurora Shell', this.uuid); + setGlobalLogger(consoleLogger); + consoleLogger.log('Enabling extension', { prefix: LOG_PREFIX }); this._settings = this.getSettings(); this._context = new DefaultExtensionContext( this.uuid, this.path, - logger, new GSettingsManager(this._settings), new GnomeShellAdapter(), ); @@ -55,7 +57,7 @@ export default class AuroraShellExtension extends Extension { try { module.enable(); } catch (e) { - this._context!.logger.error(`Failed to enable module ${name}: ${e}`); + logger.error(`Failed to enable module ${name}: ${e}`, { prefix: LOG_PREFIX }); } } } @@ -79,27 +81,27 @@ export default class AuroraShellExtension extends Extension { const existing = this._modules.get(def.key); if (enabled && !existing) { - this._context!.logger.log(`Enabling module ${def.key}`); + logger.log(`Enabling module ${def.key}`, { prefix: LOG_PREFIX }); try { const module = def.factory(this._context!); module.enable(); this._modules.set(def.key, module); } catch (e) { - this._context!.logger.error(`Failed to enable module ${def.key}: ${e}`); + logger.error(`Failed to enable module ${def.key}: ${e}`, { prefix: LOG_PREFIX }); } } else if (!enabled && existing) { - this._context!.logger.log(`Disabling module ${def.key}`); + logger.log(`Disabling module ${def.key}`, { prefix: LOG_PREFIX }); try { existing.disable(); this._modules.delete(def.key); } catch (e) { - this._context!.logger.error(`Failed to disable module ${def.key}: ${e}`); + logger.error(`Failed to disable module ${def.key}: ${e}`, { prefix: LOG_PREFIX }); } } } override disable(): void { - this._context!.logger.log('Disabling extension'); + logger.log('Disabling extension', { prefix: LOG_PREFIX }); this._settings?.disconnectObject(this); @@ -107,7 +109,7 @@ export default class AuroraShellExtension extends Extension { try { module.disable(); } catch (e) { - this._context!.logger.error(`Failed to disable module ${name}: ${e}`); + logger.error(`Failed to disable module ${name}: ${e}`, { prefix: LOG_PREFIX }); } } diff --git a/src/modules/autoThemeSwitcher/autoThemeSwitcher.ts b/src/modules/autoThemeSwitcher/autoThemeSwitcher.ts index eec4624..96f18b5 100644 --- a/src/modules/autoThemeSwitcher/autoThemeSwitcher.ts +++ b/src/modules/autoThemeSwitcher/autoThemeSwitcher.ts @@ -4,6 +4,7 @@ import GLib from '@girs/glib-2.0'; import Gio from '@girs/gio-2.0'; 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'; @@ -23,6 +24,7 @@ const TIME_KEYS = [ 'auto-theme-switcher-dark-hours', 'auto-theme-switcher-dark-minutes', ] as const; +const LOG_PREFIX = 'AutoThemeSwitcher'; export class AutoThemeSwitcher extends Module { private _sourceId: number | null = null; @@ -62,7 +64,7 @@ export class AutoThemeSwitcher extends Module { ); this._tick(); } catch (error) { - this.context.logger.error('AutoThemeSwitcher: Failed to enable:', error); + logger.error('Failed to enable:', { prefix: LOG_PREFIX }, error); } } @@ -106,9 +108,9 @@ export class AutoThemeSwitcher extends Module { if (current_scheme !== scheme) { this._desktopSettings.setString('color-scheme', scheme); - this.context.logger.debug(`AutoThemeSwitcher: applied ${scheme}`); + logger.debug(`applied ${scheme}`, { prefix: LOG_PREFIX }); } else { - this.context.logger.debug(`AutoThemeSwitcher: already on ${scheme}`); + logger.debug(`already on ${scheme}`, { prefix: LOG_PREFIX }); } let next = isLight ? dark : light; diff --git a/src/modules/bluetoothMenu/bluetoothMenu.ts b/src/modules/bluetoothMenu/bluetoothMenu.ts index 43cdff0..886a4da 100644 --- a/src/modules/bluetoothMenu/bluetoothMenu.ts +++ b/src/modules/bluetoothMenu/bluetoothMenu.ts @@ -4,10 +4,13 @@ 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 type { ModuleDefinition } from '~/module.ts'; import { BluetoothDeviceItemPatcher } from '~/modules/bluetoothMenu/deviceItem.ts'; +const LOG_PREFIX = 'BluetoothMenu'; + export class BluetoothMenu extends Module { private _toggle: any = null; private _patchers = new Map(); @@ -28,7 +31,7 @@ export class BluetoothMenu extends Module { const grid = Main.panel.statusArea.quickSettings?.menu?._grid; if (!grid) { - console.error('Aurora Shell: BluetoothMenu could not find quick settings grid'); + logger.error('Could not find quick settings grid', { prefix: LOG_PREFIX }); return; } diff --git a/src/modules/bluetoothMenu/deviceItem.ts b/src/modules/bluetoothMenu/deviceItem.ts index 7f4f92b..10d0f74 100644 --- a/src/modules/bluetoothMenu/deviceItem.ts +++ b/src/modules/bluetoothMenu/deviceItem.ts @@ -5,11 +5,13 @@ import GLib from '@girs/glib-2.0'; import St from '@girs/st-18'; import Clutter from '@girs/clutter-18'; +import { logger } from '~/core/logger.ts'; import { loadIcon } from '~/shared/icons.ts'; const COLOR_DISCONNECTED = '#9a9a9a'; const COLOR_CONNECTED = '#1c71d8'; const COLOR_ANIMATING = '#3584e4'; +const LOG_PREFIX = 'BluetoothMenu'; export class BluetoothDeviceItemPatcher { private _item: any; @@ -34,7 +36,9 @@ export class BluetoothDeviceItemPatcher { // The parent PopupMenuBase listens to 'activate' and calls menu.close(); // calling _toggleConnected() directly bypasses that signal path. item.activate = (_event: any) => { - item._toggleConnected().catch(console.error); + item + ._toggleConnected() + .catch((e: Error) => logger.error(`toggleConnected failed: ${e}`, { prefix: LOG_PREFIX })); }; // Remove icon, subtitle, and spinner from actor hierarchy entirely. diff --git a/src/modules/iconWeave/iconWeave.ts b/src/modules/iconWeave/iconWeave.ts index 0a3ea05..b9feb18 100644 --- a/src/modules/iconWeave/iconWeave.ts +++ b/src/modules/iconWeave/iconWeave.ts @@ -4,11 +4,13 @@ import GioUnix from '@girs/giounix-2.0'; import Shell from '@girs/shell-18'; 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'; const WINDOW_INSPECT_DELAY_MS = 500; const MIN_MATCH_SCORE = 50; +const LOG_PREFIX = 'IconWeave'; const BLACKLISTED_PREFIXES = [ 'org.gnome', @@ -389,9 +391,9 @@ export class IconWeave extends Module { const title: string = win.get_title() ?? ''; - this.context.logger.log( - `[IconWeave] untracked window: title="${title}" wm_class="${wmClass}" app_id="${appId}"`, - ); + logger.log(`untracked window: title="${title}" wm_class="${wmClass}" app_id="${appId}"`, { + prefix: LOG_PREFIX, + }); const appSystem = Shell.AppSystem.get_default(); @@ -400,9 +402,9 @@ export class IconWeave extends Module { // renders, eliminating the generic-icon flash for most apps. const deterministic = this._deterministicMatch(appSystem, wmClass, appId, title); if (deterministic) { - this.context.logger.log( - `[IconWeave] deterministic match found: ${deterministic.get_id()} — applying`, - ); + logger.log(`deterministic match found: ${deterministic.get_id()} — applying`, { + prefix: LOG_PREFIX, + }); this._windowAppMap.set(win, deterministic); this.context.signals.emit('icons-woven'); tracker.emit('tracked-windows-changed'); @@ -426,20 +428,20 @@ export class IconWeave extends Module { const candidate = this._heuristicMatch(appSystem, wmClass, appId, title); if (candidate) { - this.context.logger.log( - `[IconWeave] heuristic match found: ${candidate.get_id()} — applying`, - ); + logger.log(`heuristic match found: ${candidate.get_id()} — applying`, { + prefix: LOG_PREFIX, + }); this._windowAppMap.set(win, candidate); this.context.signals.emit('icons-woven'); tracker.emit('tracked-windows-changed'); candidate.emit('windows-changed'); } else { - this.context.logger.log(`[IconWeave] no candidate found for wm_class="${wmClass}"`); + logger.log(`no candidate found for wm_class="${wmClass}"`, { prefix: LOG_PREFIX }); } this._processed.add(dedupeKey); } catch (e) { - this.context.logger.log(`[IconWeave] _inspectWindow error: ${e}`); + logger.log(`_inspectWindow error: ${e}`, { prefix: LOG_PREFIX }); } } @@ -506,9 +508,9 @@ export class IconWeave extends Module { } if (bestScore >= MIN_MATCH_SCORE) { - this.context.logger.log( - `[IconWeave] heuristic match score=${bestScore}: ${bestApp.get_id()}`, - ); + logger.log(`heuristic match score=${bestScore}: ${bestApp.get_id()}`, { + prefix: LOG_PREFIX, + }); return bestApp; } diff --git a/src/modules/privacy/dndOnShare.ts b/src/modules/privacy/dndOnShare.ts index 4216887..92a073b 100644 --- a/src/modules/privacy/dndOnShare.ts +++ b/src/modules/privacy/dndOnShare.ts @@ -1,10 +1,12 @@ import type { ExtensionContext } from '~/core/context.ts'; +import { logger } from '~/core/logger.ts'; import { Module } from '~/module.ts'; import * as Main from '@girs/gnome-shell/ui/main'; import type { SettingsManager } from '~/core/settings.ts'; const NOTIFICATIONS_SCHEMA = 'org.gnome.desktop.notifications'; const SHOW_BANNERS_KEY = 'show-banners'; +const LOG_PREFIX = 'DndOnShare'; export class DndOnShare extends Module { private _notificationsSettings: SettingsManager | null = null; @@ -42,7 +44,7 @@ export class DndOnShare extends Module { this._syncDndState(true); } } else { - console.warn('[aurora-shell] Screen sharing indicator not found for DndOnShare module'); + logger.warn('Screen sharing indicator not found', { prefix: LOG_PREFIX }); } } diff --git a/src/modules/privacy/privacyPanel.ts b/src/modules/privacy/privacyPanel.ts index 00baaf4..4fc2d8a 100644 --- a/src/modules/privacy/privacyPanel.ts +++ b/src/modules/privacy/privacyPanel.ts @@ -2,12 +2,14 @@ import Clutter from '@girs/clutter-18'; 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'; const FADE_DURATION = 200; const EASE_MODE = Clutter.AnimationMode.EASE_OUT_QUAD; // _rightBox is handled per-child; indicator restored explicitly after fade const FULL_BOXES = ['_leftBox', '_centerBox'] as const; +const LOG_PREFIX = 'PrivacyPanel'; export class PrivacyPanel extends Module { private _isSharing = false; @@ -24,7 +26,7 @@ export class PrivacyPanel extends Module { const indicator = this._getSharingIndicator(); if (!indicator) { - console.warn('[aurora-shell] Screen sharing indicator not found for PrivacyPanel module'); + logger.warn('Screen sharing indicator not found', { prefix: LOG_PREFIX }); return; } diff --git a/src/modules/themeChanger/themeChanger.ts b/src/modules/themeChanger/themeChanger.ts index 5b8f8a7..8792cb4 100644 --- a/src/modules/themeChanger/themeChanger.ts +++ b/src/modules/themeChanger/themeChanger.ts @@ -2,10 +2,13 @@ import '@girs/gjs'; import { gettext as _ } from 'gettext'; 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'; + /** * ThemeChanger Module * @@ -24,21 +27,21 @@ export class ThemeChanger extends Module { } public enable(): void { - this.context.logger.debug('Initializing theme monitor22222'); + logger.debug('Initializing theme monitor22222', { prefix: LOG_PREFIX }); try { this._settings = this.context.settings.getSchema('org.gnome.desktop.interface'); const currentScheme = this._settings.getString('color-scheme'); - this.context.logger.debug(`Current color-scheme: ${currentScheme}`); + logger.debug(`Current color-scheme: ${currentScheme}`, { prefix: LOG_PREFIX }); this._signalId = this._settings.connect('changed::color-scheme', () => { this._onColorSchemeChanged(); }); - this.context.logger.debug('Theme monitor active'); + logger.debug('Theme monitor active', { prefix: LOG_PREFIX }); } catch (error) { - this.context.logger.error('Failed to initialize:', error); + logger.error('Failed to initialize:', { prefix: LOG_PREFIX }, error); } } @@ -46,17 +49,17 @@ export class ThemeChanger extends Module { if (!this._settings) return; const scheme = this._settings.getString('color-scheme'); - this.context.logger.debug(`Color scheme changed to: ${scheme}`); + logger.debug(`Color scheme changed to: ${scheme}`, { prefix: LOG_PREFIX }); if (scheme === 'default') { - this.context.logger.warn('Detected "default", forcing to prefer-light'); + logger.log('Detected "default", forcing to prefer-light', { prefix: LOG_PREFIX }); this._settings.setString('color-scheme', 'prefer-light'); return; } } override disable(): void { - this.context.logger.debug('Disabling theme monitor'); + logger.debug('Disabling theme monitor', { prefix: LOG_PREFIX }); if (this._signalId && this._settings) { this._settings.disconnect(this._signalId); diff --git a/src/modules/trayIcons/backgroundAppsSource.ts b/src/modules/trayIcons/backgroundAppsSource.ts new file mode 100644 index 0000000..7e59924 --- /dev/null +++ b/src/modules/trayIcons/backgroundAppsSource.ts @@ -0,0 +1,158 @@ +// src/modules/trayIcons/backgroundAppsSource.ts +import '@girs/gjs'; +import { gettext as _ } from 'gettext'; + +import Gio from '@girs/gio-2.0'; +import GLib from '@girs/glib-2.0'; +import Shell from '@girs/shell-18'; + +import type { TrayItem, TrayItemStatus } from './trayState.ts'; +import { logger } from '~/core/logger.ts'; + +const DBUS_NAME = 'org.freedesktop.background.Monitor'; +const DBUS_OBJECT = '/org/freedesktop/background/monitor'; +const LOG_PREFIX = 'AuroraTray'; + +const BACKGROUND_MONITOR_XML = ` + + + + + +`; + +const BackgroundMonitorProxy = Gio.DBusProxy.makeProxyWrapper(BACKGROUND_MONITOR_XML); + +// @ts-ignore — _promisify is a GJS extension not reflected in .d.ts +Gio._promisify(Gio.DBusConnection.prototype, 'call'); + +type Callbacks = { + onItemAdded(item: TrayItem): void; + onItemRemoved(id: string): void; +}; + +export class BackgroundAppsSource { + private _proxy: Gio.DBusProxy | null = null; + private _cancellable: Gio.Cancellable | null = null; + private _knownIds = new Map(); + private _proxyChangedId = 0; + private _callbacks: Callbacks; + private _appSystem: Shell.AppSystem; + constructor(callbacks: Callbacks) { + this._callbacks = callbacks; + this._appSystem = Shell.AppSystem.get_default(); + } + + async start(): Promise { + this._cancellable = new Gio.Cancellable(); + try { + this._proxy = new (BackgroundMonitorProxy as any)( + Gio.DBus.session, + DBUS_NAME, + DBUS_OBJECT, + this._cancellable, + ) as Gio.DBusProxy; + + this._proxyChangedId = this._proxy.connect('g-properties-changed', () => this._sync()); + this._sync(); + } catch (e) { + if (!(e as any)?.matches?.(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) { + logger.warn(`BackgroundApps proxy unavailable: ${e}`, { prefix: LOG_PREFIX }); + } + this._proxy = null; + } + } + + private _sync(): void { + if (!this._proxy) return; + + const backgroundApps = (this._proxy as any).BackgroundApps; + const currentApps = new Map(); + + if (backgroundApps) { + // GJS might have already unpacked the aa{sv} into an array of objects + const apps = Array.isArray(backgroundApps) ? backgroundApps : []; + + for (const appData of apps) { + if (!appData || typeof appData !== 'object') continue; + + // makeProxyWrapper unpacks aa{sv} to JS array of objects, but each + // value in the a{sv} dict is still a GLib.Variant (the 'v' box is not + // unwrapped). Handle both that case and fully-unpacked plain strings. + const dict: Record = + appData instanceof GLib.Variant + ? (appData.deep_unpack() as Record) + : (appData as Record); + + const unpackStr = (val: unknown): string | undefined => { + if (val instanceof GLib.Variant) return val.unpack() as string; + if (typeof val === 'string') return val; + return undefined; + }; + + const appId = unpackStr(dict['app_id']); + const message = unpackStr(dict['message']) ?? null; + + if (!appId) continue; + + const app = this._appSystem.lookup_app(`${appId}.desktop`); + if (!app) continue; + if (currentApps.has(appId)) continue; + currentApps.set(appId, { app, message }); + } + } + + // Remove gone apps + for (const [id] of this._knownIds) { + if (!currentApps.has(id)) { + logger.log(`BG app removed from monitor: ${id}`, { prefix: LOG_PREFIX }); + this._knownIds.delete(id); + this._callbacks.onItemRemoved(`bg:${id}`); + } + } + + // Add new apps + for (const [appId, { app, message }] of currentApps) { + if (!this._knownIds.has(appId)) { + logger.log(`BG app found in monitor: ${appId}`, { prefix: LOG_PREFIX }); + const item = this._makeItem(appId, app, message); + this._knownIds.set(appId, item); + this._callbacks.onItemAdded(item); + } + } + } + + private _makeItem(appId: string, app: Shell.App, message: string | null): TrayItem { + return { + id: `bg:${appId}`, + get icon() { + return app.get_icon() ?? 'application-x-executable-symbolic'; + }, + get tooltip() { + return message ?? ''; + }, + get status(): TrayItemStatus { + return 'Active'; + }, + activate(_x: number, _y: number) { + app.activate(); + }, + menuItems: [ + { label: _('Open'), action: () => app.activate() }, + { label: _('Quit'), action: () => app.request_quit() }, + ], + destroy() {}, + }; + } + + destroy(): void { + this._cancellable?.cancel(); + this._cancellable = null; + if (this._proxy && this._proxyChangedId) { + this._proxy.disconnect(this._proxyChangedId); + this._proxyChangedId = 0; + } + this._proxy = null; + this._knownIds.clear(); + } +} diff --git a/src/modules/trayIcons/dbusMenu.ts b/src/modules/trayIcons/dbusMenu.ts new file mode 100644 index 0000000..3463d38 --- /dev/null +++ b/src/modules/trayIcons/dbusMenu.ts @@ -0,0 +1,241 @@ +// src/modules/trayIcons/dbusMenu.ts +import '@girs/gjs'; +import Gio from '@girs/gio-2.0'; +import GLib from '@girs/glib-2.0'; +import Clutter from '@girs/clutter-18'; +import * as PopupMenu from '@girs/gnome-shell/ui/popupMenu'; +import { logger } from '~/core/logger.ts'; + +const DBUS_MENU_IFACE = 'com.canonical.dbusmenu'; +const LOG_PREFIX = 'AuroraTray'; + +const DBUS_MENU_XML = ` + + + + + + + + + + + + + + + + + + + + +`; + +const DBusMenuInterfaceInfo = Gio.DBusInterfaceInfo.new_for_xml(DBUS_MENU_XML); + +// @ts-ignore — _promisify is a GJS extension not reflected in .d.ts +Gio._promisify(Gio.DBusProxy.prototype, 'init_async'); +// @ts-ignore +Gio._promisify(Gio.DBusProxy.prototype, 'call'); + +type MenuNode = { + id: number; + label: string; + type: string; + enabled: boolean; + visible: boolean; + children: MenuNode[]; +}; + +export class DBusMenuClient { + private _proxy: Gio.DBusProxy | null = null; + private _busName: string; + private _objectPath: string; + private _cancellable: Gio.Cancellable; + private _initialized = false; + + constructor(busName: string, objectPath: string) { + this._busName = busName; + this._objectPath = objectPath; + this._cancellable = new Gio.Cancellable(); + } + + async init(): Promise { + if (this._initialized) return; + this._initialized = true; + + const proxy = new Gio.DBusProxy({ + g_connection: Gio.DBus.session, + g_name: this._busName, + g_object_path: this._objectPath, + g_interface_name: DBUS_MENU_IFACE, + g_interface_info: DBusMenuInterfaceInfo, + g_flags: Gio.DBusProxyFlags.DO_NOT_LOAD_PROPERTIES, + }); + + try { + await proxy.init_async(GLib.PRIORITY_DEFAULT, this._cancellable); + this._proxy = proxy; + } catch (e) { + if (!this._cancellable.is_cancelled()) { + logger.warn(`DBusMenu init failed for ${this._busName}: ${e}`, { prefix: LOG_PREFIX }); + } + } + } + + async updateMenu(menu: PopupMenu.PopupMenu): Promise { + if (!this._proxy) return; + + // Signal the app to prepare the menu — required by some apps (e.g. Dropbox) + try { + await (this._proxy as any).call( + 'AboutToShow', + new GLib.Variant('(i)', [0]), + Gio.DBusCallFlags.NONE, + -1, + null, + ); + } catch { + // Not all apps implement AboutToShow; ignore errors silently + } + + try { + const res = await (this._proxy as any).call( + 'GetLayout', + new GLib.Variant('(iias)', [0, -1, []]), + Gio.DBusCallFlags.NONE, + -1, + null, + ); + + let layout: unknown; + if (res instanceof GLib.Variant) { + layout = (res.deep_unpack() as [number, unknown])[1]; + } else if (Array.isArray(res)) { + const rawLayout = res[1]; + layout = rawLayout instanceof GLib.Variant ? rawLayout.deep_unpack() : rawLayout; + } else { + throw new Error('Unexpected GetLayout response format'); + } + + if (!Array.isArray(layout)) { + throw new Error('Layout data is missing or not an array'); + } + + const nodes = this._parseChildren(layout); + + menu.removeAll(); + for (const node of nodes) { + this._renderNode(menu, node); + } + } catch (e) { + logger.warn(`GetLayout failed for ${this._busName}: ${e}`, { prefix: LOG_PREFIX }); + } + } + + private _parseChildren(layout: unknown[]): MenuNode[] { + if (layout.length < 3) return []; + const childArray = layout[2]; + if (!Array.isArray(childArray)) return []; + return (childArray as unknown[]) + .map((c) => this._parseNode(c)) + .filter((n): n is MenuNode => n !== null); + } + + private _parseNode(raw: unknown): MenuNode | null { + const data: unknown[] = + raw instanceof GLib.Variant ? (raw.deep_unpack() as unknown[]) : (raw as unknown[]); + + if (!Array.isArray(data) || data.length < 3) return null; + + const id = data[0] as number; + const props = data[1] as Record; + const childArray = data[2] as unknown[]; + + const get = (key: string, def: unknown): unknown => { + const v = props[key]; + if (v instanceof GLib.Variant) return v.unpack(); + return v ?? def; + }; + + return { + id, + label: String(get('label', '')), + type: String(get('type', 'standard')), + enabled: Boolean(get('enabled', true)), + visible: Boolean(get('visible', true)), + children: Array.isArray(childArray) + ? (childArray as unknown[]) + .map((c) => this._parseNode(c)) + .filter((n): n is MenuNode => n !== null) + : [], + }; + } + + private _renderNode(target: PopupMenu.PopupMenu | PopupMenu.PopupSubMenu, node: MenuNode): void { + if (!node.visible) return; + + if (node.type === 'separator') { + target.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + return; + } + + // Convert GTK mnemonics (e.g. "_File") to plain text + const cleanLabel = node.label.replace(/_([^ _])/g, '$1'); + + if (node.children.length > 0) { + const sub = new PopupMenu.PopupSubMenuMenuItem(cleanLabel); + sub.setSensitive(node.enabled); + target.addMenuItem(sub); + for (const child of node.children) { + this._renderNode(sub.menu, child); + } + return; + } + + const item = new PopupMenu.PopupMenuItem(cleanLabel); + item.setSensitive(node.enabled); + item.connect('activate', (_item, event: Clutter.Event | null) => + this._sendEvent(node.id, event), + ); + target.addMenuItem(item); + } + + private _sendEvent(id: number, event: Clutter.Event | null): void { + if (!this._proxy) return; + + const timestamp = this._eventTimestamp(event); + try { + this._proxy.call( + 'Event', + new GLib.Variant('(isvu)', [id, 'clicked', new GLib.Variant('i', 0), timestamp]), + Gio.DBusCallFlags.NONE, + -1, + null, + (p, res) => { + try { + (p as any).call_finish(res); + } catch (e) { + logger.warn(`DBusMenu Event failed for id ${id}: ${e}`, { prefix: LOG_PREFIX }); + } + }, + ); + } catch (e) { + logger.warn(`DBusMenu Event could not be sent for id ${id}: ${e}`, { + prefix: LOG_PREFIX, + }); + } + } + + private _eventTimestamp(event: Clutter.Event | null): number { + const timestamp = event?.get_time() ?? Clutter.get_current_event_time(); + if (!Number.isFinite(timestamp) || timestamp < 0 || timestamp > 0xffffffff) return 0; + return Math.round(timestamp); + } + + destroy(): void { + this._cancellable.cancel(); + this._proxy = null; + } +} diff --git a/src/modules/trayIcons/sniHost.ts b/src/modules/trayIcons/sniHost.ts new file mode 100644 index 0000000..0b181f2 --- /dev/null +++ b/src/modules/trayIcons/sniHost.ts @@ -0,0 +1,485 @@ +// src/modules/trayIcons/sniHost.ts +import '@girs/gjs'; + +import Gio from '@girs/gio-2.0'; +import GLib from '@girs/glib-2.0'; +import GdkPixbuf from '@girs/gdkpixbuf-2.0'; +import St from '@girs/st-18'; + +import type { TrayItem, TrayItemStatus } from './trayState.ts'; +import type { SniWatcher } from './sniWatcher.ts'; +import { logger } from '~/core/logger.ts'; + +const SNI_ITEM_XML = ` + + + + + + + + + + + + + + + + + + + + + +`; + +const SniItemInterfaceInfo = Gio.DBusInterfaceInfo.new_for_xml(SNI_ITEM_XML); +const MIN_PIXMAP_SIZE = 8; +const SYMBOLIC_CHANNEL_TOLERANCE = 18; +const SYMBOLIC_REQUIRED_RATIO = 0.92; +const LIGHT_PANEL_ICON = [48, 48, 48] as const; +const DARK_PANEL_ICON = [250, 250, 251] as const; +const LOG_PREFIX = 'AuroraTray'; + +// @ts-ignore — _promisify is a GJS extension not reflected in .d.ts +Gio._promisify(Gio.DBusProxy.prototype, 'init_async'); + +type HostCallbacks = { + onItemAdded(item: TrayItem): void; + onItemRemoved(id: string): void; + onStatusChanged(id: string, status: TrayItemStatus): void; + onIconChanged(id: string): void; +}; + +type SniHostOptions = { + getColorScheme?: () => string; + shouldRecolorSymbolicPixmaps?: () => boolean; +}; + +type SniEntry = { + proxy: Gio.DBusProxy; + item: TrayItem; + sniId: string; // SNI Id property — used for app-id-based dedup fallback + signalId: number; + nameWatchId: number; + cancellable: Gio.Cancellable; +}; + +export class SniHost { + private _entries = new Map(); + private _callbacks: HostCallbacks; + private _watcher: SniWatcher; + private _getColorScheme: () => string; + private _shouldRecolorSymbolicPixmaps: () => boolean; + + constructor(watcher: SniWatcher, callbacks: HostCallbacks, options: SniHostOptions = {}) { + this._watcher = watcher; + this._callbacks = callbacks; + this._getColorScheme = options.getColorScheme ?? (() => 'prefer-dark'); + this._shouldRecolorSymbolicPixmaps = options.shouldRecolorSymbolicPixmaps ?? (() => true); + } + + async registerItem(busName: string, objectPath: string): Promise { + const id = `${busName}${objectPath}`; + if (this._entries.has(id)) return; + + const cancellable = new Gio.Cancellable(); + const proxy = new Gio.DBusProxy({ + g_connection: Gio.DBus.session, + g_interface_name: 'org.kde.StatusNotifierItem', + g_interface_info: SniItemInterfaceInfo, + g_name: busName, + g_object_path: objectPath, + g_flags: Gio.DBusProxyFlags.GET_INVALIDATED_PROPERTIES, + }); + + try { + await proxy.init_async(GLib.PRIORITY_DEFAULT, cancellable); + } catch (e) { + if (!(e as any)?.matches?.(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) { + logger.warn(`Failed to create SNI proxy for ${id}: ${e}`, { prefix: LOG_PREFIX }); + } + return; + } + + const item = this._makeItem(id, proxy); + const sniId = (proxy.get_cached_property('Id')?.unpack() as string | undefined) ?? ''; + + const menuPath = proxy.get_cached_property('Menu')?.unpack() as string | undefined; + logger.log( + `Registered item ${id}. Id=${sniId || '(none)'}. Menu path: ${menuPath || 'none'}. Status: ${item.status}`, + { prefix: LOG_PREFIX }, + ); + + const signalId = proxy.connect('g-signal', (_proxy, _sender, signalName, params) => { + if (signalName === 'NewStatus') { + const newStatus = params.get_child_value(0).unpack() as string; + item.status = newStatus as TrayItemStatus; + this._callbacks.onStatusChanged(id, item.status); + } else if (signalName === 'NewIcon' || signalName === 'NewAttentionIcon') { + item.icon = this._resolveIcon(proxy, signalName); + this._callbacks.onIconChanged(id); + } + }); + + const nameWatchId = Gio.DBus.session.watch_name( + busName, + Gio.BusNameWatcherFlags.NONE, + // GJS accepts plain functions here; types expect GObject.Closure + null as unknown as never, + (() => { + const wasTracked = this._entries.has(id); + this._removeEntry(id); + if (wasTracked) this._watcher.unregisterItem(busName, objectPath); + }) as unknown as never, + ); + + this._entries.set(id, { proxy, item, sniId, signalId, nameWatchId, cancellable }); + this._callbacks.onItemAdded(item); + } + + private _resolveIcon(proxy: Gio.DBusProxy, reason = 'initial'): TrayItem['icon'] { + const status = (proxy.get_cached_property('Status')?.unpack() as string) ?? 'Active'; + const useAttention = status === 'NeedsAttention'; + const itemId = `${proxy.g_name}${proxy.g_object_path}`; + + const iconName = + (proxy + .get_cached_property(useAttention ? 'AttentionIconName' : 'IconName') + ?.unpack() as string) ?? ''; + const iconThemePath = + (proxy.get_cached_property('IconThemePath')?.unpack() as string | undefined) ?? ''; + if (iconName) { + const themedIcon = this._resolveThemedIcon(iconName, iconThemePath, itemId, reason); + if (themedIcon) { + logger.log( + `SNI icon ${itemId} reason=${reason} source=theme-path name=${iconName} path=${iconThemePath}`, + { prefix: LOG_PREFIX }, + ); + return themedIcon; + } + logger.log( + `SNI icon ${itemId} reason=${reason} source=icon-name name=${iconName} path=${iconThemePath || 'none'}`, + { prefix: LOG_PREFIX }, + ); + return iconName; + } + + const pixmaps = proxy.get_cached_property(useAttention ? 'AttentionIconPixmap' : 'IconPixmap'); + if (pixmaps) { + const pb = this._extractPixbuf(pixmaps, itemId, reason); + if (pb) { + logger.log( + `SNI icon ${itemId} reason=${reason} source=pixmap size=${pb.get_width()}x${pb.get_height()}`, + { prefix: LOG_PREFIX }, + ); + return pb; + } + } + + logger.log(`SNI icon ${itemId} reason=${reason} source=fallback`, { prefix: LOG_PREFIX }); + return 'image-missing-symbolic'; + } + + refreshIcons(reason = 'theme-change'): void { + for (const entry of this._entries.values()) { + entry.item.icon = this._resolveIcon(entry.proxy, reason); + this._callbacks.onIconChanged(entry.item.id); + } + } + + private _resolveThemedIcon( + iconName: string, + iconThemePath: string, + itemId: string, + reason: string, + ): Gio.Icon | GdkPixbuf.Pixbuf | null { + if (!iconThemePath) return null; + + try { + const theme = St.IconTheme.new(); + theme.append_search_path(iconThemePath); + const iconInfo = theme.lookup_icon(iconName, 24, St.IconLookupFlags.FORCE_SIZE); + const filename = iconInfo?.get_filename(); + if (!filename) return null; + + // SVGs go through GTK's symbolic pipeline via St.Icon; return as-is. + if (filename.toLowerCase().endsWith('.svg')) { + return new Gio.FileIcon({ file: Gio.File.new_for_path(filename) }); + } + + // Raster icons (PNG etc.) bypass GTK symbolic colorization — load and recolor manually. + const pixbuf = GdkPixbuf.Pixbuf.new_from_file(filename); + if (!pixbuf) return null; + return this._recolorFilePixbuf(pixbuf, itemId, reason); + } catch (e) { + logger.warn(`Failed to resolve themed SNI icon ${iconName} from ${iconThemePath}: ${e}`, { + prefix: LOG_PREFIX, + }); + return null; + } + } + + private _recolorFilePixbuf( + pixbuf: GdkPixbuf.Pixbuf, + itemId: string, + reason: string, + ): GdkPixbuf.Pixbuf { + if (!this._shouldRecolorSymbolicPixmaps() || pixbuf.get_n_channels() !== 4) return pixbuf; + + const w = pixbuf.get_width(); + const h = pixbuf.get_height(); + const rowstride = pixbuf.get_rowstride(); + const data = pixbuf.get_pixels(); + if (!data) return pixbuf; + + let opaquePixels = 0; + let monochromePixels = 0; + for (let row = 0; row < h; row++) { + const base = row * rowstride; + for (let col = 0; col < w; col++) { + const i = base + col * 4; + const a = data[i + 3]!; + if (a < 16) continue; + opaquePixels++; + const r = data[i]!; + const g = data[i + 1]!; + const b = data[i + 2]!; + if (Math.max(r, g, b) - Math.min(r, g, b) <= SYMBOLIC_CHANNEL_TOLERANCE) monochromePixels++; + } + } + + if (opaquePixels === 0 || monochromePixels / opaquePixels < SYMBOLIC_REQUIRED_RATIO) + return pixbuf; + + const [targetR, targetG, targetB] = this._panelIconColor(); + const pixels = new Uint8Array(w * h * 4); + for (let row = 0; row < h; row++) { + const srcBase = row * rowstride; + const dstBase = row * w * 4; + for (let col = 0; col < w; col++) { + const si = srcBase + col * 4; + const di = dstBase + col * 4; + pixels[di] = targetR; + pixels[di + 1] = targetG; + pixels[di + 2] = targetB; + pixels[di + 3] = data[si + 3]!; + } + } + + logger.log( + `Recolored symbolic theme-path icon ${itemId} reason=${reason} scheme=${this._getColorScheme()} size=${w}x${h}`, + { prefix: LOG_PREFIX }, + ); + + return GdkPixbuf.Pixbuf.new_from_bytes(pixels, GdkPixbuf.Colorspace.RGB, true, 8, w, h, w * 4); + } + + private _extractPixbuf( + variant: GLib.Variant, + itemId: string, + reason: string, + ): GdkPixbuf.Pixbuf | null { + const n = variant.n_children(); + if (n === 0) return null; + + let bestChild = variant.get_child_value(0); + let maxW = 0; + for (let i = 0; i < n; i++) { + const child = variant.get_child_value(i); + const w = child.get_child_value(0).unpack() as number; + if (w > maxW) { + maxW = w; + bestChild = child; + } + } + + const w = bestChild.get_child_value(0).unpack() as number; + const h = bestChild.get_child_value(1).unpack() as number; + const data = bestChild.get_child_value(2).get_data_as_bytes(); // GLib.Bytes + + if (!data || w <= 0 || h <= 0) return null; + if (w < MIN_PIXMAP_SIZE || h < MIN_PIXMAP_SIZE) { + logger.log(`Ignoring tiny SNI pixmap ${itemId} reason=${reason} size=${w}x${h}`, { + prefix: LOG_PREFIX, + }); + return null; + } + + // SNI IconPixmap is a(iiay): array of (width, height, ARGB data in network byte order) + // GdkPixbuf expects RGBA. We must swap ARGB -> RGBA. + const unpacked = data.get_data(); + if (!unpacked || unpacked.length < w * h * 4) return null; + + const pixels = new Uint8Array(unpacked.length); + const symbolic = this._shouldRecolorSymbolicPixmaps() && this._isSymbolicPixmap(unpacked, w, h); + const [targetR, targetG, targetB] = this._panelIconColor(); + for (let i = 0; i < unpacked.length; i += 4) { + pixels[i] = symbolic ? targetR : unpacked[i + 1]!; // R + pixels[i + 1] = symbolic ? targetG : unpacked[i + 2]!; // G + pixels[i + 2] = symbolic ? targetB : unpacked[i + 3]!; // B + pixels[i + 3] = unpacked[i]!; // A + } + + if (symbolic) { + logger.log( + `Recolored symbolic SNI pixmap ${itemId} reason=${reason} scheme=${this._getColorScheme()} size=${w}x${h}`, + { prefix: LOG_PREFIX }, + ); + } + + return GdkPixbuf.Pixbuf.new_from_bytes(pixels, GdkPixbuf.Colorspace.RGB, true, 8, w, h, w * 4); + } + + private _panelIconColor(): readonly [number, number, number] { + return this._getColorScheme() === 'prefer-light' ? LIGHT_PANEL_ICON : DARK_PANEL_ICON; + } + + private _isSymbolicPixmap(data: Uint8Array, width: number, height: number): boolean { + let opaquePixels = 0; + let monochromePixels = 0; + const expectedLength = width * height * 4; + + for (let i = 0; i < expectedLength; i += 4) { + const a = data[i]!; + if (a < 16) continue; + + opaquePixels++; + const r = data[i + 1]!; + const g = data[i + 2]!; + const b = data[i + 3]!; + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + if (max - min <= SYMBOLIC_CHANNEL_TOLERANCE) monochromePixels++; + } + + if (opaquePixels === 0) return false; + return monochromePixels / opaquePixels >= SYMBOLIC_REQUIRED_RATIO; + } + + private _makeItem(id: string, proxy: Gio.DBusProxy): TrayItem { + return { + id, + icon: this._resolveIcon(proxy), + get tooltip(): string | undefined { + const raw = proxy.get_cached_property('ToolTip'); + if (!raw) return undefined; + try { + const desc = raw.get_child_value(3).unpack() as string; + if (desc) return desc; + const title = raw.get_child_value(2).unpack() as string; + return title || undefined; + } catch { + return undefined; + } + }, + get status(): TrayItemStatus { + return (proxy.get_cached_property('Status')?.unpack() as TrayItemStatus) ?? 'Active'; + }, + set status(v: TrayItemStatus) { + proxy.set_cached_property('Status', new GLib.Variant('s', v)); + }, + menuBusName: proxy.g_name, + menuObjectPath: proxy.get_cached_property('Menu')?.unpack() as string | undefined, + activate: (x: number, y: number) => { + proxy.call( + 'Activate', + new GLib.Variant('(ii)', [Math.round(x), Math.round(y)]), + Gio.DBusCallFlags.NONE, + -1, + null, + (p, res) => { + try { + p?.call_finish(res); + } catch (e) { + logger.warn(`Activate failed for ${id}: ${e}`, { prefix: LOG_PREFIX }); + } + }, + ); + }, + secondaryActivate: (x: number, y: number) => { + proxy.call( + 'SecondaryActivate', + new GLib.Variant('(ii)', [Math.round(x), Math.round(y)]), + Gio.DBusCallFlags.NONE, + -1, + null, + (p, res) => { + try { + p?.call_finish(res); + } catch (e) { + logger.warn(`SecondaryActivate failed for ${id}: ${e}`, { prefix: LOG_PREFIX }); + } + }, + ); + }, + showMenu: (x: number, y: number) => { + proxy.call( + 'ContextMenu', + new GLib.Variant('(ii)', [Math.round(x), Math.round(y)]), + Gio.DBusCallFlags.NONE, + -1, + null, + (p, res) => { + try { + p?.call_finish(res); + } catch (e) { + logger.warn(`ContextMenu failed for ${id}: ${e}`, { prefix: LOG_PREFIX }); + } + }, + ); + }, + destroy: () => { + this._removeEntry(id); + }, + }; + } + + private _removeEntry(id: string): void { + const entry = this._entries.get(id); + if (!entry) return; + this._entries.delete(id); + + entry.cancellable.cancel(); + entry.proxy.disconnect(entry.signalId); + Gio.DBus.session.unwatch_name(entry.nameWatchId); + + this._callbacks.onItemRemoved(id); + } + + hasItemForBus(busName: string): boolean { + for (const [id, entry] of this._entries) { + if (id.startsWith(`${busName}/`)) return true; + // busName may be a unique name; compare against the proxy's actual unique owner + const uniqueOwner = entry.proxy.g_name_owner; + if (uniqueOwner && uniqueOwner === busName) return true; + } + return false; + } + + // Fallback dedup: match BG app ID against SNI item's Id property. + // 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 lower = appId.toLowerCase(); + // Last dot-component: "com.rtosta.zapzap" → "zapzap" + const lastComponent = lower.split('.').at(-1) ?? lower; + if (lastComponent.length < 4) return false; // too short to match reliably + for (const entry of this._entries.values()) { + if (!entry.sniId) continue; + const sniLower = entry.sniId.toLowerCase(); + // Exact match or BG app ID ends with last component of SNI Id (and vice versa) + if (lower === sniLower) return true; + const sniLast = sniLower.split('.').at(-1) ?? sniLower; + if (sniLast.length >= 4 && lastComponent === sniLast) return true; + } + return false; + } + + destroy(): void { + for (const id of [...this._entries.keys()]) { + this._removeEntry(id); + } + } +} diff --git a/src/modules/trayIcons/sniWatcher.ts b/src/modules/trayIcons/sniWatcher.ts new file mode 100644 index 0000000..0c62707 --- /dev/null +++ b/src/modules/trayIcons/sniWatcher.ts @@ -0,0 +1,189 @@ +// src/modules/trayIcons/sniWatcher.ts +import '@girs/gjs'; + +import Gio from '@girs/gio-2.0'; +import GLib from '@girs/glib-2.0'; + +import { logger } from '~/core/logger.ts'; + +export const SNI_WATCHER_BUS_NAME = 'org.kde.StatusNotifierWatcher'; +const SNI_WATCHER_OBJECT = '/StatusNotifierWatcher'; +const SNI_INTERFACE_NAME = 'org.kde.StatusNotifierWatcher'; +const SNI_DEFAULT_ITEM_PATH = '/StatusNotifierItem'; +const LOG_PREFIX = 'AuroraTray'; + +const WATCHER_XML = ` + + + + + + + + + + + + + + + +`; + +export type SniRegisteredCallback = (busName: string, objectPath: string) => void; +export type SniUnregisteredCallback = (busName: string, objectPath: string) => void; + +export class SniWatcher { + private _registrationId = 0; + private _ownNameId = 0; + private _failed = false; + private _registeredItems: string[] = []; + private _onItemRegistered: SniRegisteredCallback; + private _onItemUnregistered: SniUnregisteredCallback; + + constructor( + onItemRegistered: SniRegisteredCallback, + onItemUnregistered: SniUnregisteredCallback, + ) { + this._onItemRegistered = onItemRegistered; + this._onItemUnregistered = onItemUnregistered; + } + + start(): void { + const ifaceInfo = + Gio.DBusNodeInfo.new_for_xml(WATCHER_XML).lookup_interface(SNI_INTERFACE_NAME)!; + + try { + this._registrationId = Gio.DBus.session.register_object( + SNI_WATCHER_OBJECT, + ifaceInfo, + ( + _conn: Gio.DBusConnection, + sender: string, + _objPath: string, + _ifaceName: string, + methodName: string, + params: GLib.Variant, + invocation: Gio.DBusMethodInvocation, + ) => { + if (methodName === 'RegisterStatusNotifierItem') { + const service = params.get_child_value(0).unpack() as string; + this._handleRegisterItem(service, sender); + } else if (methodName === 'RegisterStatusNotifierHost') { + this._emitSignal('StatusNotifierHostRegistered', null); + } + invocation.return_value(null); + }, + ( + _conn: Gio.DBusConnection, + _sender: string, + _objPath: string, + _ifaceName: string, + propertyName: string, + ): GLib.Variant | null => { + switch (propertyName) { + case 'RegisteredStatusNotifierItems': + return new GLib.Variant('as', this._registeredItems); + case 'IsStatusNotifierHostRegistered': + return new GLib.Variant('b', !this._failed); + case 'ProtocolVersion': + return new GLib.Variant('i', 0); + default: + return null; + } + }, + null, + ); + } catch (e) { + logger.warn(`Failed to register SNI watcher object: ${e}`, { prefix: LOG_PREFIX }); + this._failed = true; + return; + } + + this._ownNameId = Gio.DBus.session.own_name( + SNI_WATCHER_BUS_NAME, + Gio.BusNameOwnerFlags.NONE, + // GJS accepts plain functions here; types expect GObject.Closure + (() => { + logger.info('Acquired org.kde.StatusNotifierWatcher', { prefix: LOG_PREFIX }); + this._emitSignal('StatusNotifierHostRegistered', null); + }) as unknown as never, + (() => { + logger.warn( + 'org.kde.StatusNotifierWatcher already owned by another process. SNI icons disabled.', + { prefix: LOG_PREFIX }, + ); + this._failed = true; + }) as unknown as never, + ); + } + + private _handleRegisterItem(service: string, sender: string): void { + if (this._failed) return; + + let busName: string; + let objectPath: string; + + if (service.startsWith('/')) { + // Bare object path (e.g., Steam): sender is the bus name + busName = sender; + objectPath = service; + logger.log(`SNI bare-path registration from ${sender}: ${service}`, { + prefix: LOG_PREFIX, + }); + } else if (service.includes('/')) { + const slashIdx = service.indexOf('/'); + busName = service.substring(0, slashIdx); + objectPath = service.substring(slashIdx); + } else { + busName = service; + objectPath = SNI_DEFAULT_ITEM_PATH; + } + + const id = `${busName}${objectPath}`; + if (this._registeredItems.includes(id)) return; + this._registeredItems.push(id); + + this._emitSignal('StatusNotifierItemRegistered', new GLib.Variant('(s)', [id])); + this._onItemRegistered(busName, objectPath); + } + + private _emitSignal(signalName: string, params: GLib.Variant | null): void { + try { + Gio.DBus.session.emit_signal( + null, + SNI_WATCHER_OBJECT, + SNI_INTERFACE_NAME, + signalName, + params, + ); + } catch { + // signal emission may fail if unregistered + } + } + + unregisterItem(busName: string, objectPath: string): void { + const id = `${busName}${objectPath}`; + const idx = this._registeredItems.indexOf(id); + if (idx === -1) return; + this._registeredItems.splice(idx, 1); + this._emitSignal('StatusNotifierItemUnregistered', new GLib.Variant('(s)', [id])); + this._onItemUnregistered(busName, objectPath); + } + + destroy(): void { + if (this._ownNameId) { + Gio.DBus.session.unown_name(this._ownNameId); + this._ownNameId = 0; + } + if (this._registrationId) { + try { + Gio.DBus.session.unregister_object(this._registrationId); + } catch { + // may fail if already unregistered + } + this._registrationId = 0; + } + this._registeredItems = []; + } +} diff --git a/src/modules/trayIcons/trayContainer.ts b/src/modules/trayIcons/trayContainer.ts new file mode 100644 index 0000000..55d4d32 --- /dev/null +++ b/src/modules/trayIcons/trayContainer.ts @@ -0,0 +1,538 @@ +// src/modules/trayIcons/trayContainer.ts +import '@girs/gjs'; + +import St from '@girs/st-18'; +import Clutter from '@girs/clutter-18'; +import GObject from '@girs/gobject-2.0'; +import GLib from '@girs/glib-2.0'; +import * as PanelMenu from '@girs/gnome-shell/ui/panelMenu'; + +import { logger } from '~/core/logger.ts'; + +import { + createTrayState, + toggleCollapsed, + applyScroll, + addAttention, + clearAttention, +} from './trayState.ts'; +import type { TrayState, TrayItem } from './trayState.ts'; +import { TrayIconItem, destroyTooltip } from './trayIconItem.ts'; + +const SCROLL_STEP = 28; +const ICON_GAP = 3; +const ITEM_PADDING = 3; // Must match .aurora-tray-icon-item padding in SCSS +const ANIM_DURATION = 600; +const LOG_PREFIX = 'AuroraTray'; + +@GObject.registerClass +class TrayClipArea extends Clutter.Actor { + public fullWidth = 0; + private _viewportWidth = 0; + private _clipStart = 0; + private _viewportTimeoutId = 0; + + override _init(params = {}) { + super._init({ + clip_to_allocation: false, + x_expand: false, + y_expand: true, + ...params, + }); + } + + override vfunc_allocate(box: Clutter.ActorBox): void { + super.vfunc_allocate(box); + const ownH = Math.round(box.y2 - box.y1); + const childW = Math.round(this.fullWidth); + + this._syncClip(); + + const childBox = new Clutter.ActorBox(); + childBox.set_origin(0, 0); + childBox.set_size(childW, ownH); + for (const child of this.get_children()) { + child.allocate(childBox); + } + } + + private _syncClip(): void { + const fullWidth = Math.round(this.fullWidth); + const clipStart = Math.min(fullWidth, Math.max(0, Math.round(this._clipStart))); + const visibleWidth = Math.min( + fullWidth - clipStart, + Math.max(0, Math.round(this._viewportWidth)), + ); + const height = Math.max(0, Math.round(this.height)); + this.set_clip(clipStart, 0, visibleWidth, height); + } + + setViewport(fullWidth: number, viewportWidth: number, clipStart: number): void { + this.fullWidth = Math.round(fullWidth); + this._viewportWidth = viewportWidth; + this._clipStart = clipStart; + this.set_width(this.fullWidth); + this._syncClip(); + } + + animateViewport( + fromViewportWidth: number, + fromClipStart: number, + toViewportWidth: number, + toClipStart: number, + durationMs: number, + onFrame: (viewportWidth: number, clipStart: number) => void, + onComplete: () => void, + ): void { + this.cancelViewportAnimation(); + const startUs = GLib.get_monotonic_time(); + const durationUs = durationMs * 1000; + const viewportDelta = toViewportWidth - fromViewportWidth; + const clipStartDelta = toClipStart - fromClipStart; + + this._viewportTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 16, () => { + const elapsedUs = GLib.get_monotonic_time() - startUs; + const progress = Math.min(1, elapsedUs / durationUs); + const eased = 1 - Math.pow(1 - progress, 3); + + this._viewportWidth = fromViewportWidth + viewportDelta * eased; + this._clipStart = fromClipStart + clipStartDelta * eased; + this._syncClip(); + onFrame(this._viewportWidth, this._clipStart); + + if (progress < 1) return GLib.SOURCE_CONTINUE; + + this._viewportTimeoutId = 0; + this._viewportWidth = toViewportWidth; + this._clipStart = toClipStart; + this._syncClip(); + onFrame(toViewportWidth, toClipStart); + onComplete(); + return GLib.SOURCE_REMOVE; + }); + } + + cancelViewportAnimation(): void { + if (this._viewportTimeoutId > 0) { + GLib.Source.remove(this._viewportTimeoutId); + this._viewportTimeoutId = 0; + } + } + + get viewportWidth(): number { + return this._viewportWidth; + } + + get clipStart(): number { + return this._clipStart; + } + + layoutSnapshot(): string { + const child = this.get_first_child(); + return `reservedWidth=${Math.round(this.width)} viewportWidth=${Math.round(this._viewportWidth)} clipStart=${Math.round(this._clipStart)} allocated=${Math.round(this.allocation.x2 - this.allocation.x1)} fullWidth=${Math.round(this.fullWidth)} childX=${child ? Math.round(child.x) : 'none'} childWidth=${child ? Math.round(child.width) : 'none'}`; + } +} + +@GObject.registerClass +export class TrayContainer extends PanelMenu.Button { + static [GObject.properties] = { + 'anim-scroll': GObject.ParamSpec.double( + 'anim-scroll', + 'anim-scroll', + 'Animated scroll position snapped to pixels', + GObject.ParamFlags.READWRITE, + -10000, + 10000, + 0, + ), + }; + + declare private _state: TrayState; + declare private _iconSize: number; + declare private _limit: number; + declare private _items: Map; + declare private _chevron: St.Button; + declare private _chevronIcon: St.Icon; + declare private _clipArea: TrayClipArea; + declare private _iconRow: St.BoxLayout; + declare private _outerBox: St.BoxLayout; + declare private _userInteracted: boolean; + declare private _attentionTimeoutSeconds: number; + declare private _autoCollapseTimeoutId: number; + declare private _opacityTargets: WeakMap; + declare private _scrollTarget: number; + + private _animScrollValue = 0; + + get anim_scroll(): number { + return this._animScrollValue; + } + + set anim_scroll(v: number) { + this._animScrollValue = v; + const rounded = Math.round(v); + if (this._iconRow.translationX !== rounded) { + this._iconRow.translationX = rounded; + } + } + + override vfunc_allocate(box: Clutter.ActorBox): void { + // Snap the box to integer pixels to avoid parent BoxLayout sub-pixel jitter. + box.x1 = Math.round(box.x1); + box.x2 = Math.round(box.x2); + box.y1 = Math.round(box.y1); + box.y2 = Math.round(box.y2); + super.vfunc_allocate(box); + } + + // @ts-expect-error Our _init signature differs from PanelMenu.Button._init overloads, + // which is the standard GJS GObject subclassing pattern when using custom constructor args. + override _init(iconSize: number, limit: number): void { + super._init(0.0, 'aurora-tray-icons', true); // dontCreateMenu = true + this.add_style_class_name('aurora-tray-button'); + this.track_hover = false; // highlight only individual icon items, not the whole button area + this._state = createTrayState(); + this._iconSize = iconSize; + this._limit = limit; + this._items = new Map(); + this._userInteracted = false; + this._attentionTimeoutSeconds = 5; + this._autoCollapseTimeoutId = 0; + this._opacityTargets = new WeakMap(); + this._scrollTarget = 0; + + // Chevron button (collapse/expand toggle) + this._chevronIcon = new St.Icon({ + icon_name: 'pan-end-symbolic', + icon_size: 14, + style_class: 'aurora-tray-chevron-icon', + }); + this._chevronIcon.set_pivot_point(0.5, 0.5); + this._chevronIcon.rotation_angle_z = 180; // starts collapsed → pointing left + this._chevron = new St.Button({ + child: this._chevronIcon, + style_class: 'aurora-tray-chevron', + can_focus: true, + visible: false, + }); + this._chevron.connect('clicked', () => { + this._userInteracted = true; + toggleCollapsed(this._state); + logger.log(`Chevron toggled collapsed=${this._state.collapsed}`, { prefix: LOG_PREFIX }); + this._syncLayout(true); + }); + + this._iconRow = new St.BoxLayout({ + style_class: 'aurora-tray-icon-row', + }); + (this._iconRow.layout_manager as Clutter.BoxLayout).spacing = ICON_GAP; + + this._clipArea = new TrayClipArea(); + this._clipArea.add_child(this._iconRow); + + // Outer layout + this._outerBox = new St.BoxLayout({ + style_class: 'aurora-tray-container', + }); + this._outerBox.add_child(this._chevron); + this._outerBox.add_child(this._clipArea); + this.add_child(this._outerBox); + + // Scroll to peek + this.connect('scroll-event', (_actor: Clutter.Actor, event: Clutter.Event) => { + if (!this._state.collapsed) return Clutter.EVENT_PROPAGATE; + this._userInteracted = true; + const direction = event.get_scroll_direction(); + const delta = direction === Clutter.ScrollDirection.UP ? SCROLL_STEP : -SCROLL_STEP; + applyScroll(this._state, delta, this._maxScroll()); + this._syncScrollPosition(); + return Clutter.EVENT_STOP; + }); + } + + private _itemWidth(): number { + return this._iconSize + 2 * ITEM_PADDING; + } + + private _maxScroll(): number { + const hiddenCount = Math.max(0, this._items.size - this._limit); + return hiddenCount * (this._itemWidth() + ICON_GAP); + } + + addItem(item: TrayItem): void { + // Immediate dedup removal — no pop-out animation to avoid _syncLayout conflicts + const oldWidget = this._items.get(item.id); + if (oldWidget) { + this._items.delete(item.id); + this._opacityTargets.delete(oldWidget); + this._iconRow.remove_child(oldWidget); + oldWidget.destroy(); + } + + const widget = new (TrayIconItem as unknown as new ( + item: TrayItem, + iconSize: number, + ) => TrayIconItem)(item, this._iconSize); + this._items.set(item.id, widget); + this._iconRow.add_child(widget); + + // Non-animated sync sets correct opacity immediately — no layout animation thrash. + this._syncLayout(false); + + // Pop-in only for icons that ended up visible. + if (widget.opacity === 255) { + widget.set_pivot_point(0.5, 0.5); + widget.set_scale(0.5, 0.5); + widget.ease({ + scaleX: 1.0, + scaleY: 1.0, + duration: 500, + mode: Clutter.AnimationMode.EASE_OUT_BACK, + }); + } + } + + updateItemIcon(id: string): void { + this._items.get(id)?.updateIcon(); + } + + removeItem(id: string): void { + const widget = this._items.get(id); + if (!widget) return; + this._items.delete(id); + this._opacityTargets.delete(widget); + + // Pop-out: scale→0.5, opacity→0, then remove from DOM and sync layout. + widget.set_pivot_point(0.5, 0.5); + widget.ease({ + scaleX: 0.5, + scaleY: 0.5, + opacity: 0, + duration: 400, + mode: Clutter.AnimationMode.EASE_IN_QUAD, + onComplete: () => { + this._iconRow.remove_child(widget); + widget.destroy(); + this._syncLayout(false); + }, + }); + } + + notifyAttention(id: string): void { + addAttention(this._state, id); + const widget = this._items.get(id); + + // Auto-expand if the item is not visible. + const isHidden = !this._visibleIds().has(id); + if (isHidden && this._state.collapsed) { + this._state.collapsed = false; + this._syncLayout(true); + } + + widget?.showBadge(); + widget?.bounce(); + this._scheduleAutoCollapse(); + } + + clearAttentionBadge(id: string): void { + clearAttention(this._state, id); + this._items.get(id)?.hideBadge(); + } + + setLimit(limit: number): void { + this._limit = limit; + this._syncLayout(false); + } + + setIconSize(size: number): void { + this._iconSize = size; + for (const widget of this._items.values()) { + widget.setIconSize(size); + } + this._syncLayout(false); + } + + setAttentionTimeout(seconds: number): void { + this._attentionTimeoutSeconds = seconds; + } + + private _scheduleAutoCollapse(): void { + if (this._autoCollapseTimeoutId) { + GLib.Source.remove(this._autoCollapseTimeoutId); + this._autoCollapseTimeoutId = 0; + } + this._autoCollapseTimeoutId = GLib.timeout_add_seconds( + GLib.PRIORITY_DEFAULT, + this._attentionTimeoutSeconds, + () => { + this._autoCollapseTimeoutId = 0; + if (!this._userInteracted && !this._state.collapsed) { + this._state.collapsed = true; + this._syncLayout(true); + } + this._userInteracted = false; // reset for next attention cycle + return GLib.SOURCE_REMOVE; + }, + ); + } + + private _visibleIds(): Set { + const keys = [...this._items.keys()]; + return new Set(keys.slice(Math.max(0, keys.length - this._limit))); + } + + private _syncLayout(animated = false): void { + const count = this._items.size; + this.visible = count > 0; + const hasOverflow = count > this._limit; + this._chevron.visible = hasOverflow; + + // Chevron rotation: 0° = expanded (points right), 180° = collapsed (points left). + this._chevronIcon.ease({ + rotationAngleZ: this._state.collapsed ? 180 : 0, + duration: animated ? ANIM_DURATION : 0, + mode: Clutter.AnimationMode.EASE_OUT_CUBIC, + }); + + // collapsed → maxScroll anchors the row to newest icons (right-aligned in clip). + // expanded → 0 resets any manual scroll. + this._state.scrollOffset = this._state.collapsed ? this._maxScroll() : 0; + + const itemW = this._itemWidth(); + const visibleCount = Math.min(count, this._limit); + const collapsedWidth = visibleCount * itemW + Math.max(0, visibleCount - 1) * ICON_GAP; + const fullWidth = count * itemW + Math.max(0, count - 1) * ICON_GAP; + const hiddenWidth = Math.max(0, fullWidth - collapsedWidth); + const targetViewportWidth = Math.round(this._state.collapsed ? collapsedWidth : fullWidth); + const targetClipStart = Math.round(this._state.collapsed ? hiddenWidth : 0); + const startViewportWidth = Math.round(this._clipArea.viewportWidth || targetViewportWidth); + const startClipStart = Math.round(this._clipArea.clipStart); + + if (animated) { + logger.log( + `Viewport animation collapsed=${this._state.collapsed} count=${count} limit=${this._limit} visible=${visibleCount} fullWidth=${fullWidth} hiddenWidth=${hiddenWidth} fromViewport=${startViewportWidth} toViewport=${targetViewportWidth} fromClipStart=${startClipStart} toClipStart=${targetClipStart} scrollOffset=${this._state.scrollOffset} chevronX=${Math.round(this._chevron.translationX)} ${this._clipArea.layoutSnapshot()}`, + { prefix: LOG_PREFIX }, + ); + } + + if (count === 0) { + this._clipArea.remove_all_transitions(); + this._clipArea.cancelViewportAnimation(); + this._clipArea.setViewport(0, 0, 0); + this._outerBox.translationX = 0; + this._setChevronAnchor(0); + this._syncScrollPosition(0); + return; + } + + this._clipArea.remove_all_transitions(); + this._clipArea.cancelViewportAnimation(); + this._outerBox.translationX = 0; + + if ( + animated && + (startViewportWidth !== targetViewportWidth || startClipStart !== targetClipStart) + ) { + if (this._state.collapsed) { + for (const widget of [...this._items.values()]) { + widget.remove_transition('opacity'); + this._opacityTargets.set(widget, 255); + widget.opacity = 255; + } + } + this._clipArea.setViewport(fullWidth, startViewportWidth, startClipStart); + this._setChevronAnchor(startClipStart); + this._clipArea.animateViewport( + startViewportWidth, + startClipStart, + targetViewportWidth, + targetClipStart, + ANIM_DURATION, + (_viewportWidth, clipStart) => { + this._setChevronAnchor(clipStart); + }, + () => { + this._applyIconOpacity(); + logger.log( + `Viewport animation complete chevronX=${Math.round(this._chevron.translationX)} ${this._clipArea.layoutSnapshot()}`, + { prefix: LOG_PREFIX }, + ); + }, + ); + GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => { + logger.log( + `Viewport post-allocate chevronX=${Math.round(this._chevron.translationX)} ${this._clipArea.layoutSnapshot()}`, + { prefix: LOG_PREFIX }, + ); + return GLib.SOURCE_REMOVE; + }); + } else { + this._clipArea.setViewport(fullWidth, targetViewportWidth, targetClipStart); + this._setChevronAnchor(targetClipStart); + this._applyIconOpacity(); + } + + if (animated && !this._state.collapsed) this._applyIconOpacity(); + + this._syncScrollPosition(0); + } + + private _setChevronAnchor(x: number): void { + const rounded = Math.round(x); + if (this._chevron.translationX !== rounded) this._chevron.translationX = rounded; + } + + private _applyIconOpacity(): void { + const count = this._items.size; + const hiddenCount = Math.max(0, count - this._limit); + const allWidgets = [...this._items.values()]; + for (let i = 0; i < allWidgets.length; i++) { + const widget = allWidgets[i]!; + const targetOpacity = i < hiddenCount && this._state.collapsed ? 0 : 255; + if (this._opacityTargets.get(widget) === targetOpacity) continue; + this._opacityTargets.set(widget, targetOpacity); + widget.remove_transition('opacity'); + widget.opacity = targetOpacity; + } + } + + private _syncScrollPosition(duration = 150): void { + // Right-aligned allocation shows newest icons at translationX=0. + // Positive translationX shifts row right (peek at older icons on the left). + // scrollOffset=maxScroll (default collapsed) → targetX=0, no shift needed. + const targetX = this._state.collapsed ? this._maxScroll() - this._state.scrollOffset : 0; + + if (this._scrollTarget === targetX) return; + this._scrollTarget = targetX; + if (duration > 0) { + logger.log( + `Scroll animation collapsed=${this._state.collapsed} targetX=${targetX} maxScroll=${this._maxScroll()} offset=${this._state.scrollOffset} duration=${duration}`, + { prefix: LOG_PREFIX }, + ); + } + + if (duration > 0) { + this.remove_transition('anim-scroll'); + this.ease({ + anim_scroll: targetX, + duration, + mode: Clutter.AnimationMode.EASE_OUT_CUBIC, + } as Parameters[0] & { anim_scroll: number }); + } else { + this.remove_transition('anim-scroll'); + this.anim_scroll = targetX; + } + } + + override destroy(): void { + this._clipArea.cancelViewportAnimation(); + if (this._autoCollapseTimeoutId > 0) { + GLib.Source.remove(this._autoCollapseTimeoutId); + this._autoCollapseTimeoutId = 0; + } + destroyTooltip(); + for (const widget of this._items.values()) widget.destroy(); + this._items.clear(); + super.destroy(); + } +} diff --git a/src/modules/trayIcons/trayIconItem.ts b/src/modules/trayIcons/trayIconItem.ts new file mode 100644 index 0000000..5dc375c --- /dev/null +++ b/src/modules/trayIcons/trayIconItem.ts @@ -0,0 +1,276 @@ +// src/modules/trayIcons/trayIconItem.ts +import '@girs/gjs'; + +import St from '@girs/st-18'; +import Clutter from '@girs/clutter-18'; +import GObject from '@girs/gobject-2.0'; +import type Gio from '@girs/gio-2.0'; +import GdkPixbuf from '@girs/gdkpixbuf-2.0'; +import * as Main from '@girs/gnome-shell/ui/main'; +import * as PopupMenu from '@girs/gnome-shell/ui/popupMenu'; +import { PopupAnimation } from '@girs/gnome-shell/ui/boxpointer'; +import { logger } from '~/core/logger.ts'; + +import type { TrayItem } from './trayState.ts'; +import { DBusMenuClient } from './dbusMenu.ts'; + +const BADGE_SIZE = 6; +const BOUNCE_DURATION = 1400; +const LOG_PREFIX = 'AuroraTray'; + +// Module-level tooltip shared by all TrayIconItems to avoid allocating one per icon. +let _tooltipLabel: St.Label | null = null; + +function _showTooltip(anchor: Clutter.Actor, text: string): void { + if (!_tooltipLabel) { + _tooltipLabel = new St.Label({ style_class: 'aurora-tray-tooltip', visible: false }); + Main.uiGroup.add_child(_tooltipLabel); + } + _tooltipLabel.text = text; + _tooltipLabel.show(); + + // Position: horizontally centred below the anchor, clamped to the monitor. + const monitor = Main.layoutManager.findMonitorForActor(anchor); + const [ax, ay] = anchor.get_transformed_position(); + const [aw, ah] = anchor.get_transformed_size(); + _tooltipLabel.ensure_style(); + const tw = _tooltipLabel.width; + const tx = monitor + ? Math.max(monitor.x, Math.min(ax + aw / 2 - tw / 2, monitor.x + monitor.width - tw)) + : ax + aw / 2 - tw / 2; + _tooltipLabel.set_position(Math.round(tx), Math.round(ay + ah + 4)); +} + +function _hideTooltip(): void { + _tooltipLabel?.hide(); +} + +export function destroyTooltip(): void { + if (_tooltipLabel) { + Main.uiGroup.remove_child(_tooltipLabel); + _tooltipLabel.destroy(); + _tooltipLabel = null; + } +} + +@GObject.registerClass +export class TrayIconItem extends St.Button { + declare private _trayItem: TrayItem; + declare private _iconWidget: St.Icon; + declare private _badge: St.Widget; + declare private _iconSize: number; + declare private _menu: PopupMenu.PopupMenu | null; + declare private _menuManager: PopupMenu.PopupMenuManager | null; + declare private _dbusMenuClient: DBusMenuClient | null; + declare private _localMenu: PopupMenu.PopupMenu | null; + declare private _localMenuManager: PopupMenu.PopupMenuManager | null; + + override _init(item: TrayItem, iconSize: number): void { + super._init({ + style_class: 'aurora-tray-icon-item', + button_mask: St.ButtonMask.ONE | St.ButtonMask.TWO | St.ButtonMask.THREE, + can_focus: true, + track_hover: true, + }); + + this._trayItem = item; + this._iconSize = iconSize; + this._menu = null; + this._menuManager = null; + this._dbusMenuClient = null; + this._localMenu = null; + this._localMenuManager = null; + + if (item.menuBusName && item.menuObjectPath) { + this._dbusMenuClient = new DBusMenuClient(item.menuBusName, item.menuObjectPath); + this._menu = new PopupMenu.PopupMenu(this, 0.5, St.Side.TOP); + this._menu.actor.add_style_class_name('aurora-tray-menu'); + this._menuManager = new PopupMenu.PopupMenuManager(this); + this._menuManager.addMenu(this._menu); + + Main.uiGroup.add_child(this._menu.actor); + this._menu.actor.hide(); + } + + if (item.menuItems && item.menuItems.length > 0) { + this._localMenu = new PopupMenu.PopupMenu(this, 0.5, St.Side.TOP); + this._localMenu.actor.add_style_class_name('aurora-tray-menu'); + this._localMenuManager = new PopupMenu.PopupMenuManager(this); + this._localMenuManager.addMenu(this._localMenu); + Main.uiGroup.add_child(this._localMenu.actor); + this._localMenu.actor.hide(); + + for (const mi of item.menuItems) { + const entry = new PopupMenu.PopupMenuItem(mi.label); + entry.connect('activate', () => mi.action()); + this._localMenu.addMenuItem(entry); + } + } + + const box = new St.Widget({ + layout_manager: new Clutter.BinLayout(), + reactive: false, + }); + this.set_child(box); + + this._iconWidget = new St.Icon({ + icon_size: iconSize, + fallback_icon_name: 'image-missing-symbolic', + reactive: false, + }); + box.add_child(this._iconWidget); + + this._applyIcon(); + + this._badge = new St.Widget({ + style: ` + width: ${BADGE_SIZE}px; + height: ${BADGE_SIZE}px; + border-radius: ${BADGE_SIZE / 2}px; + background-color: #3584e4; + border: 1.5px solid rgba(0,0,0,0.35); + `, + visible: false, + reactive: false, + x_align: Clutter.ActorAlign.END, + y_align: Clutter.ActorAlign.START, + }); + this._badge.set_pivot_point(0.5, 0.5); + box.add_child(this._badge); + + this.connect('notify::hover', () => { + const label = this._trayItem.tooltip; + if (this.hover && label) { + _showTooltip(this, label); + } else { + _hideTooltip(); + } + }); + + this.connect('button-press-event', (_actor: St.Button, event: Clutter.Event) => { + const btn = event.get_button(); + const [x, y] = this.get_transformed_position(); + const [w, h] = this.get_transformed_size(); + const centerX = x + w / 2; + const centerY = y + h / 2; + + if (btn === 3 && this._dbusMenuClient && this._menu) { + if (this._menu.isOpen) { + this._menu.close(PopupAnimation.FULL); + } else { + this._showDbusMenu(); + } + return Clutter.EVENT_STOP; + } + + if (btn === 3 && this._localMenu) { + if (this._localMenu.isOpen) { + this._localMenu.close(PopupAnimation.FULL); + } else { + this._localMenu.open(PopupAnimation.FULL); + } + return Clutter.EVENT_STOP; + } + + if (btn === 1) { + this._trayItem.activate(centerX, centerY); + } else if (btn === 2) { + this._trayItem.secondaryActivate?.(centerX, centerY); + } else if (btn === 3) { + this._trayItem.showMenu?.(centerX, centerY); + } + return Clutter.EVENT_STOP; + }); + } + + private async _showDbusMenu(): Promise { + if (!this._dbusMenuClient || !this._menu) return; + + try { + await this._dbusMenuClient.init(); + await this._dbusMenuClient.updateMenu(this._menu); + this._menu.open(PopupAnimation.FULL); + } catch (e) { + logger.warn(`_showDbusMenu failed: ${e}`, { prefix: LOG_PREFIX }); + } + } + + private _applyIcon(): void { + const icon = this._trayItem.icon; + + if (typeof icon === 'string') { + this._iconWidget.icon_name = icon; + } else if (icon instanceof GdkPixbuf.Pixbuf) { + // St.Icon does not auto-scale raw GdkPixbuf gicons — scale to iconSize + // so pixmap-based SNI icons render at the correct pixel size. + const scaled = + icon.scale_simple(this._iconSize, this._iconSize, GdkPixbuf.InterpType.BILINEAR) ?? icon; + this._iconWidget.gicon = scaled as unknown as Gio.Icon; + } else { + this._iconWidget.gicon = icon; + } + } + + updateIcon(): void { + this._applyIcon(); + } + + setIconSize(size: number): void { + this._iconSize = size; + this._iconWidget.set_icon_size(size); + } + + showBadge(): void { + this._badge.remove_all_transitions(); + this._badge.set_scale(0, 0); + this._badge.opacity = 0; + this._badge.visible = true; + this._badge.ease({ + scaleX: 1.0, + scaleY: 1.0, + opacity: 255, + duration: 450, + mode: Clutter.AnimationMode.EASE_OUT_BACK, + }); + } + + hideBadge(): void { + this._badge.remove_all_transitions(); + this._badge.ease({ + scaleX: 0, + scaleY: 0, + opacity: 0, + duration: 300, + mode: Clutter.AnimationMode.EASE_IN_QUAD, + onComplete: () => { + this._badge.visible = false; + this._badge.set_scale(1, 1); + this._badge.opacity = 255; + }, + }); + } + + bounce(): void { + this.remove_transition('bounce'); + const transition = new Clutter.KeyframeTransition({ property_name: 'translation-y' }); + transition.set_duration(BOUNCE_DURATION); + transition.set_from(0); + transition.set_to(0); + transition.set_key_frames([0.13, 0.25, 0.4, 0.52, 0.65, 0.78]); + transition.set_values([-6, 0, -4, 0, -2, 0]); + this.add_transition('bounce', transition); + } + + get trayItem(): TrayItem { + return this._trayItem; + } + + override destroy(): void { + _hideTooltip(); + this._dbusMenuClient?.destroy(); + this._menu?.destroy(); + this._localMenu?.destroy(); + this._trayItem.destroy(); + super.destroy(); + } +} diff --git a/src/modules/trayIcons/trayIcons.ts b/src/modules/trayIcons/trayIcons.ts new file mode 100644 index 0000000..6a0fccb --- /dev/null +++ b/src/modules/trayIcons/trayIcons.ts @@ -0,0 +1,389 @@ +// src/modules/trayIcons/trayIcons.ts +import '@girs/gjs'; +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 type { Button as PanelMenuButton } from '@girs/gnome-shell/ui/panelMenu'; + +// @ts-ignore +Gio._promisify(Gio.DBusConnection.prototype, 'call'); + +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 type { SettingsManager } from '~/core/settings.ts'; + +import { TrayContainer } from './trayContainer.ts'; +import { BackgroundAppsSource } from './backgroundAppsSource.ts'; +import { SniWatcher } from './sniWatcher.ts'; +import { SniHost } from './sniHost.ts'; +import type { TrayItem, TrayItemStatus } from './trayState.ts'; + +const PANEL_INDICATOR_ID = 'aurora-tray-icons'; +const LOG_PREFIX = 'AuroraTray'; + +export class TrayIcons extends Module { + private _container: TrayContainer | null = null; + private _sniWatcher: SniWatcher | null = null; + private _sniHost: SniHost | null = null; + private _bgSource: BackgroundAppsSource | null = null; + private _settingsChangedIds: number[] = []; + private _bgItemAppIds = new Map(); // appId → item.id + private _dedupBgApps = true; + private _bgAppsToggle: any = null; + private _bgAppsToggleVisibleId = 0; + private _desktopSettings: SettingsManager | null = null; + private _desktopSettingsChangedId = 0; + + constructor(context: ExtensionContext) { + super(context); + } + + override enable(): void { + 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'); + const limit = settings.get_int('tray-icons-limit'); + const attentionTimeout = settings.get_int('tray-icons-attention-timeout'); + this._dedupBgApps = settings.get_boolean('tray-icons-dedup-bg-apps'); + if (settings.get_boolean('tray-icons-hide-bg-quick-settings')) { + this._hideBgAppsQuickSettings(); + } + + this._container = new (TrayContainer as unknown as new ( + iconSize: number, + limit: number, + ) => TrayContainer)(iconSize, limit); + this._container.setAttentionTimeout(attentionTimeout); + + Main.panel.addToStatusArea( + PANEL_INDICATOR_ID, + this._container as unknown as PanelMenuButton, + 0, + 'right', + ); + + if (GLib.getenv('AURORA_TRAY_DEBUG')) { + const fakeIcons = [ + 'face-smile-symbolic', + 'computer-symbolic', + 'network-wireless-symbolic', + 'audio-headphones-symbolic', + 'bluetooth-symbolic', + 'camera-symbolic', + 'mail-unread-symbolic', + 'printer-symbolic', + ]; + for (let i = 0; i < fakeIcons.length; i++) { + const id = `debug-fake-${i}`; + this._container.addItem({ + id, + icon: fakeIcons[i]!, + status: 'Active', + tooltip: `Fake Icon ${i + 1}`, + activate: () => {}, + destroy: () => {}, + }); + } + } + + // SNI layer + this._sniWatcher = new SniWatcher( + (busName, objectPath) => { + this._sniHost + ?.registerItem(busName, objectPath) + ?.catch((e) => logger.warn(`registerItem failed: ${e}`, { prefix: LOG_PREFIX })); + }, + (_busName, _objectPath) => {}, + ); + this._sniHost = new SniHost( + this._sniWatcher, + { + onItemAdded: (item) => this._onSniItemAdded(item), + onItemRemoved: (id) => this._onItemRemoved(id), + onStatusChanged: (id, status) => this._onStatusChanged(id, status), + onIconChanged: (id) => this._container?.updateItemIcon(id), + }, + { + getColorScheme: () => this._desktopSettings?.getString('color-scheme') ?? 'prefer-dark', + shouldRecolorSymbolicPixmaps: () => + settings.get_boolean('tray-icons-recolor-symbolic-pixmaps'), + }, + ); + this._sniWatcher.start(); + this._desktopSettingsChangedId = this._desktopSettings.connect('changed::color-scheme', () => { + const scheme = this._desktopSettings?.getString('color-scheme') ?? 'unknown'; + logger.log(`Color scheme changed to ${scheme}; refreshing SNI icons`, { + prefix: LOG_PREFIX, + }); + this._sniHost?.refreshIcons('color-scheme'); + }); + + // Background Apps layer + this._bgSource = new BackgroundAppsSource({ + onItemAdded: (item) => this._onBgItemAdded(item).catch(() => {}), + onItemRemoved: (id) => this._onItemRemoved(id), + }); + this._bgSource + .start() + .catch((e) => logger.warn(`bg source start failed: ${e}`, { prefix: LOG_PREFIX })); + + // Settings change listeners + 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, itemId] of [...this._bgItemAppIds]) { + this._sniCoversApp(appId) + .then((covered) => { + if (covered) { + this._bgItemAppIds.delete(appId); + this._container?.removeItem(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(); + } + }), + settings.connect('changed::tray-icons-recolor-symbolic-pixmaps', () => { + logger.log( + `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 { + logger.log(`SNI item added: ${item.id} (menuBus=${item.menuBusName ?? 'none'})`, { + prefix: LOG_PREFIX, + }); + this._container?.addItem(item); + if (this._dedupBgApps && item.menuBusName) { + this._removeBgItemCoveredBy(item.menuBusName).catch((e) => + logger.warn(`_removeBgItemCoveredBy failed: ${e}`, { prefix: LOG_PREFIX }), + ); + } + } + + private async _onBgItemAdded(item: TrayItem): Promise { + const appId = item.id.replace('bg:', ''); + logger.log(`BG app detected: ${appId}`, { prefix: LOG_PREFIX }); + if (this._dedupBgApps && (await this._sniCoversApp(appId))) { + logger.log(`BG app ${appId} already covered by SNI, skipping`, { prefix: LOG_PREFIX }); + return; + } + if (!this._container) return; + this._bgItemAppIds.set(appId, item.id); + this._container.addItem(item); + logger.log(`BG app ${appId} added to tray`, { prefix: LOG_PREFIX }); + } + + private async _getUniqueName(busName: string): Promise { + try { + const res = await (Gio.DBus.session as any).call( + 'org.freedesktop.DBus', + '/org/freedesktop/DBus', + 'org.freedesktop.DBus', + 'GetNameOwner', + GLib.Variant.new('(s)', [busName]), + new GLib.VariantType('(s)'), + Gio.DBusCallFlags.NONE, + -1, + null, + ); + return res.get_child_value(0).unpack() as string; + } catch { + return null; + } + } + + private async _sniCoversApp(appId: string): Promise { + const owner = await this._getUniqueName(appId); + if (owner) { + const covered = this._sniHost?.hasItemForBus(owner) ?? false; + logger.log(`SNI covers ${appId}? owner=${owner} covered=${covered}`, { + prefix: LOG_PREFIX, + }); + return covered; + } + // Fallback: app doesn't own its expected D-Bus name (common for Flatpak Qt/SNI apps). + // Match by the SNI item's Id property instead. + const coveredById = this._sniHost?.hasSniForAppId(appId) ?? false; + logger.log(`SNI covers ${appId}? owner=none, Id-match=${coveredById}`, { + prefix: LOG_PREFIX, + }); + return coveredById; + } + + private async _removeBgItemCoveredBy(sniBusName: string): Promise { + // Resolve to unique name so comparison works regardless of whether + // the SNI app registered with a well-known or unique bus name. + const sniUnique = sniBusName.startsWith(':') + ? sniBusName + : await this._getUniqueName(sniBusName); + + if (!sniUnique) { + logger.log(`Cannot resolve SNI bus ${sniBusName}, skip bg dedup`, { prefix: LOG_PREFIX }); + return; + } + + logger.log( + `Dedup: SNI ${sniBusName} (unique=${sniUnique}), bg items: [${[...this._bgItemAppIds.keys()].join(', ')}]`, + { prefix: LOG_PREFIX }, + ); + + for (const [appId, itemId] of this._bgItemAppIds) { + const owner = await this._getUniqueName(appId); + logger.log(`Dedup: bg ${appId} owner=${owner ?? 'none'}`, { prefix: LOG_PREFIX }); + if (owner === sniUnique) { + logger.log(`Removing bg:${appId} covered by SNI ${sniBusName}`, { prefix: LOG_PREFIX }); + this._bgItemAppIds.delete(appId); + this._container?.removeItem(itemId); + return; + } + } + } + + private _onItemRemoved(id: string): void { + if (id.startsWith('bg:')) { + this._bgItemAppIds.delete(id.replace('bg:', '')); + } + this._container?.removeItem(id); + } + + private _onStatusChanged(id: string, status: TrayItemStatus): void { + if (status === 'NeedsAttention') { + this._container?.notifyAttention(id); + } else { + this._container?.clearAttentionBadge(id); + } + } + + private _hideBgAppsQuickSettings(): void { + if (this._bgAppsToggle) return; + const grid = (Main.panel.statusArea.quickSettings as any)?.menu?._grid; + if (!grid) return; + for (const child of grid.get_children()) { + if ((child as any).has_style_class_name?.('background-apps-quick-toggle')) { + this._bgAppsToggle = child; + child.visible = false; + this._bgAppsToggleVisibleId = child.connect('notify::visible', () => { + if (child.visible) child.visible = false; + }); + break; + } + } + } + + private _restoreBgAppsQuickSettings(): void { + if (!this._bgAppsToggle) return; + if (this._bgAppsToggleVisibleId) { + this._bgAppsToggle.disconnect(this._bgAppsToggleVisibleId); + this._bgAppsToggleVisibleId = 0; + } + this._bgAppsToggle._syncVisibility?.(); + this._bgAppsToggle = null; + } + + 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._desktopSettings = null; + + this._restoreBgAppsQuickSettings(); + + this._bgSource?.destroy(); + this._bgSource = null; + this._bgItemAppIds.clear(); + + this._sniHost?.destroy(); + this._sniHost = null; + + this._sniWatcher?.destroy(); + this._sniWatcher = null; + + (Main.panel.statusArea as Record)[PANEL_INDICATOR_ID] = null; + this._container?.destroy(); + this._container = null; + } +} + +export const definition: ModuleDefinition = { + key: 'tray-icons', + settingsKey: 'module-tray-icons', + 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', + }, + ], + factory: (ctx) => new TrayIcons(ctx), +}; diff --git a/src/modules/trayIcons/trayState.ts b/src/modules/trayIcons/trayState.ts new file mode 100644 index 0000000..a7e032d --- /dev/null +++ b/src/modules/trayIcons/trayState.ts @@ -0,0 +1,57 @@ +// src/modules/trayIcons/trayState.ts + +// Type-only imports: erased at compile time, no GJS runtime dependency in unit tests. +import type Gio from '@girs/gio-2.0'; +import type GdkPixbuf from '@girs/gdkpixbuf-2.0'; + +export type TrayItemStatus = 'Passive' | 'Active' | 'NeedsAttention'; + +export type TrayMenuItem = { label: string; action: () => void }; + +export interface TrayItem { + readonly id: string; + icon: Gio.Icon | GdkPixbuf.Pixbuf | string; + tooltip?: string | undefined; + status: TrayItemStatus; + menuBusName?: string; + menuObjectPath?: string | undefined; + menuItems?: TrayMenuItem[]; + activate(x: number, y: number): void; + secondaryActivate?(x: number, y: number): void; + showMenu?(x: number, y: number): void; + destroy(): void; +} + +export interface TrayState { + collapsed: boolean; + scrollOffset: number; + attentionIds: Set; + autoCollapseTimer: number | null; +} + +export function createTrayState(): TrayState { + return { + collapsed: true, + scrollOffset: 0, + attentionIds: new Set(), + autoCollapseTimer: null, + }; +} + +export function toggleCollapsed(state: TrayState): void { + state.collapsed = !state.collapsed; + // scrollOffset is managed by TrayContainer._syncLayout (needs UI metrics) +} + +export function applyScroll(state: TrayState, delta: number, maxScroll: number): void { + if (maxScroll <= 0) return; + state.scrollOffset = Math.max(0, Math.min(maxScroll, state.scrollOffset + delta)); +} + +export function addAttention(state: TrayState, id: string): void { + state.attentionIds.add(id); +} + +export function clearAttention(state: TrayState, id: string): void { + state.attentionIds.delete(id); +} diff --git a/src/modules/volumeMixer/volumeMixer.ts b/src/modules/volumeMixer/volumeMixer.ts index 61f0f82..40b65e9 100644 --- a/src/modules/volumeMixer/volumeMixer.ts +++ b/src/modules/volumeMixer/volumeMixer.ts @@ -10,11 +10,14 @@ import { PopupAnimation } from '@girs/gnome-shell/ui/boxpointer'; import * as Main from '@girs/gnome-shell/ui/main'; 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 { VolumeMixerPanel } from '~/modules/volumeMixer/mixerPanel.ts'; import { loadIcon } from '~/shared/icons.ts'; +const LOG_PREFIX = 'VolumeMixer'; + /** * Volume Mixer Module * @@ -58,7 +61,7 @@ export class VolumeMixer extends Module { const grid = Main.panel.statusArea.quickSettings?.menu?._grid; if (!grid) { - console.error('Aurora Shell: VolumeMixer could not find quick settings grid'); + logger.error('Could not find quick settings grid', { prefix: LOG_PREFIX }); return; } @@ -116,7 +119,7 @@ export class VolumeMixer extends Module { const grid = this._quickSettings?.menu?._grid; if (!grid) { - console.error('Aurora Shell: VolumeMixer could not find quick settings grid'); + logger.error('Could not find quick settings grid', { prefix: LOG_PREFIX }); return null; } @@ -146,7 +149,7 @@ export class VolumeMixer extends Module { try { Gio.Subprocess.new(['gnome-control-center', 'sound'], Gio.SubprocessFlags.NONE); } catch (e) { - console.error(`Aurora Shell: Failed to open sound settings: ${e}`); + logger.error(`Failed to open sound settings: ${e}`, { prefix: LOG_PREFIX }); } this._quickSettings?.menu.close(PopupAnimation.FULL); }); diff --git a/src/prefsMetadata.ts b/src/prefsMetadata.ts index 09e21ed..c783070 100644 --- a/src/prefsMetadata.ts +++ b/src/prefsMetadata.ts @@ -124,5 +124,55 @@ export function getModuleMetadata(): ModuleMetadata[] { title: _('Bluetooth Menu'), subtitle: _('Shows battery level and animated icons in the Bluetooth Quick Settings panel'), }, + { + key: 'tray-icons', + settingsKey: 'module-tray-icons', + 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', + }, + ], + }, ]; } diff --git a/src/registry.ts b/src/registry.ts index bdc9e12..c6160a4 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -9,6 +9,7 @@ import { definition as iconWeave } from '~/modules/iconWeave/iconWeave.ts'; import { definition as appSearchTooltip } from '~/modules/appSearchTooltip/appSearchTooltip.ts'; import { definition as autoThemeSwitcher } from '~/modules/autoThemeSwitcher/autoThemeSwitcher.ts'; import { definition as bluetoothMenu } from '~/modules/bluetoothMenu/bluetoothMenu.ts'; +import { definition as trayIcons } from '~/modules/trayIcons/trayIcons.ts'; import type { ModuleDefinition } from '~/module.ts'; @@ -27,5 +28,6 @@ export function getModuleRegistry(): ModuleDefinition[] { appSearchTooltip, autoThemeSwitcher, bluetoothMenu, + trayIcons, ]; } diff --git a/src/shared/ui/dash.ts b/src/shared/ui/dash.ts index defde30..a6bfa5e 100644 --- a/src/shared/ui/dash.ts +++ b/src/shared/ui/dash.ts @@ -795,6 +795,10 @@ export class AuroraDash extends Dash { if (this._isDestroyed) return; this._clearTimeout('_autohideTimeoutId'); this._autohideTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, AUTOHIDE_TIMEOUT, () => { + if (this._isDestroyed) { + this._autohideTimeoutId = 0; + return GLib.SOURCE_REMOVE; + } const dashContainer = (this as any)._dashContainer as St.Widget | undefined; if (dashContainer?.get_hover?.() || this._blockAutoHide || this._isMenuOpen()) { @@ -812,8 +816,10 @@ export class AuroraDash extends Dash { if (this._isDestroyed) return; this._clearTimeout('_blockAutoHideDelayId'); this._blockAutoHideDelayId = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { - const dashContainer = (this as any)._dashContainer as St.Widget | undefined; - if (dashContainer?.get_hover?.()) this.show(false); + if (!this._isDestroyed) { + const dashContainer = (this as any)._dashContainer as St.Widget | undefined; + if (dashContainer?.get_hover?.()) this.show(false); + } this._blockAutoHideDelayId = 0; return GLib.SOURCE_REMOVE; }); diff --git a/src/styles/_tray-icons.scss b/src/styles/_tray-icons.scss new file mode 100644 index 0000000..8635a63 --- /dev/null +++ b/src/styles/_tray-icons.scss @@ -0,0 +1,81 @@ +@use 'variables' as vars; + +// Tray Icons +#panel .panel-button.aurora-tray-button { + &:focus, + &:hover, + &:active, + &:checked, + &:active:hover, + &:checked:hover { + box-shadow: inset 0 0 0 100px transparent; + } +} + +.aurora-tray-container { + spacing: 2px; + min-width: 0; +} + +.aurora-tray-chevron { + border-radius: 5px; + padding: 3px; + min-width: 0; + + @if vars.$variant == 'light' { + &:hover { + background-color: rgba(0, 0, 0, 0.08); + } + } @else { + &:hover { + background-color: rgba(255, 255, 255, 0.1); + } + } +} + +.aurora-tray-icon-item { + border-radius: 6px; + padding: 3px; + min-width: 0; + + @if vars.$variant == 'light' { + &:hover { + background-color: rgba(0, 0, 0, 0.06); + } + + &:active { + background-color: rgba(0, 0, 0, 0.12); + } + } @else { + &:hover { + background-color: rgba(255, 255, 255, 0.08); + } + + &:active { + background-color: rgba(255, 255, 255, 0.15); + } + } +} + +.aurora-tray-icon-row { + spacing: 3px; + min-width: 0; +} + +.aurora-tray-menu { + min-width: 120px; +} + +.aurora-tray-tooltip { + border-radius: 6px; + padding: 4px 8px; + font-size: 0.85em; + + @if vars.$variant == 'light' { + background-color: rgba(0, 0, 0, 0.75); + color: #ffffff; + } @else { + background-color: rgba(30, 30, 30, 0.92); + color: #eeeeee; + } +} diff --git a/src/styles/stylesheet-dark.scss b/src/styles/stylesheet-dark.scss index 8ab7b33..9274f90 100644 --- a/src/styles/stylesheet-dark.scss +++ b/src/styles/stylesheet-dark.scss @@ -9,3 +9,4 @@ @use './app-search-tooltip'; @use './switcher'; @use './bluetooth-menu'; +@use './tray-icons'; diff --git a/src/styles/stylesheet-light.scss b/src/styles/stylesheet-light.scss index f85c09d..7bd5bb5 100644 --- a/src/styles/stylesheet-light.scss +++ b/src/styles/stylesheet-light.scss @@ -10,3 +10,4 @@ @use './app-search-tooltip'; @use './switcher'; @use './bluetooth-menu'; +@use './tray-icons'; diff --git a/tests/shell/auroraTrayIcons.js b/tests/shell/auroraTrayIcons.js new file mode 100644 index 0000000..cf09996 --- /dev/null +++ b/tests/shell/auroraTrayIcons.js @@ -0,0 +1,92 @@ +/* eslint camelcase: ["error", { properties: "never", allow: ["^script_"] }] */ + +/** + * Aurora Shell — Tray Icons integration test + * + * Verifies that: + * - The aurora-tray-icons indicator is added to panel.statusArea when module is enabled + * - The indicator is null in panel.statusArea after the module is disabled + * + * Run with: + * gnome-shell-test-tool --headless \ + * --extension dist/target/aurora-shell@luminusos.github.io.shell-extension.zip \ + * tests/shell/auroraTrayIcons.js + */ + +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; +import * as Scripting from 'resource:///org/gnome/shell/ui/scripting.js'; +import { EXTENSION_UUID, getAuroraSettings, waitForExtension } from './testUtils.js'; + +const INDICATOR_ID = 'aurora-tray-icons'; + +export var METRICS = {}; + +/** @returns {void} */ +export function init() { + Scripting.defineScriptEvent('extensionEnabled', 'Extension enabled'); + Scripting.defineScriptEvent('trayFound', 'Tray indicator found in panel.statusArea'); + Scripting.defineScriptEvent('trayGone', 'Tray indicator absent after disable'); +} + +/** @returns {Promise} */ +export async function run() { + await waitForExtension(EXTENSION_UUID); + Scripting.scriptEvent('extensionEnabled'); + await Scripting.sleep(500); + + // Wait for the indicator to appear (allows time for hot-reload to settle) + let trayIndicator = null; + for (let i = 0; i < 30; i++) { + trayIndicator = Main.panel.statusArea[INDICATOR_ID]; + if (trayIndicator) break; + console.log(`[aurora-tray-test] Waiting for indicator... attempt ${i + 1}`); + await Scripting.sleep(200); + } + + // --- 1. Verify tray indicator exists in panel.statusArea --- + if (!trayIndicator) + throw new Error(`"${INDICATOR_ID}" indicator not found in panel.statusArea after retries`); + + Scripting.scriptEvent('trayFound'); + await Scripting.sleep(200); + + // --- 2. Disable the module and verify indicator is removed --- + const settings = getAuroraSettings(); + settings.set_boolean('module-tray-icons', false); + await Scripting.waitLeisure(); + await Scripting.sleep(500); + + const afterDisable = Main.panel.statusArea[INDICATOR_ID]; + if (afterDisable) + throw new Error(`"${INDICATOR_ID}" still present in panel.statusArea after disable`); + + Scripting.scriptEvent('trayGone'); + + // Re-enable for cleanup + settings.set_boolean('module-tray-icons', true); + await Scripting.waitLeisure(); + await Scripting.sleep(300); +} + +let _extensionEnabled = false; +let _trayFound = false; +let _trayGone = false; + +/** @returns {void} */ +export function script_extensionEnabled() { _extensionEnabled = true; } + +/** @returns {void} */ +export function script_trayFound() { _trayFound = true; } + +/** @returns {void} */ +export function script_trayGone() { _trayGone = true; } + +/** @returns {void} */ +export function finish() { + if (!_extensionEnabled) + throw new Error('Extension was not found or not enabled'); + if (!_trayFound) + throw new Error('Tray indicator was not found in panel.statusArea after enable'); + if (!_trayGone) + throw new Error('Tray indicator was not removed from panel.statusArea after disable'); +} diff --git a/tests/shell/auroraTrayStability.js b/tests/shell/auroraTrayStability.js new file mode 100644 index 0000000..01b4f8b --- /dev/null +++ b/tests/shell/auroraTrayStability.js @@ -0,0 +1,93 @@ +/* eslint camelcase: ["error", { properties: "never", allow: ["^script_"] }] */ + +/** + * Aurora Shell — Tray Stability test + * + * Stress tests the tray by adding/removing fake items and toggling state. + * Verifies that the custom layout logic doesn't crash the shell. + */ + +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; +import * as Scripting from 'resource:///org/gnome/shell/ui/scripting.js'; +import { EXTENSION_UUID, waitForExtension } from './testUtils.js'; + +export var METRICS = {}; + +export function init() { + Scripting.defineScriptEvent('extensionEnabled', 'Extension enabled'); + Scripting.defineScriptEvent('stressTestPassed', 'Stress test completed without crash'); +} + +export async function run() { + await waitForExtension(EXTENSION_UUID); + Scripting.scriptEvent('extensionEnabled'); + await Scripting.sleep(500); + + const extension = Main.extensionManager.lookup(EXTENSION_UUID); + if (!extension || !extension.stateObj) + throw new Error('Extension state object not found'); + + const trayIconsModule = extension.stateObj._modules.get('tray-icons'); + if (!trayIconsModule) + throw new Error('Tray icons module not found'); + + const trayContainer = trayIconsModule._container; + if (!trayContainer) + throw new Error('Tray container not found'); + + console.log('[aurora-tray-stability] Starting stress test...'); + + // --- 1. Add multiple fake items --- + for (let i = 0; i < 10; i++) { + const id = `fake-item-${i}`; + console.log(`[aurora-tray-stability] Adding item ${id}`); + trayContainer.addItem({ + id, + icon: 'face-smile-symbolic', + status: 'Active', + activate: () => {}, + destroy: () => {} + }); + await Scripting.sleep(50); + } + + await Scripting.sleep(500); + + // --- 2. Toggle collapse state multiple times --- + for (let i = 0; i < 5; i++) { + console.log(`[aurora-tray-stability] Toggling collapse state (iteration ${i + 1})`); + // Simulate chevron click logic + const state = trayContainer._state; + state.collapsed = !state.collapsed; + trayContainer._syncLayout(true); + await Scripting.sleep(1000); // Wait for animation + } + + // --- 3. Remove items while animating --- + console.log('[aurora-tray-stability] Removing items during animation...'); + const state = trayContainer._state; + state.collapsed = false; + trayContainer._syncLayout(true); + + await Scripting.sleep(200); + for (let i = 0; i < 10; i++) { + trayContainer.removeItem(`fake-item-${i}`); + await Scripting.sleep(100); + } + + await Scripting.sleep(1000); + Scripting.scriptEvent('stressTestPassed'); +} + +let _extensionEnabled = false; +let _stressTestPassed = false; + +export function script_extensionEnabled() { _extensionEnabled = true; } +export function script_stressTestPassed() { _stressTestPassed = true; } + +export function finish() { + if (!_extensionEnabled) + throw new Error('Extension was not found or not enabled'); + if (!_stressTestPassed) + throw new Error('Stress test did not complete'); +} diff --git a/tests/unit/metadata.test.ts b/tests/unit/metadata.test.ts index a1ed1e7..c8d603a 100644 --- a/tests/unit/metadata.test.ts +++ b/tests/unit/metadata.test.ts @@ -19,7 +19,7 @@ const meta = JSON.parse(readFileSync(resolve(root, 'metadata.json'), 'utf-8')); const EXPECTED_UUID = 'aurora-shell@luminusos.github.io'; test('metadata.json — required fields are present', () => { - for (const field of ['uuid', 'name', 'description', 'version', 'shell-version', 'settings-schema', 'gettext-domain']) { + for (const field of ['uuid', 'name', 'description', 'version-name', 'shell-version', 'settings-schema', 'gettext-domain']) { assert.ok(field in meta, `Missing required field: ${field}`); assert.ok(meta[field] !== null && meta[field] !== undefined && meta[field] !== '', `Field "${field}" must not be empty`); @@ -50,8 +50,8 @@ test('metadata.json — shell-version is a non-empty array of numeric strings', } }); -test('metadata.json — version is a positive integer (or numeric string)', () => { - const v = Number(meta.version); - assert.ok(!Number.isNaN(v) && v > 0 && Number.isInteger(v), - `version "${meta.version}" must be a positive integer or numeric string`); +test('metadata.json — version-name is a non-empty string', () => { + const v = meta['version-name']; + assert.ok(typeof v === 'string' && v.length > 0, + `version-name "${v}" must be a non-empty string`); }); diff --git a/tests/unit/trayState.test.ts b/tests/unit/trayState.test.ts new file mode 100644 index 0000000..49edb39 --- /dev/null +++ b/tests/unit/trayState.test.ts @@ -0,0 +1,73 @@ +// tests/unit/trayState.test.ts +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + createTrayState, + toggleCollapsed, + applyScroll, + addAttention, + clearAttention, +} from '../../src/modules/trayIcons/trayState.ts'; +import type { TrayItem } from '../../src/modules/trayIcons/trayState.ts'; + +test('createTrayState returns collapsed=true, offset=0, empty attention set', () => { + const state = createTrayState(); + assert.strictEqual(state.collapsed, true); + assert.strictEqual(state.scrollOffset, 0); + assert.strictEqual(state.attentionIds.size, 0); + assert.strictEqual(state.autoCollapseTimer, null); +}); + +test('toggleCollapsed flips collapsed', () => { + const state = createTrayState(); + state.scrollOffset = 50; + toggleCollapsed(state); + assert.strictEqual(state.collapsed, false); + // scrollOffset is managed by TrayContainer._syncLayout, not toggleCollapsed + toggleCollapsed(state); + assert.strictEqual(state.collapsed, true); +}); + +test('applyScroll clamps to [0, maxScroll]', () => { + const state = createTrayState(); + applyScroll(state, 200, 100); + assert.strictEqual(state.scrollOffset, 100); + applyScroll(state, -300, 100); + assert.strictEqual(state.scrollOffset, 0); + applyScroll(state, 30, 100); + assert.strictEqual(state.scrollOffset, 30); +}); + +test('applyScroll does nothing when maxScroll <= 0', () => { + const state = createTrayState(); + applyScroll(state, 50, 0); + assert.strictEqual(state.scrollOffset, 0); + applyScroll(state, 50, -10); + assert.strictEqual(state.scrollOffset, 0); +}); + +test('addAttention and clearAttention manage Set membership', () => { + const state = createTrayState(); + addAttention(state, 'item-1'); + assert.ok(state.attentionIds.has('item-1')); + addAttention(state, 'item-2'); + assert.strictEqual(state.attentionIds.size, 2); + clearAttention(state, 'item-1'); + assert.ok(!state.attentionIds.has('item-1')); + assert.ok(state.attentionIds.has('item-2')); +}); + +test('TrayItem interface — optional fields are optional', () => { + const item: TrayItem = { + id: 'test-item', + icon: 'application-symbolic', + status: 'Active', + activate() {}, + destroy() {}, + }; + // If TypeScript compiled this, optional fields (tooltip, secondaryActivate, showMenu) are optional + assert.strictEqual(item.tooltip, undefined); + assert.strictEqual(item.secondaryActivate, undefined); + assert.strictEqual(item.showMenu, undefined); + assert.strictEqual(item.status, 'Active'); +});