Compare commits

..

15 commits

Author SHA1 Message Date
896e513a17 Bump version to 5 2026-02-27 13:15:54 +01:00
9db21cd228 Show generation icon and frequency band per AP row 2026-02-27 13:11:12 +01:00
b186c99efb Replace nearby card signal percentage with colored bar gauge 2026-02-27 12:43:53 +01:00
8fa946085d Show AP max theoretical bitrate in Speed line 2026-02-27 12:26:28 +01:00
913d63a0c4 Color AP bitrate with 6-level speed quality scale
Separate maxBitrate from the grey details label into its own colored label in AP rows. Speed quality uses a dedicated palette: purple (>=1000), blue (>=300), green (>=100), yellow (>=50), orange (>=20), red (<20 Mbit/s).
2026-02-27 11:43:19 +01:00
fffe358a3c Add Access Points section showing all APs of the connected SSID
Display all access points sharing the connected network's SSID in a dedicated section between Signal and Nearby Networks. The connected AP is marked with a green checkmark icon, other APs get a spacer to keep BSSIDs aligned. Section is hidden when only one AP exists. NM duplicate BSSIDs are deduplicated by keeping the strongest signal.
2026-02-27 01:49:23 +01:00
7fbced9d25 Remove broken ScrollView wrapper around nearby networks section 2026-02-27 00:46:13 +01:00
be3ad57b67 Exclude connected network from nearby list by SSID instead of BSSID 2026-02-27 00:45:14 +01:00
7c771939e2 Prevent concurrent refreshes and add error handling on async calls 2026-02-27 00:39:46 +01:00
956f4b5916 Guard async init against stale enable/disable cycles with epoch counter 2026-02-27 00:38:15 +01:00
d0ad901aab Add scrollable nearby networks section and prevent concurrent updates 2026-02-27 00:03:00 +01:00
41dd546394 Preserve expanded state of nearby network cards across refreshes 2026-02-26 16:24:16 +01:00
6d27c67cbd Replace connection info header with large generation icon and compact labels 2026-02-26 13:26:57 +01:00
ce7c6bcbef Redesign nearby networks as expandable cards with generation icons, signal badges, and AP sub-rows 2026-02-26 12:52:25 +01:00
8b1f1b3973 Hide indicator when not connected to Wi-Fi 2026-02-24 17:17:10 +01:00
8 changed files with 1241 additions and 63 deletions

View file

