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');
+});