diff --git a/docs/extension-review.md b/docs/extension-review.md index c6d42f8..8bec48d 100644 --- a/docs/extension-review.md +++ b/docs/extension-review.md @@ -62,9 +62,9 @@ Capture Tools destroys its session actors directly in `disable()`. Replaceable m owned by `LifecycleScope` through `ManagedSource`; replacing a source removes the previous one and disposing the scope removes the active source. Aurora Dash, Dock bindings, Clipboard History, Auto Theme Switcher, clocks, tray widgets, Bluetooth, and the remaining single-source owners use this -path. Dynamic source collections in Icon Weave and Dock Intellihide remain explicitly removed because -their per-operation ownership is clearer as a set. Shexli does not currently report `EGO-L-004` for -either cleanup form. +path. Dynamic inspection sources in Icon Weave use short-lived lifecycle scopes, and Dock Intellihide +uses a managed timeout batch for staggered refreshes. No production main-loop source bypasses this +ownership path. Shexli does not currently report `EGO-L-004` for this cleanup form. Recheck this classification against every new Shexli run. A stable rule ID does not imply that new locations are automatically accepted. diff --git a/src/core/mainLoop.ts b/src/core/mainLoop.ts index a7a39e0..d935da5 100644 --- a/src/core/mainLoop.ts +++ b/src/core/mainLoop.ts @@ -2,6 +2,47 @@ import GLib from '@girs/glib-2.0'; import type { LifecycleScope, ManagedSource } from '~/core/lifecycleScope.ts'; +export interface ManagedTimeoutBatch { + replace(delays: readonly number[], callback: () => void): void; + clear(): void; +} + +class ManagedTimeoutBatchImpl implements ManagedTimeoutBatch { + private _sourceIds: Set | null = new Set(); + + replace(delays: readonly number[], callback: () => void): void { + this.clear(); + if (!this._sourceIds) return; + + for (const delay of delays) { + const sourceId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, delay, () => { + if (!this._sourceIds) return GLib.SOURCE_REMOVE; + + this._sourceIds.delete(sourceId); + callback(); + return GLib.SOURCE_REMOVE; + }); + this._sourceIds.add(sourceId); + } + } + + clear(): void { + if (!this._sourceIds) return; + + for (const sourceId of this._sourceIds) { + removeSource(sourceId); + } + this._sourceIds.clear(); + } + + dispose(): void { + if (!this._sourceIds) return; + + this.clear(); + this._sourceIds = null; + } +} + export function removeSource(sourceId: number): 0 { if (sourceId !== 0) GLib.source_remove(sourceId); return 0; @@ -16,3 +57,9 @@ export function removeSource(sourceId: number): 0 { export function createManagedSource(scope: LifecycleScope): ManagedSource { return scope.manageSource(removeSource); } + +export function createManagedTimeoutBatch(scope: LifecycleScope): ManagedTimeoutBatch { + const batch = new ManagedTimeoutBatchImpl(); + scope.onDispose(() => batch.dispose()); + return batch; +} diff --git a/src/dock/intellihide.ts b/src/dock/intellihide.ts index cabb735..d07ee36 100644 --- a/src/dock/intellihide.ts +++ b/src/dock/intellihide.ts @@ -8,7 +8,11 @@ import * as Main from '@girs/gnome-shell/ui/main'; import { LifecycleScope, type ManagedSource } from '~/core/lifecycleScope.ts'; import { logger } from '~/core/logger.ts'; -import { createManagedSource } from '~/core/mainLoop.ts'; +import { + createManagedSource, + createManagedTimeoutBatch, + type ManagedTimeoutBatch, +} from '~/core/mainLoop.ts'; import { getBlockingOverlapState, isOnActiveWorkspace, @@ -74,7 +78,7 @@ export const DockIntellihide = GObject.registerClass( private _excludePipFromSmartReveal = false; declare private _trackedWindowActors: Set; declare private _trackedWindows: Set; - declare private _queuedRefreshIds: Set; + declare private _queuedRefreshes: ManagedTimeoutBatch; override _init(monitorIndex: number, excludePipFromSmartReveal = false) { super._init(); @@ -84,7 +88,7 @@ export const DockIntellihide = GObject.registerClass( this._excludePipFromSmartReveal = excludePipFromSmartReveal; this._trackedWindowActors = new Set(); this._trackedWindows = new Set(); - this._queuedRefreshIds = new Set(); + this._queuedRefreshes = createManagedTimeoutBatch(this._lifecycle); global.display.connectObject( 'window-entered-monitor', @@ -157,10 +161,6 @@ export const DockIntellihide = GObject.registerClass( destroy(): void { this._cancelPendingStatus(); this._lifecycle.dispose(); - for (const id of this._queuedRefreshIds) { - GLib.source_remove(id); - } - this._queuedRefreshIds.clear(); this._clearTrackedWindows(); global.display.disconnectObject(this); Main.layoutManager.disconnectObject(this); @@ -224,14 +224,7 @@ export const DockIntellihide = GObject.registerClass( } private _queueRefresh(reason: string, delays: number[] = [0]): void { - for (const delay of delays) { - const id = GLib.timeout_add(GLib.PRIORITY_DEFAULT, delay, () => { - this._queuedRefreshIds.delete(id); - this._checkOverlap(reason); - return GLib.SOURCE_REMOVE; - }); - this._queuedRefreshIds.add(id); - } + this._queuedRefreshes.replace(delays, () => this._checkOverlap(reason)); } private _isMonitorValid(): boolean { diff --git a/tests/shell/dock/dock.test.js b/tests/shell/dock/dock.test.js index 74c711a..9ae0f04 100644 --- a/tests/shell/dock/dock.test.js +++ b/tests/shell/dock/dock.test.js @@ -4,7 +4,6 @@ import * as Main from 'resource:///org/gnome/shell/ui/main.js'; import * as Scripting from 'resource:///org/gnome/shell/ui/scripting.js'; import Clutter from 'gi://Clutter'; import Gio from 'gi://Gio'; -import GLib from 'gi://GLib'; import Shell from 'gi://Shell'; import St from 'gi://St'; import { @@ -29,14 +28,8 @@ function findDockActor() { } function clearIntellihideQueuedRefreshes(intellihide) { - for (const id of intellihide._queuedRefreshIds || []) { - GLib.source_remove(id); - } - if (intellihide._queuedRefreshIds) intellihide._queuedRefreshIds.clear(); - if (intellihide._settleId) { - GLib.source_remove(intellihide._settleId); - intellihide._settleId = 0; - } + intellihide._queuedRefreshes?.clear(); + intellihide._settle?.clear(); } export var METRICS = {}; diff --git a/tests/unit/project/egoPolicy.test.ts b/tests/unit/project/egoPolicy.test.ts index cebe56a..006deb9 100644 --- a/tests/unit/project/egoPolicy.test.ts +++ b/tests/unit/project/egoPolicy.test.ts @@ -141,3 +141,46 @@ test('lifecycle methods are not empty placeholders', () => { assert.deepEqual(violations, []); }); + +test('main-loop sources are created through replaceable lifecycle owners', () => { + const violations: string[] = []; + const sourceCreators = new Set(['idle_add', 'timeout_add', 'timeout_add_seconds']); + + for (const file of sourceFiles(sourceRoot).filter((path) => path.endsWith('.ts'))) { + if (file.endsWith(join('core', 'mainLoop.ts'))) continue; + + const source = readFileSync(file, 'utf8'); + const tree = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true); + const visit = (node: ts.Node): void => { + if ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + node.expression.expression.getText(tree) === 'GLib' && + sourceCreators.has(node.expression.name.text) + ) { + let owner: ts.Node | undefined = node.parent; + let managed = false; + while (owner && !ts.isMethodDeclaration(owner) && !ts.isFunctionDeclaration(owner)) { + if ( + ts.isCallExpression(owner) && + ts.isPropertyAccessExpression(owner.expression) && + owner.expression.name.text === 'replace' + ) { + managed = true; + break; + } + owner = owner.parent; + } + + if (!managed) { + const line = tree.getLineAndCharacterOfPosition(node.getStart(tree)).line + 1; + violations.push(`${file.slice(sourceRoot.length + 1)}:${line}`); + } + } + ts.forEachChild(node, visit); + }; + visit(tree); + } + + assert.deepEqual(violations, []); +});