@ -3,7 +3,7 @@
"name": "WiFi Signal Plus", "name": "WiFi Signal Plus",
"description": "Displays WiFi generation (4/5/6/7) in the top bar with detailed connection info on hover", "description": "Displays WiFi generation (4/5/6/7) in the top bar with detailed connection info on hover",
"shell-version": ["45", "46", "47", "48", "49"], "shell-version": ["45", "46", "47", "48", "49"],
"version": 4, "version": 5,
"url": "https://github.com/JalilArfaoui/gnome-extension-wifi-signal-plus", "url": "https://github.com/JalilArfaoui/gnome-extension-wifi-signal-plus",
"donations": { "donations": {
"liberapay": "Jalil" "liberapay": "Jalil"

View file

@ -8,6 +8,7 @@
"copy-assets": "cp metadata.json stylesheet.css dist/ && cp -r src/icons dist/", "copy-assets": "cp metadata.json stylesheet.css dist/ && cp -r src/icons dist/",
"install-extension": "npm run build && rm -rf ~/.local/share/gnome-shell/extensions/wifi-signal-plus@jalil.arfaoui.net && cp -r dist ~/.local/share/gnome-shell/extensions/wifi-signal-plus@jalil.arfaoui.net", "install-extension": "npm run build && rm -rf ~/.local/share/gnome-shell/extensions/wifi-signal-plus@jalil.arfaoui.net && cp -r dist ~/.local/share/gnome-shell/extensions/wifi-signal-plus@jalil.arfaoui.net",
"nested": "npm run install-extension && MUTTER_DEBUG_DUMMY_MODE_SPECS=1920x1080 dbus-run-session gnome-shell --devkit --wayland", "nested": "npm run install-extension && MUTTER_DEBUG_DUMMY_MODE_SPECS=1920x1080 dbus-run-session gnome-shell --devkit --wayland",
"nested:safe": "npm run build && rm -rf /tmp/wifi-signal-plus-test && mkdir -p /tmp/wifi-signal-plus-test/gnome-shell/extensions && cp -r dist /tmp/wifi-signal-plus-test/gnome-shell/extensions/wifi-signal-plus@jalil.arfaoui.net && XDG_DATA_HOME=/tmp/wifi-signal-plus-test MUTTER_DEBUG_DUMMY_MODE_SPECS=1920x1080 dbus-run-session -- gnome-shell --devkit --wayland",
"reload": "npm run install-extension && gnome-extensions disable wifi-signal-plus@jalil.arfaoui.net 2>/dev/null; gnome-extensions enable wifi-signal-plus@jalil.arfaoui.net", "reload": "npm run install-extension && gnome-extensions disable wifi-signal-plus@jalil.arfaoui.net 2>/dev/null; gnome-extensions enable wifi-signal-plus@jalil.arfaoui.net",
"watch": "tsc --watch", "watch": "tsc --watch",
"pack": "npm run build && cd dist && gnome-extensions pack --extra-source=icons --extra-source=wifiInfo.js --extra-source=wifiGeneration.js --extra-source=types.js --force --out-dir=..", "pack": "npm run build && cd dist && gnome-extensions pack --extra-source=icons --extra-source=wifiInfo.js --extra-source=wifiGeneration.js --extra-source=types.js --force --out-dir=..",

View file

@ -11,6 +11,7 @@ import St from 'gi://St';
import { Extension } from 'resource:///org/gnome/shell/extensions/extension.js'; import { Extension } from 'resource:///org/gnome/shell/extensions/extension.js';
import * as Main from 'resource:///org/gnome/shell/ui/main.js'; import * as Main from 'resource:///org/gnome/shell/ui/main.js';
import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js'; import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js';
import * as BoxPointer from 'resource:///org/gnome/shell/ui/boxpointer.js';
import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js';
import { import {
@ -19,17 +20,26 @@ import {
isConnected, isConnected,
type WifiConnectionInfo, type WifiConnectionInfo,
type ConnectedInfo, type ConnectedInfo,
type ScannedNetwork,
} from './wifiInfo.js'; } from './wifiInfo.js';
import { import {
WIFI_GENERATIONS,
GENERATION_CSS_CLASSES, GENERATION_CSS_CLASSES,
getGenerationLabel, getGenerationLabel,
getGenerationDescription, getGenerationDescription,
getGenerationIconFilename, getGenerationIconFilename,
} from './wifiGeneration.js'; } from './wifiGeneration.js';
import type { GenerationCssClass, ChannelWidthMHz, SignalDbm } from './types.js'; import {
getSignalQualityFromPercent,
getSpeedQuality,
type GenerationCssClass,
type ChannelWidthMHz,
type SignalDbm,
type SpeedQuality,
type WifiGeneration,
} from './types.js';
const REFRESH_INTERVAL_SECONDS = 5; const REFRESH_INTERVAL_SECONDS = 5;
const BACKGROUND_SCAN_INTERVAL_SECONDS = 300;
const PLACEHOLDER = '--' as const; const PLACEHOLDER = '--' as const;
// WiFi 7 theoretical max: 320 MHz, MCS 13 (4096-QAM 5/6), 4×4 MIMO, GI 0.8µs // WiFi 7 theoretical max: 320 MHz, MCS 13 (4096-QAM 5/6), 4×4 MIMO, GI 0.8µs
const MAX_SPEED_MBPS = 5760; const MAX_SPEED_MBPS = 5760;
@ -46,10 +56,24 @@ const SIGNAL_QUALITY_COLORS: Readonly<Record<string, [number, number, number]>>
Poor: [0.88, 0.11, 0.14], Poor: [0.88, 0.11, 0.14],
}; };
const SIGNAL_QUALITY_BAR_COLORS: Readonly<Record<string, string>> = {
Excellent: '#33d17a',
Good: '#8ff0a4',
Fair: '#f6d32d',
Weak: '#ff7800',
Poor: '#e01b24',
};
const SPEED_QUALITY_COLORS: Readonly<Record<SpeedQuality, string>> = {
Excellent: '#c061cb',
VeryGood: '#62a0ea',
Good: '#33d17a',
OK: '#f6d32d',
Weak: '#ff7800',
Poor: '#e01b24',
};
type MenuItemId = type MenuItemId =
| 'ssid'
| 'generation'
| 'band'
| 'bitrate' | 'bitrate'
| 'channelWidth' | 'channelWidth'
| 'mcs' | 'mcs'
@ -63,12 +87,6 @@ interface MenuItemConfig {
} }
const MENU_STRUCTURE: readonly MenuItemConfig[][] = [ const MENU_STRUCTURE: readonly MenuItemConfig[][] = [
// Section: Connection
[
{ id: 'ssid', label: 'Network' },
{ id: 'generation', label: 'Generation' },
{ id: 'band', label: 'Band' },
],
// Section: Performance // Section: Performance
[ [
{ id: 'bitrate', label: 'Speed' }, { id: 'bitrate', label: 'Speed' },
@ -85,28 +103,57 @@ const MENU_STRUCTURE: readonly MenuItemConfig[][] = [
const ITEMS_WITH_BAR: ReadonlySet<MenuItemId> = new Set(['bitrate', 'channelWidth']); const ITEMS_WITH_BAR: ReadonlySet<MenuItemId> = new Set(['bitrate', 'channelWidth']);
interface NearbyNetworkCard extends PopupMenu.PopupSubMenuMenuItem {
_ssid: string;
}
export default class WifiSignalPlusExtension extends Extension { export default class WifiSignalPlusExtension extends Extension {
private indicator: PanelMenu.Button | null = null; private indicator: PanelMenu.Button | null = null;
private icon: St.Icon | null = null; private icon: St.Icon | null = null;
private label: St.Label | null = null; private label: St.Label | null = null;
private wifiService: WifiInfoService | null = null; private wifiService: WifiInfoService | null = null;
private refreshTimeout: number | null = null; private refreshTimeout: number | null = null;
private backgroundScanTimeout: number | null = null;
private signalGraph: St.DrawingArea | null = null; private signalGraph: St.DrawingArea | null = null;
private readonly signalHistory: number[] = []; private readonly signalHistory: number[] = [];
private readonly menuItems = new Map< private readonly menuItems = new Map<
MenuItemId, MenuItemId,
{ item: PopupMenu.PopupBaseMenuItem; label: St.Label; value: St.Label; barFill?: St.Widget } { item: PopupMenu.PopupBaseMenuItem; label: St.Label; value: St.Label; barFill?: St.Widget }
>(); >();
private headerSsidLabel: St.Label | null = null;
private headerGenerationLabel: St.Label | null = null;
private headerBandLabel: St.Label | null = null;
private headerIcon: St.Icon | null = null;
private accessPointsSeparator: PopupMenu.PopupSeparatorMenuItem | null = null;
private accessPointsSection: PopupMenu.PopupMenuSection | null = null;
private accessPointsItems: PopupMenu.PopupBaseMenuItem[] = [];
private accessPointsUpdatePending = false;
private nearbySeparator: PopupMenu.PopupSeparatorMenuItem | null = null;
private nearbySection: PopupMenu.PopupMenuSection | null = null;
private nearbyItems: NearbyNetworkCard[] = [];
private nearbyUpdatePending = false;
private currentConnectedSsid: string | undefined;
private currentConnectedBssid: string | undefined;
private isMenuOpen = false;
private enableEpoch = 0;
enable(): void { enable(): void {
const epoch = ++this.enableEpoch;
this.wifiService = new WifiInfoService(); this.wifiService = new WifiInfoService();
this.wifiService this.wifiService
.init() .init()
.then(() => { .then(() => {
if (epoch !== this.enableEpoch) return;
if (!this.wifiService) return; if (!this.wifiService) return;
this.wifiService.requestScan();
this.wifiService.watchDeviceSignals(() => {
this.wifiService?.requestScan();
this.scheduleRefresh();
});
this.createIndicator(); this.createIndicator();
this.refresh(); this.scheduleRefresh();
this.startRefreshTimer(); this.startRefreshTimer();
this.startBackgroundScanTimer();
}) })
.catch(e => { .catch(e => {
console.error('[WiFi Signal Plus] Failed to initialize:', e); console.error('[WiFi Signal Plus] Failed to initialize:', e);
@ -114,7 +161,11 @@ export default class WifiSignalPlusExtension extends Extension {
} }
disable(): void { disable(): void {
this.stopBackgroundScanTimer();
this.stopRefreshTimer(); this.stopRefreshTimer();
this.wifiService?.unwatchDeviceSignals();
this.clearAccessPointsItems();
this.clearNearbyItems();
this.indicator?.destroy(); this.indicator?.destroy();
this.wifiService?.destroy(); this.wifiService?.destroy();
@ -125,6 +176,22 @@ export default class WifiSignalPlusExtension extends Extension {
this.signalGraph = null; this.signalGraph = null;
this.signalHistory.length = 0; this.signalHistory.length = 0;
this.menuItems.clear(); this.menuItems.clear();
this.headerSsidLabel = null;
this.headerGenerationLabel = null;
this.headerBandLabel = null;
this.headerIcon = null;
this.accessPointsSeparator = null;
this.accessPointsSection = null;
this.accessPointsItems = [];
this.accessPointsUpdatePending = false;
this.nearbySeparator = null;
this.nearbySection = null;
this.nearbyItems = [];
this.refreshPending = false;
this.nearbyUpdatePending = false;
this.currentConnectedSsid = undefined;
this.currentConnectedBssid = undefined;
this.isMenuOpen = false;
} }
private createIndicator(): void { private createIndicator(): void {
@ -157,18 +224,24 @@ export default class WifiSignalPlusExtension extends Extension {
const menu = this.indicator.menu as PopupMenu.PopupMenu; const menu = this.indicator.menu as PopupMenu.PopupMenu;
menu.box.add_style_class_name('wifi-signal-plus-popup'); menu.box.add_style_class_name('wifi-signal-plus-popup');
menu.connect('open-state-changed', (_menu, isOpen: boolean) => { menu.connect('open-state-changed', (_menu, isOpen: boolean) => {
if (isOpen) this.refresh(); this.isMenuOpen = isOpen;
if (isOpen) {
this.stopBackgroundScanTimer();
this.scheduleRefresh();
} else {
this.startBackgroundScanTimer();
}
return undefined; return undefined;
}); });
const sectionHeaders = ['', 'Performance', 'Signal']; this.addConnectionHeader(menu);
const SIGNAL_SECTION_INDEX = 2; const sectionHeaders = ['Performance', 'Signal'];
const SIGNAL_SECTION_INDEX = 1;
MENU_STRUCTURE.forEach((section, index) => { MENU_STRUCTURE.forEach((section, index) => {
if (sectionHeaders[index]) {
menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem(sectionHeaders[index])); menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem(sectionHeaders[index]));
}
if (index === SIGNAL_SECTION_INDEX) { if (index === SIGNAL_SECTION_INDEX) {
this.addSignalGraph(menu); this.addSignalGraph(menu);
@ -178,6 +251,60 @@ export default class WifiSignalPlusExtension extends Extension {
this.addMenuItem(menu, id, label, ITEMS_WITH_BAR.has(id)); this.addMenuItem(menu, id, label, ITEMS_WITH_BAR.has(id));
} }
}); });
this.accessPointsSeparator = new PopupMenu.PopupSeparatorMenuItem('Access Points');
this.accessPointsSeparator.visible = false;
menu.addMenuItem(this.accessPointsSeparator);
this.accessPointsSection = new PopupMenu.PopupMenuSection();
this.accessPointsSection.actor.visible = false;
menu.addMenuItem(this.accessPointsSection);
this.nearbySeparator = new PopupMenu.PopupSeparatorMenuItem('Nearby Networks');
menu.addMenuItem(this.nearbySeparator);
this.nearbySection = new PopupMenu.PopupMenuSection();
menu.addMenuItem(this.nearbySection);
}
private addConnectionHeader(menu: PopupMenu.PopupMenu): void {
const item = new PopupMenu.PopupBaseMenuItem({ reactive: false });
item.add_style_class_name('wifi-connection-header');
const leftBox = new St.BoxLayout({
vertical: true,
x_expand: true,
y_align: Clutter.ActorAlign.CENTER,
});
this.headerSsidLabel = new St.Label({
text: PLACEHOLDER,
style_class: 'wifi-connection-header-ssid',
});
leftBox.add_child(this.headerSsidLabel);
this.headerGenerationLabel = new St.Label({
text: PLACEHOLDER,
style_class: 'wifi-connection-header-generation',
});
leftBox.add_child(this.headerGenerationLabel);
this.headerBandLabel = new St.Label({
text: PLACEHOLDER,
style_class: 'wifi-connection-header-band',
});
leftBox.add_child(this.headerBandLabel);
item.add_child(leftBox);
this.headerIcon = new St.Icon({
icon_size: 48,
style_class: 'wifi-connection-header-icon',
y_align: Clutter.ActorAlign.CENTER,
visible: false,
});
item.add_child(this.headerIcon);
menu.addMenuItem(item);
} }
private addMenuItem( private addMenuItem(
@ -316,27 +443,63 @@ export default class WifiSignalPlusExtension extends Extension {
} }
} }
private async refresh(): Promise<void> { private updateHeaderIcon(generation: WifiGeneration): void {
if (!this.wifiService || !this.label) return; if (!this.headerIcon) return;
const iconFilename = getGenerationIconFilename(generation);
if (!iconFilename) {
this.headerIcon.visible = false;
return;
}
const iconPath = GLib.build_filenamev([this.path, 'icons', iconFilename]);
const file = Gio.File.new_for_path(iconPath);
this.headerIcon.gicon = new Gio.FileIcon({ file });
this.headerIcon.visible = true;
}
private refreshPending = false;
private scheduleRefresh(): void {
this.refresh().catch(e => {
console.error('[WiFi Signal Plus] Refresh failed:', e);
});
}
private async refresh(): Promise<void> {
if (!this.wifiService || !this.label || this.refreshPending) return;
this.refreshPending = true;
try {
const info = await this.wifiService.getConnectionInfo(); const info = await this.wifiService.getConnectionInfo();
if (!this.wifiService) return;
this.currentConnectedSsid = isConnected(info) ? info.ssid : undefined;
this.currentConnectedBssid = isConnected(info) ? info.bssid : undefined;
this.updateIndicatorLabel(info); this.updateIndicatorLabel(info);
this.updateMenuContent(info); this.updateMenuContent(info);
if (this.isMenuOpen) {
await this.updateAccessPoints();
await this.updateNearbyNetworks();
}
} finally {
this.refreshPending = false;
}
} }
private updateIndicatorLabel(info: WifiConnectionInfo): void { private updateIndicatorLabel(info: WifiConnectionInfo): void {
if (!this.icon || !this.label) return; if (!this.indicator || !this.icon || !this.label) return;
this.clearGenerationStyles(); this.clearGenerationStyles();
if (!isConnected(info)) { if (!isConnected(info)) {
this.icon.visible = false; this.indicator.visible = false;
this.label.visible = true;
this.label.set_text('WiFi --');
this.label.add_style_class_name(GENERATION_CSS_CLASSES[WIFI_GENERATIONS.UNKNOWN]);
return; return;
} }
this.indicator.visible = true;
const iconFilename = getGenerationIconFilename(info.generation); const iconFilename = getGenerationIconFilename(info.generation);
if (iconFilename) { if (iconFilename) {
const iconPath = GLib.build_filenamev([this.path, 'icons', iconFilename]); const iconPath = GLib.build_filenamev([this.path, 'icons', iconFilename]);
@ -372,21 +535,31 @@ export default class WifiSignalPlusExtension extends Extension {
} }
private showDisconnectedState(): void { private showDisconnectedState(): void {
this.headerSsidLabel?.set_text('Not connected');
this.headerGenerationLabel?.set_text('');
this.headerBandLabel?.set_text('');
if (this.headerIcon) {
this.headerIcon.visible = false;
}
for (const section of MENU_STRUCTURE) { for (const section of MENU_STRUCTURE) {
for (const { id } of section) { for (const { id } of section) {
const value = id === 'ssid' ? 'Not connected' : PLACEHOLDER; this.updateMenuItem(id, PLACEHOLDER, 0);
this.updateMenuItem(id, value, 0);
} }
} }
this.clearAccessPointsItems();
this.setAccessPointsVisible(false);
this.signalHistory.length = 0; this.signalHistory.length = 0;
this.signalGraph?.queue_repaint(); this.signalGraph?.queue_repaint();
} }
private showConnectedState(info: ConnectedInfo): void { private showConnectedState(info: ConnectedInfo): void {
this.updateMenuItem('ssid', info.ssid); this.headerSsidLabel?.set_text(info.ssid);
this.updateMenuItem('generation', getGenerationDescription(info.generation)); this.headerGenerationLabel?.set_text(getGenerationDescription(info.generation));
this.updateMenuItem('band', this.formatBand(info)); this.headerBandLabel?.set_text(this.formatBand(info));
this.updateHeaderIcon(info.generation);
this.updateMenuItem( this.updateMenuItem(
'bitrate', 'bitrate',
this.formatBitrate(info), this.formatBitrate(info),
@ -428,17 +601,27 @@ export default class WifiSignalPlusExtension extends Extension {
} }
private formatBitrate(info: ConnectedInfo): string { private formatBitrate(info: ConnectedInfo): string {
const { txBitrate, rxBitrate, bitrate } = info; const { txBitrate, rxBitrate, bitrate, maxBitrate } = info;
let speed: string;
if (txBitrate !== null && rxBitrate !== null) { if (txBitrate !== null && rxBitrate !== null) {
const tx = txBitrate as number; const tx = txBitrate as number;
const rx = rxBitrate as number; const rx = rxBitrate as number;
return tx === rx ? `${tx} Mbit/s` : `${tx}${rx} Mbit/s`; speed = tx === rx ? `${tx} Mbit/s` : `${tx}${rx} Mbit/s`;
} else if (txBitrate !== null) {
speed = `${txBitrate} Mbit/s`;
} else if (rxBitrate !== null) {
speed = `${rxBitrate} Mbit/s`;
} else {
speed = `${bitrate} Mbit/s`;
} }
if (txBitrate !== null) return `${txBitrate} Mbit/s`; const max = maxBitrate as number;
if (rxBitrate !== null) return `${rxBitrate} Mbit/s`; if (max > 0) {
return `${bitrate} Mbit/s`; speed += ` (max ${max})`;
}
return speed;
} }
private formatChannelWidth(width: ChannelWidthMHz | null): string { private formatChannelWidth(width: ChannelWidthMHz | null): string {
@ -466,13 +649,335 @@ export default class WifiSignalPlusExtension extends Extension {
return `${signalStrength} dBm (${quality})`; return `${signalStrength} dBm (${quality})`;
} }
private async updateAccessPoints(): Promise<void> {
if (!this.wifiService || !this.accessPointsSection || this.accessPointsUpdatePending) return;
if (!this.currentConnectedSsid) {
this.clearAccessPointsItems();
this.setAccessPointsVisible(false);
return;
}
this.accessPointsUpdatePending = true;
let accessPoints: ScannedNetwork[];
try {
accessPoints = await this.wifiService.getAccessPointsForSsid(this.currentConnectedSsid);
} finally {
this.accessPointsUpdatePending = false;
}
this.clearAccessPointsItems();
if (accessPoints.length <= 1) {
this.setAccessPointsVisible(false);
return;
}
this.setAccessPointsVisible(true);
for (const ap of accessPoints) {
const isActive = ap.bssid === this.currentConnectedBssid?.toLowerCase();
const row = this.createApRow(ap, isActive ? 'connected' : 'spacer');
this.accessPointsSection.addMenuItem(row);
this.accessPointsItems.push(row);
}
}
private setAccessPointsVisible(visible: boolean): void {
if (this.accessPointsSeparator) {
this.accessPointsSeparator.visible = visible;
}
if (this.accessPointsSection) {
this.accessPointsSection.actor.visible = visible;
}
}
private clearAccessPointsItems(): void {
for (const item of this.accessPointsItems) {
item.destroy();
}
this.accessPointsItems = [];
}
private async updateNearbyNetworks(): Promise<void> {
if (!this.wifiService || !this.nearbySection || this.nearbyUpdatePending) return;
this.nearbyUpdatePending = true;
let grouped: Map<string, ScannedNetwork[]>;
try {
grouped = await this.wifiService.getAvailableNetworks(this.currentConnectedSsid);
} finally {
this.nearbyUpdatePending = false;
}
const expandedSsids = new Set(
this.nearbyItems
.filter(card => card.menu.isOpen)
.map(card => card._ssid),
);
this.clearNearbyItems();
for (const [ssid, networks] of grouped) {
const card = this.createNetworkCard(ssid, networks[0], networks);
this.nearbySection.addMenuItem(card);
this.nearbyItems.push(card);
if (expandedSsids.has(ssid)) {
card.menu.open(BoxPointer.PopupAnimation.NONE);
}
}
}
private createNetworkCard(
ssid: string,
bestAp: ScannedNetwork,
allAps: ScannedNetwork[],
): NearbyNetworkCard {
const card = new PopupMenu.PopupSubMenuMenuItem(ssid) as NearbyNetworkCard;
card._ssid = ssid;
card.add_style_class_name('wifi-nearby-card');
const barTrack = this.createCardHeader(card, ssid, bestAp, allAps.length);
card.menu.connect('open-state-changed', (_menu: PopupMenu.PopupSubMenu, isOpen: boolean): undefined => {
barTrack.visible = !isOpen;
});
for (const ap of allAps) {
const row = this.createApRow(ap);
card.menu.addMenuItem(row);
}
return card;
}
private createCardHeader(
card: PopupMenu.PopupSubMenuMenuItem,
ssid: string,
bestAp: ScannedNetwork,
apCount: number,
): St.Widget {
const headerBox = new St.BoxLayout({
style_class: 'wifi-nearby-card-header',
x_expand: true,
y_align: Clutter.ActorAlign.CENTER,
});
const genIcon = this.createGenerationIcon(bestAp.generation);
if (genIcon) {
headerBox.add_child(genIcon);
}
const ssidLabel = new St.Label({
text: ssid,
style_class: 'wifi-nearby-card-ssid',
x_expand: true,
y_align: Clutter.ActorAlign.CENTER,
});
headerBox.add_child(ssidLabel);
const metricsBox = this.createCardMetrics(bestAp, apCount);
headerBox.add_child(metricsBox);
const outerBox = new St.BoxLayout({ vertical: true, x_expand: true });
outerBox.add_child(headerBox);
const quality = getSignalQualityFromPercent(bestAp.signalPercent);
const signalColor = SIGNAL_QUALITY_BAR_COLORS[quality] ?? '#ffffff';
const barTrack = new St.Widget({
style_class: 'wifi-bar-track',
x_expand: true,
});
const barFill = new St.Widget({ style_class: 'wifi-bar-fill' });
barFill.set_style(`background-color: ${signalColor};`);
barTrack.add_child(barFill);
outerBox.add_child(barTrack);
barTrack.connect('notify::allocation', () => {
const trackWidth = barTrack.width;
if (trackWidth > 0) {
barFill.set_width(Math.round(((bestAp.signalPercent as number) / 100) * trackWidth));
}
});
card.replace_child(card.label, outerBox);
// Remove the expander (identified by .popup-menu-item-expander)
for (const child of card.get_children()) {
const widget = child as St.Widget;
if (widget.has_style_class_name?.('popup-menu-item-expander')) {
card.remove_child(child);
child.destroy();
break;
}
}
return barTrack;
}
private createCardMetrics(ap: ScannedNetwork, apCount: number): St.BoxLayout {
const box = new St.BoxLayout({
style_class: 'wifi-nearby-card-header',
y_align: Clutter.ActorAlign.CENTER,
});
// Security badge
const secBadge = new St.Label({
text: ap.security,
style_class: ap.security === 'Open'
? 'wifi-nearby-badge wifi-nearby-badge-open'
: 'wifi-nearby-badge',
y_align: Clutter.ActorAlign.CENTER,
});
box.add_child(secBadge);
// AP count
if (apCount > 1) {
const countLabel = new St.Label({
text: `×${apCount}`,
style_class: 'wifi-nearby-card-count',
y_align: Clutter.ActorAlign.CENTER,
});
box.add_child(countLabel);
}
return box;
}
private createApRow(ap: ScannedNetwork, connectedIndicator: 'connected' | 'spacer' | 'none' = 'none'): PopupMenu.PopupBaseMenuItem {
const item = new PopupMenu.PopupBaseMenuItem({ reactive: false });
item.add_style_class_name('wifi-nearby-ap');
if (connectedIndicator === 'connected') {
const connectedIcon = new St.Icon({
icon_name: 'emblem-ok-symbolic',
icon_size: 12,
style_class: 'wifi-ap-connected-icon',
y_align: Clutter.ActorAlign.CENTER,
});
item.add_child(connectedIcon);
} else if (connectedIndicator === 'spacer') {
const spacer = new St.Widget({ style_class: 'wifi-ap-icon-spacer' });
item.add_child(spacer);
}
const outerBox = new St.BoxLayout({ vertical: true, x_expand: true });
// Info row: generation + BSSID + details + signal%
const infoRow = new St.BoxLayout({ x_expand: true });
const genIconFilename = getGenerationIconFilename(ap.generation);
if (genIconFilename) {
const iconPath = GLib.build_filenamev([this.path, 'icons', genIconFilename]);
const genIcon = new St.Icon({
gicon: new Gio.FileIcon({ file: Gio.File.new_for_path(iconPath) }),
style_class: 'wifi-ap-gen-icon',
y_align: Clutter.ActorAlign.CENTER,
});
infoRow.add_child(genIcon);
}
const bssidLabel = new St.Label({
text: ap.bssid.toUpperCase(),
style_class: 'wifi-nearby-ap-bssid',
y_align: Clutter.ActorAlign.CENTER,
});
infoRow.add_child(bssidLabel);
const detailParts: string[] = [];
detailParts.push(ap.band);
detailParts.push(`Ch ${ap.channel}`);
if ((ap.bandwidth as number) > 20) {
detailParts.push(`${ap.bandwidth} MHz`);
}
const detailsLabel = new St.Label({
text: detailParts.join(' · '),
style_class: 'wifi-nearby-ap-details',
x_expand: true,
y_align: Clutter.ActorAlign.CENTER,
});
infoRow.add_child(detailsLabel);
if ((ap.maxBitrate as number) > 0) {
const speedQuality = getSpeedQuality(ap.maxBitrate);
const speedLabel = new St.Label({
text: `${ap.maxBitrate} Mbit/s`,
style_class: 'wifi-nearby-ap-speed',
y_align: Clutter.ActorAlign.CENTER,
});
speedLabel.set_style(`color: ${SPEED_QUALITY_COLORS[speedQuality]};`);
infoRow.add_child(speedLabel);
}
const quality = getSignalQualityFromPercent(ap.signalPercent);
const signalColor = SIGNAL_QUALITY_BAR_COLORS[quality] ?? '#ffffff';
const signalLabel = new St.Label({
text: `${ap.signalPercent}%`,
style_class: 'wifi-nearby-ap-signal',
y_align: Clutter.ActorAlign.CENTER,
});
signalLabel.set_style(`color: ${signalColor};`);
infoRow.add_child(signalLabel);
outerBox.add_child(infoRow);
// Signal bar
const barTrack = new St.Widget({
style_class: 'wifi-bar-track',
x_expand: true,
});
const barFill = new St.Widget({
style_class: 'wifi-bar-fill',
});
barFill.set_style(`background-color: ${signalColor};`);
barTrack.add_child(barFill);
outerBox.add_child(barTrack);
// Set bar width after allocation
barTrack.connect('notify::allocation', () => {
const trackWidth = barTrack.width;
if (trackWidth > 0) {
barFill.set_width(Math.round(((ap.signalPercent as number) / 100) * trackWidth));
}
});
item.add_child(outerBox);
return item;
}
private createGenerationIcon(generation: WifiGeneration): St.Icon | null {
const iconFilename = getGenerationIconFilename(generation);
if (!iconFilename) return null;
const iconPath = GLib.build_filenamev([this.path, 'icons', iconFilename]);
const file = Gio.File.new_for_path(iconPath);
return new St.Icon({
gicon: new Gio.FileIcon({ file }),
icon_size: 16,
style_class: 'wifi-nearby-card-icon',
y_align: Clutter.ActorAlign.CENTER,
});
}
private clearNearbyItems(): void {
for (const item of this.nearbyItems) {
item.destroy();
}
this.nearbyItems = [];
}
private startRefreshTimer(): void { private startRefreshTimer(): void {
this.stopRefreshTimer(); this.stopRefreshTimer();
this.refreshTimeout = GLib.timeout_add_seconds( this.refreshTimeout = GLib.timeout_add_seconds(
GLib.PRIORITY_DEFAULT, GLib.PRIORITY_DEFAULT,
REFRESH_INTERVAL_SECONDS, REFRESH_INTERVAL_SECONDS,
() => { () => {
this.refresh(); this.scheduleRefresh();
return GLib.SOURCE_CONTINUE; return GLib.SOURCE_CONTINUE;
} }
); );
@ -484,4 +989,23 @@ export default class WifiSignalPlusExtension extends Extension {
this.refreshTimeout = null; this.refreshTimeout = null;
} }
} }
private startBackgroundScanTimer(): void {
this.stopBackgroundScanTimer();
this.backgroundScanTimeout = GLib.timeout_add_seconds(
GLib.PRIORITY_DEFAULT,
BACKGROUND_SCAN_INTERVAL_SECONDS,
() => {
this.wifiService?.requestScan();
return GLib.SOURCE_CONTINUE;
}
);
}
private stopBackgroundScanTimer(): void {
if (this.backgroundScanTimeout !== null) {
GLib.source_remove(this.backgroundScanTimeout);
this.backgroundScanTimeout = null;
}
}
} }

View file

@ -51,6 +51,9 @@ export type FrequencyBand = (typeof FREQUENCY_BANDS)[number];
export const SIGNAL_QUALITIES = ['Excellent', 'Good', 'Fair', 'Weak', 'Poor', 'Unknown'] as const; export const SIGNAL_QUALITIES = ['Excellent', 'Good', 'Fair', 'Weak', 'Poor', 'Unknown'] as const;
export type SignalQuality = (typeof SIGNAL_QUALITIES)[number]; export type SignalQuality = (typeof SIGNAL_QUALITIES)[number];
export const SPEED_QUALITIES = ['Excellent', 'VeryGood', 'Good', 'OK', 'Weak', 'Poor'] as const;
export type SpeedQuality = (typeof SPEED_QUALITIES)[number];
export const SIGNAL_THRESHOLDS = { export const SIGNAL_THRESHOLDS = {
Excellent: -50, Excellent: -50,
Good: -60, Good: -60,
@ -139,6 +142,20 @@ export interface ConnectedInfo extends BaseConnectionInfo {
readonly channelWidth: ChannelWidthMHz | null; readonly channelWidth: ChannelWidthMHz | null;
readonly txBitrate: BitrateMbps | null; readonly txBitrate: BitrateMbps | null;
readonly rxBitrate: BitrateMbps | null; readonly rxBitrate: BitrateMbps | null;
readonly maxBitrate: BitrateMbps;
}
export interface ScannedNetwork {
readonly ssid: string;
readonly bssid: string;
readonly frequency: FrequencyMHz;
readonly channel: ChannelNumber;
readonly band: FrequencyBand;
readonly bandwidth: ChannelWidthMHz;
readonly maxBitrate: BitrateMbps;
readonly signalPercent: SignalPercent;
readonly security: SecurityProtocol;
readonly generation: WifiGeneration;
} }
export type WifiConnectionInfo = DisconnectedInfo | ConnectedInfo; export type WifiConnectionInfo = DisconnectedInfo | ConnectedInfo;
@ -157,6 +174,40 @@ export const asMcsIndex = (value: number): McsIndex => value as McsIndex;
export const asSpatialStreams = (value: number): SpatialStreams => value as SpatialStreams; export const asSpatialStreams = (value: number): SpatialStreams => value as SpatialStreams;
export const asGuardIntervalUs = (value: number): GuardIntervalUs => value as GuardIntervalUs; export const asGuardIntervalUs = (value: number): GuardIntervalUs => value as GuardIntervalUs;
const SIGNAL_PERCENT_THRESHOLDS = {
Excellent: 80,
Good: 60,
Fair: 40,
Weak: 20,
} as const;
export function getSignalQualityFromPercent(signalPercent: SignalPercent): SignalQuality {
const pct = signalPercent as number;
if (pct >= SIGNAL_PERCENT_THRESHOLDS.Excellent) return 'Excellent';
if (pct >= SIGNAL_PERCENT_THRESHOLDS.Good) return 'Good';
if (pct >= SIGNAL_PERCENT_THRESHOLDS.Fair) return 'Fair';
if (pct >= SIGNAL_PERCENT_THRESHOLDS.Weak) return 'Weak';
return 'Poor';
}
const SPEED_THRESHOLDS = {
Excellent: 1000,
VeryGood: 300,
Good: 100,
OK: 50,
Weak: 20,
} as const;
export function getSpeedQuality(bitrate: BitrateMbps): SpeedQuality {
const mbps = bitrate as number;
if (mbps >= SPEED_THRESHOLDS.Excellent) return 'Excellent';
if (mbps >= SPEED_THRESHOLDS.VeryGood) return 'VeryGood';
if (mbps >= SPEED_THRESHOLDS.Good) return 'Good';
if (mbps >= SPEED_THRESHOLDS.OK) return 'OK';
if (mbps >= SPEED_THRESHOLDS.Weak) return 'Weak';
return 'Poor';
}
export function createEmptyIwLinkInfo(): IwLinkInfo { export function createEmptyIwLinkInfo(): IwLinkInfo {
return Object.freeze({ return Object.freeze({
generation: WIFI_GENERATIONS.UNKNOWN, generation: WIFI_GENERATIONS.UNKNOWN,

View file

@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { import {
parseIwLinkOutput, parseIwLinkOutput,
parseIwScanDump,
createEmptyIwLinkInfo, createEmptyIwLinkInfo,
WIFI_GENERATIONS, WIFI_GENERATIONS,
IEEE_STANDARDS, IEEE_STANDARDS,
@ -10,7 +11,13 @@ import {
getGenerationIconFilename, getGenerationIconFilename,
isKnownGeneration, isKnownGeneration,
} from './wifiGeneration'; } from './wifiGeneration';
import { GUARD_INTERVALS } from './types'; import {
GUARD_INTERVALS,
asBitrateMbps,
asSignalPercent,
getSignalQualityFromPercent,
getSpeedQuality,
} from './types';
describe('createEmptyIwLinkInfo', () => { describe('createEmptyIwLinkInfo', () => {
it('should create an object with all null values and UNKNOWN generation', () => { it('should create an object with all null values and UNKNOWN generation', () => {
@ -425,3 +432,272 @@ describe('GENERATION_CSS_CLASSES', () => {
expect(classes).toContain('wifi-disconnected'); expect(classes).toContain('wifi-disconnected');
}); });
}); });
describe('parseIwScanDump', () => {
it('should return an empty map for empty input', () => {
const result = parseIwScanDump('');
expect(result.size).toBe(0);
});
it('should detect WiFi 6 via HE capabilities', () => {
const output = `BSS ae:8b:a9:51:30:23(on wlp192s0) -- associated
\tlast seen: 340 ms ago
\tTSF: 123456789 usec (0d, 00:00:00)
\tfreq: 5220
\tbeacon interval: 100 TUs
\tcapability: ESS Privacy ShortSlotTime (0x0411)
\tsignal: -39.00 dBm
\tSSID: MyNetwork
\tHT capabilities:
\t\tCapabilities: 0x0963
\tHT operation:
\t\t * primary channel: 44
\tVHT capabilities:
\t\tVHT Capabilities (0x338b79b2):
\tVHT operation:
\t\t * channel width: 1 (80 MHz)
\tHE capabilities:
\t\tHE MAC Capabilities (0x000801185018):
\t\tHE PHY Capabilities (0x043c2e090f):`;
const result = parseIwScanDump(output);
expect(result.size).toBe(1);
expect(result.get('ae:8b:a9:51:30:23')).toBe(WIFI_GENERATIONS.WIFI_6);
});
it('should detect WiFi 7 via EHT capabilities', () => {
const output = `BSS 11:22:33:44:55:66(on wlan0)
\tfreq: 6115
\tsignal: -45.00 dBm
\tSSID: WiFi7Network
\tHT capabilities:
\t\tCapabilities: 0x0963
\tVHT capabilities:
\t\tVHT Capabilities (0x338b79b2):
\tHE capabilities:
\t\tHE MAC Capabilities (0x000801185018):
\tEHT capabilities:
\t\tEHT MAC Capabilities (0x0000):`;
const result = parseIwScanDump(output);
expect(result.size).toBe(1);
expect(result.get('11:22:33:44:55:66')).toBe(WIFI_GENERATIONS.WIFI_7);
});
it('should detect WiFi 5 via VHT capabilities', () => {
const output = `BSS aa:bb:cc:dd:ee:ff(on wlan0)
\tfreq: 5180
\tsignal: -55.00 dBm
\tSSID: AcNetwork
\tHT capabilities:
\t\tCapabilities: 0x0963
\tHT operation:
\t\t * primary channel: 36
\tVHT capabilities:
\t\tVHT Capabilities (0x338b79b2):
\tVHT operation:
\t\t * channel width: 1 (80 MHz)`;
const result = parseIwScanDump(output);
expect(result.size).toBe(1);
expect(result.get('aa:bb:cc:dd:ee:ff')).toBe(WIFI_GENERATIONS.WIFI_5);
});
it('should detect WiFi 4 via HT capabilities only', () => {
const output = `BSS 00:11:22:33:44:55(on wlan0)
\tfreq: 2437
\tsignal: -65.00 dBm
\tSSID: OldRouter
\tHT capabilities:
\t\tCapabilities: 0x0963
\tHT operation:
\t\t * primary channel: 6`;
const result = parseIwScanDump(output);
expect(result.size).toBe(1);
expect(result.get('00:11:22:33:44:55')).toBe(WIFI_GENERATIONS.WIFI_4);
});
it('should return UNKNOWN for BSS without any capabilities', () => {
const output = `BSS ff:ee:dd:cc:bb:aa(on wlan0)
\tfreq: 2412
\tsignal: -80.00 dBm
\tSSID: LegacyAP`;
const result = parseIwScanDump(output);
expect(result.size).toBe(1);
expect(result.get('ff:ee:dd:cc:bb:aa')).toBe(WIFI_GENERATIONS.UNKNOWN);
});
it('should parse multiple BSS blocks', () => {
const output = `BSS aa:bb:cc:dd:ee:01(on wlan0)
\tfreq: 5180
\tSSID: FastNetwork
\tHT capabilities:
\t\tCapabilities: 0x0963
\tVHT capabilities:
\t\tVHT Capabilities (0x338b79b2):
\tHE capabilities:
\t\tHE MAC Capabilities (0x000801185018):
BSS aa:bb:cc:dd:ee:02(on wlan0)
\tfreq: 2437
\tSSID: SlowNetwork
\tHT capabilities:
\t\tCapabilities: 0x0963
BSS aa:bb:cc:dd:ee:03(on wlan0)
\tfreq: 2412
\tSSID: LegacyNetwork`;
const result = parseIwScanDump(output);
expect(result.size).toBe(3);
expect(result.get('aa:bb:cc:dd:ee:01')).toBe(WIFI_GENERATIONS.WIFI_6);
expect(result.get('aa:bb:cc:dd:ee:02')).toBe(WIFI_GENERATIONS.WIFI_4);
expect(result.get('aa:bb:cc:dd:ee:03')).toBe(WIFI_GENERATIONS.UNKNOWN);
});
it('should normalize BSSID to lowercase', () => {
const output = `BSS AA:BB:CC:DD:EE:FF(on wlan0)
\tfreq: 5180
\tSSID: UpperCaseNetwork
\tHE capabilities:
\t\tHE MAC Capabilities (0x000801185018):`;
const result = parseIwScanDump(output);
expect(result.get('aa:bb:cc:dd:ee:ff')).toBe(WIFI_GENERATIONS.WIFI_6);
});
it('should pick the highest generation when multiple capabilities present', () => {
const output = `BSS aa:bb:cc:dd:ee:ff(on wlan0)
\tfreq: 5220
\tSSID: DualCapNetwork
\tHT capabilities:
\t\tCapabilities: 0x0963
\tHT operation:
\t\t * primary channel: 44
\tVHT capabilities:
\t\tVHT Capabilities (0x338b79b2):
\tHE capabilities:
\t\tHE MAC Capabilities (0x000801185018):`;
const result = parseIwScanDump(output);
expect(result.get('aa:bb:cc:dd:ee:ff')).toBe(WIFI_GENERATIONS.WIFI_6);
});
it('should detect WiFi 5 via VHT operation without VHT capabilities', () => {
const output = `BSS aa:bb:cc:dd:ee:ff(on wlan0)
\tfreq: 5180
\tSSID: VhtOpOnly
\tHT capabilities:
\t\tCapabilities: 0x0963
\tVHT operation:
\t\t * channel width: 1 (80 MHz)`;
const result = parseIwScanDump(output);
expect(result.get('aa:bb:cc:dd:ee:ff')).toBe(WIFI_GENERATIONS.WIFI_5);
});
it('should detect WiFi 4 via HT operation without HT capabilities', () => {
const output = `BSS aa:bb:cc:dd:ee:ff(on wlan0)
\tfreq: 2437
\tSSID: HtOpOnly
\tHT operation:
\t\t * primary channel: 6`;
const result = parseIwScanDump(output);
expect(result.get('aa:bb:cc:dd:ee:ff')).toBe(WIFI_GENERATIONS.WIFI_4);
});
});
describe('getSignalQualityFromPercent', () => {
it('should return Poor for 0%', () => {
expect(getSignalQualityFromPercent(asSignalPercent(0))).toBe('Poor');
});
it('should return Poor for 19%', () => {
expect(getSignalQualityFromPercent(asSignalPercent(19))).toBe('Poor');
});
it('should return Weak for 20%', () => {
expect(getSignalQualityFromPercent(asSignalPercent(20))).toBe('Weak');
});
it('should return Fair for 40%', () => {
expect(getSignalQualityFromPercent(asSignalPercent(40))).toBe('Fair');
});
it('should return Fair for 59%', () => {
expect(getSignalQualityFromPercent(asSignalPercent(59))).toBe('Fair');
});
it('should return Good for 60%', () => {
expect(getSignalQualityFromPercent(asSignalPercent(60))).toBe('Good');
});
it('should return Excellent for 80%', () => {
expect(getSignalQualityFromPercent(asSignalPercent(80))).toBe('Excellent');
});
it('should return Excellent for 100%', () => {
expect(getSignalQualityFromPercent(asSignalPercent(100))).toBe('Excellent');
});
});
describe('getSpeedQuality', () => {
it('should return Poor for 0 Mbit/s', () => {
expect(getSpeedQuality(asBitrateMbps(0))).toBe('Poor');
});
it('should return Poor for 19 Mbit/s', () => {
expect(getSpeedQuality(asBitrateMbps(19))).toBe('Poor');
});
it('should return Weak for 20 Mbit/s', () => {
expect(getSpeedQuality(asBitrateMbps(20))).toBe('Weak');
});
it('should return Weak for 49 Mbit/s', () => {
expect(getSpeedQuality(asBitrateMbps(49))).toBe('Weak');
});
it('should return OK for 50 Mbit/s', () => {
expect(getSpeedQuality(asBitrateMbps(50))).toBe('OK');
});
it('should return OK for 99 Mbit/s', () => {
expect(getSpeedQuality(asBitrateMbps(99))).toBe('OK');
});
it('should return Good for 100 Mbit/s', () => {
expect(getSpeedQuality(asBitrateMbps(100))).toBe('Good');
});
it('should return Good for 299 Mbit/s', () => {
expect(getSpeedQuality(asBitrateMbps(299))).toBe('Good');
});
it('should return VeryGood for 300 Mbit/s', () => {
expect(getSpeedQuality(asBitrateMbps(300))).toBe('VeryGood');
});
it('should return VeryGood for 999 Mbit/s', () => {
expect(getSpeedQuality(asBitrateMbps(999))).toBe('VeryGood');
});
it('should return Excellent for 1000 Mbit/s', () => {
expect(getSpeedQuality(asBitrateMbps(1000))).toBe('Excellent');
});
it('should return Excellent for 2400 Mbit/s', () => {
expect(getSpeedQuality(asBitrateMbps(2400))).toBe('Excellent');
});
});

View file

@ -300,6 +300,31 @@ function parseHeGuardInterval(line: string, prefix: string): GuardIntervalUs {
return HE_GI_INDEX_MAP[giIndex] ?? GUARD_INTERVALS.NORMAL; return HE_GI_INDEX_MAP[giIndex] ?? GUARD_INTERVALS.NORMAL;
} }
export function parseIwScanDump(output: string): Map<string, WifiGeneration> {
const result = new Map<string, WifiGeneration>();
if (!output) return result;
const bssBlocks = output.split(/^BSS /m);
for (const block of bssBlocks) {
const bssidMatch = block.match(/^([0-9a-f:]{17})/i);
if (!bssidMatch) continue;
const bssid = bssidMatch[1].toLowerCase();
result.set(bssid, detectScanGeneration(block));
}
return result;
}
function detectScanGeneration(block: string): WifiGeneration {
if (block.includes('EHT capabilities')) return WIFI_GENERATIONS.WIFI_7;
if (block.includes('HE capabilities')) return WIFI_GENERATIONS.WIFI_6;
if (block.includes('VHT capabilities') || block.includes('VHT operation')) return WIFI_GENERATIONS.WIFI_5;
if (block.includes('HT capabilities') || block.includes('HT operation')) return WIFI_GENERATIONS.WIFI_4;
return WIFI_GENERATIONS.UNKNOWN;
}
export function getGenerationLabel(generation: WifiGeneration): string { export function getGenerationLabel(generation: WifiGeneration): string {
return isKnownGeneration(generation) ? `WiFi ${generation}` : 'WiFi'; return isKnownGeneration(generation) ? `WiFi ${generation}` : 'WiFi';
} }

View file

@ -6,13 +6,15 @@
import Gio from 'gi://Gio'; import Gio from 'gi://Gio';
import GLib from 'gi://GLib'; import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import NM from 'gi://NM'; import NM from 'gi://NM';
import { parseIwLinkOutput, createEmptyIwLinkInfo } from './wifiGeneration.js'; import { parseIwLinkOutput, parseIwScanDump, createEmptyIwLinkInfo, WIFI_GENERATIONS } from './wifiGeneration.js';
import { import {
type WifiConnectionInfo, type WifiConnectionInfo,
type ConnectedInfo, type ConnectedInfo,
type DisconnectedInfo, type DisconnectedInfo,
type ScannedNetwork,
type FrequencyMHz, type FrequencyMHz,
type FrequencyBand, type FrequencyBand,
type ChannelNumber, type ChannelNumber,
@ -20,6 +22,8 @@ import {
type SignalQuality, type SignalQuality,
type SecurityProtocol, type SecurityProtocol,
type SignalCssClass, type SignalCssClass,
type ChannelWidthMHz,
type WifiGeneration,
SIGNAL_THRESHOLDS, SIGNAL_THRESHOLDS,
createDisconnectedInfo, createDisconnectedInfo,
isConnected, isConnected,
@ -28,23 +32,29 @@ import {
asSignalPercent, asSignalPercent,
asBitrateMbps, asBitrateMbps,
asChannelNumber, asChannelNumber,
asChannelWidthMHz,
} from './types.js'; } from './types.js';
export { export {
type WifiConnectionInfo, type WifiConnectionInfo,
type ConnectedInfo, type ConnectedInfo,
type DisconnectedInfo, type DisconnectedInfo,
type ScannedNetwork,
type SignalQuality, type SignalQuality,
isConnected, isConnected,
}; };
Gio._promisify(Gio.Subprocess.prototype, 'communicate_utf8_async', 'communicate_utf8_finish'); Gio._promisify(Gio.Subprocess.prototype, 'communicate_utf8_async', 'communicate_utf8_finish');
Gio._promisify(NM.DeviceWifi.prototype, 'request_scan_async', 'request_scan_finish');
const PLACEHOLDER = '--' as const; const PLACEHOLDER = '--' as const;
export class WifiInfoService { export class WifiInfoService {
private client: NM.Client | null = null; private client: NM.Client | null = null;
private initPromise: Promise<void> | null = null; private initPromise: Promise<void> | null = null;
private watchedDevice: NM.DeviceWifi | null = null;
private deviceSignalIds: number[] = [];
private generationMap = new Map<string, WifiGeneration>();
async init(): Promise<void> { async init(): Promise<void> {
if (this.client) return; if (this.client) return;
@ -65,8 +75,34 @@ export class WifiInfoService {
} }
destroy(): void { destroy(): void {
this.unwatchDeviceSignals();
this.client = null; this.client = null;
this.initPromise = null; this.initPromise = null;
this.generationMap.clear();
}
watchDeviceSignals(callback: () => void): void {
this.unwatchDeviceSignals();
const device = this.findWifiDevice();
if (!device) return;
this.watchedDevice = device;
this.deviceSignalIds = [
device.connect('state-changed', () => callback()),
device.connect('notify::active-access-point', () => callback()),
device.connect('notify::last-scan', () => this.onScanCompleted()),
];
}
unwatchDeviceSignals(): void {
if (this.watchedDevice) {
for (const id of this.deviceSignalIds) {
GObject.signal_handler_disconnect(this.watchedDevice, id);
}
}
this.watchedDevice = null;
this.deviceSignalIds = [];
} }
async getConnectionInfo(): Promise<WifiConnectionInfo> { async getConnectionInfo(): Promise<WifiConnectionInfo> {
@ -74,12 +110,17 @@ export class WifiInfoService {
return createDisconnectedInfo(); return createDisconnectedInfo();
} }
const wifiDevice = this.findActiveWifiDevice(); const wifiDevice = this.findWifiDevice();
if (!wifiDevice) { if (!wifiDevice) {
return createDisconnectedInfo(); return createDisconnectedInfo();
} }
const interfaceName = wifiDevice.get_iface(); const interfaceName = wifiDevice.get_iface();
if (wifiDevice.get_state() !== NM.DeviceState.ACTIVATED) {
return createDisconnectedInfo(interfaceName);
}
const activeAp = wifiDevice.get_active_access_point(); const activeAp = wifiDevice.get_active_access_point();
if (!activeAp) { if (!activeAp) {
@ -89,6 +130,144 @@ export class WifiInfoService {
return this.buildConnectedInfo(wifiDevice, activeAp, interfaceName); return this.buildConnectedInfo(wifiDevice, activeAp, interfaceName);
} }
requestScan(): void {
const device = this.findWifiDevice();
if (!device) return;
device.request_scan_async(null).catch(() => {
// Rate-limited or permission denied - use cached results
});
}
async getAccessPointsForSsid(ssid: string): Promise<ScannedNetwork[]> {
if (!this.client) return [];
const wifiDevice = this.findWifiDevice();
if (!wifiDevice) return [];
const accessPoints = wifiDevice.get_access_points();
const bestByBssid = new Map<string, ScannedNetwork>();
for (const ap of accessPoints) {
const apSsid = this.decodeSsid(ap.get_ssid());
if (apSsid !== ssid) continue;
const bssid = (ap.get_bssid() ?? '').toLowerCase();
if (!bssid) continue;
const strength = ap.get_strength();
const existing = bestByBssid.get(bssid);
if (existing && (existing.signalPercent as number) >= strength) continue;
const frequency = asFrequencyMHz(ap.get_frequency());
const generation = this.generationMap.get(bssid) ?? WIFI_GENERATIONS.UNKNOWN;
bestByBssid.set(bssid, Object.freeze({
ssid: apSsid,
bssid,
frequency,
channel: frequencyToChannel(frequency),
band: frequencyToBand(frequency),
bandwidth: getApBandwidth(ap),
maxBitrate: asBitrateMbps(ap.get_max_bitrate() / 1000),
signalPercent: asSignalPercent(strength),
security: getSecurityProtocol(ap),
generation,
}));
}
return sortBySignalStrength([...bestByBssid.values()]);
}
async getAvailableNetworks(excludeSsid?: string): Promise<Map<string, ScannedNetwork[]>> {
if (!this.client) return new Map();
const wifiDevice = this.findWifiDevice();
if (!wifiDevice) return new Map();
const accessPoints = wifiDevice.get_access_points();
const lastScanSec = wifiDevice.get_last_scan() / 1000;
const networks: ScannedNetwork[] = [];
for (const ap of accessPoints) {
if (isStaleAccessPoint(ap, lastScanSec)) continue;
const ssid = this.decodeSsid(ap.get_ssid());
if (!ssid) continue;
if (excludeSsid && ssid === excludeSsid) continue;
const bssid = (ap.get_bssid() ?? '').toLowerCase();
if (!bssid) continue;
const frequency = asFrequencyMHz(ap.get_frequency());
const generation = this.generationMap.get(bssid) ?? WIFI_GENERATIONS.UNKNOWN;
networks.push(Object.freeze({
ssid,
bssid,
frequency,
channel: frequencyToChannel(frequency),
band: frequencyToBand(frequency),
bandwidth: getApBandwidth(ap),
maxBitrate: asBitrateMbps(ap.get_max_bitrate() / 1000),
signalPercent: asSignalPercent(ap.get_strength()),
security: getSecurityProtocol(ap),
generation,
}));
}
return groupBySSID(sortBySignalStrength(networks));
}
private findWifiDevice(): NM.DeviceWifi | null {
if (!this.client) return null;
const devices = this.client.get_devices();
for (const device of devices) {
if (device instanceof NM.DeviceWifi && device.get_state() === NM.DeviceState.ACTIVATED) {
return device;
}
}
for (const device of devices) {
if (device instanceof NM.DeviceWifi) {
return device;
}
}
return null;
}
private onScanCompleted(): void {
const device = this.findWifiDevice();
if (!device) return;
const interfaceName = device.get_iface();
if (!interfaceName) return;
this.executeIwScanDump(interfaceName);
}
private async executeIwScanDump(interfaceName: string): Promise<void> {
try {
const proc = Gio.Subprocess.new(
['iw', 'dev', interfaceName, 'scan', 'dump'],
Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE,
);
const [stdout] = await proc.communicate_utf8_async(null, null);
if (proc.get_successful() && stdout) {
const freshMap = parseIwScanDump(stdout);
if (freshMap.size > 0) {
this.generationMap = freshMap;
}
}
} catch {
// iw scan dump not available or insufficient permissions - graceful degradation
}
}
private async buildConnectedInfo( private async buildConnectedInfo(
device: NM.DeviceWifi, device: NM.DeviceWifi,
ap: NM.AccessPoint, ap: NM.AccessPoint,
@ -118,29 +297,10 @@ export class WifiInfoService {
channelWidth: iwInfo.channelWidth, channelWidth: iwInfo.channelWidth,
txBitrate: iwInfo.txBitrate, txBitrate: iwInfo.txBitrate,
rxBitrate: iwInfo.rxBitrate, rxBitrate: iwInfo.rxBitrate,
maxBitrate: asBitrateMbps(ap.get_max_bitrate() / 1000),
}); });
} }
private findActiveWifiDevice(): NM.DeviceWifi | null {
if (!this.client) return null;
const devices = this.client.get_devices();
for (const device of devices) {
if (device instanceof NM.DeviceWifi && device.get_state() === NM.DeviceState.ACTIVATED) {
return device;
}
}
for (const device of devices) {
if (device instanceof NM.DeviceWifi) {
return device;
}
}
return null;
}
private async executeIwLink(interfaceName: string | null) { private async executeIwLink(interfaceName: string | null) {
if (!interfaceName) { if (!interfaceName) {
return createEmptyIwLinkInfo(); return createEmptyIwLinkInfo();
@ -276,3 +436,42 @@ export function formatValue<T>(value: T | null, formatter?: (v: T) => string): s
if (value === null) return PLACEHOLDER; if (value === null) return PLACEHOLDER;
return formatter ? formatter(value) : String(value); return formatter ? formatter(value) : String(value);
} }
export function sortBySignalStrength(networks: ScannedNetwork[]): ScannedNetwork[] {
return [...networks].sort(
(a, b) => (b.signalPercent as number) - (a.signalPercent as number),
);
}
export function groupBySSID(networks: ScannedNetwork[]): Map<string, ScannedNetwork[]> {
const groups = new Map<string, ScannedNetwork[]>();
for (const network of networks) {
const existing = groups.get(network.ssid);
if (existing) {
existing.push(network);
} else {
groups.set(network.ssid, [network]);
}
}
return groups;
}
const STALE_AP_TOLERANCE_SECONDS = 10;
function isStaleAccessPoint(ap: NM.AccessPoint, lastScanSec: number): boolean {
if (lastScanSec <= 0) return false;
const lastSeen = ap.get_last_seen();
if (lastSeen === -1) return true;
return lastSeen < lastScanSec - STALE_AP_TOLERANCE_SECONDS;
}
const DEFAULT_BANDWIDTH_MHZ = 20;
function getApBandwidth(ap: NM.AccessPoint): ChannelWidthMHz {
const bandwidth = (ap as unknown as { get_bandwidth?: () => number }).get_bandwidth?.();
return asChannelWidthMHz(bandwidth && bandwidth > 0 ? bandwidth : DEFAULT_BANDWIDTH_MHZ);
}

View file

@ -72,6 +72,30 @@
color: rgba(255, 255, 255, 0.7); color: rgba(255, 255, 255, 0.7);
} }
/* Connection header */
.wifi-connection-header {
padding: 4px 8px;
}
.wifi-connection-header-ssid {
font-size: 1.2em;
font-weight: bold;
}
.wifi-connection-header-generation {
font-size: 0.95em;
color: rgba(255, 255, 255, 0.8);
}
.wifi-connection-header-band {
font-size: 0.9em;
color: rgba(255, 255, 255, 0.6);
}
.wifi-connection-header-icon {
margin-left: 12px;
}
.wifi-popup-value { .wifi-popup-value {
font-size: 1em; font-size: 1em;
font-weight: 500; font-weight: 500;
@ -118,3 +142,81 @@
.wifi-signal-poor { .wifi-signal-poor {
color: #e01b24; color: #e01b24;
} }
/* Nearby networks - Card header */
.wifi-nearby-card {
margin: 2px 0;
}
.wifi-nearby-card-header {
spacing: 8px;
}
.wifi-nearby-card-icon {
icon-size: 16px;
}
.wifi-ap-gen-icon {
icon-size: 14px;
margin-right: 6px;
}
.wifi-nearby-card-ssid {
font-weight: 500;
}
.wifi-nearby-card-count {
font-size: 0.8em;
color: rgba(255, 255, 255, 0.5);
}
/* Nearby networks - Badges */
.wifi-nearby-badge {
font-size: 0.8em;
padding: 1px 5px;
border-radius: 3px;
background-color: rgba(255, 255, 255, 0.1);
}
.wifi-nearby-badge-open {
background-color: rgba(224, 27, 36, 0.3);
color: #e01b24;
}
/* Access Points - connected AP icon */
.wifi-ap-connected-icon {
color: #33d17a;
}
/* Access Points - spacer matching icon width for non-connected rows */
.wifi-ap-icon-spacer {
width: 12px;
}
/* Nearby networks - AP sub-rows */
.wifi-nearby-ap {
min-height: 0;
padding: 3px 8px 3px 12px;
}
.wifi-nearby-ap-bssid {
font-size: 0.85em;
color: rgba(255, 255, 255, 0.5);
min-width: 10em;
}
.wifi-nearby-ap-details {
font-size: 0.85em;
color: rgba(255, 255, 255, 0.6);
}
.wifi-nearby-ap-speed {
font-weight: bold;
font-size: 0.85em;
margin-right: 8px;
}
.wifi-nearby-ap-signal {
font-weight: bold;
font-size: 0.85em;
}