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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/extension-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
47 changes: 47 additions & 0 deletions src/core/mainLoop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number> | 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;
Expand All @@ -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;
}
23 changes: 8 additions & 15 deletions src/dock/intellihide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -74,7 +78,7 @@ export const DockIntellihide = GObject.registerClass(
private _excludePipFromSmartReveal = false;
declare private _trackedWindowActors: Set<any>;
declare private _trackedWindows: Set<Meta.Window>;
declare private _queuedRefreshIds: Set<number>;
declare private _queuedRefreshes: ManagedTimeoutBatch;

override _init(monitorIndex: number, excludePipFromSmartReveal = false) {
super._init();
Expand All @@ -84,7 +88,7 @@ export const DockIntellihide = GObject.registerClass(
this._excludePipFromSmartReveal = excludePipFromSmartReveal;
this._trackedWindowActors = new Set<any>();
this._trackedWindows = new Set<Meta.Window>();
this._queuedRefreshIds = new Set<number>();
this._queuedRefreshes = createManagedTimeoutBatch(this._lifecycle);

global.display.connectObject(
'window-entered-monitor',
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down
11 changes: 2 additions & 9 deletions tests/shell/dock/dock.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 = {};
Expand Down
43 changes: 43 additions & 0 deletions tests/unit/project/egoPolicy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, []);
});