Redesign nearby networks as expandable cards with generation icons, signal badges, and AP sub-rows

This commit is contained in:
Jalil Arfaoui 2026-02-26 12:52:25 +01:00
parent 8b1f1b3973
commit ce7c6bcbef
7 changed files with 810 additions and 25 deletions

View file

@ -8,6 +8,7 @@
"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",
"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",
"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=..",

View file

@ -19,6 +19,7 @@ import {
isConnected,
type WifiConnectionInfo,
type ConnectedInfo,
type ScannedNetwork,
} from './wifiInfo.js';
import {
GENERATION_CSS_CLASSES,
@ -26,9 +27,17 @@ import {
getGenerationDescription,
getGenerationIconFilename,
} from './wifiGeneration.js';
import type { GenerationCssClass, ChannelWidthMHz, SignalDbm } from './types.js';
import {
getSignalQualityFromPercent,
type GenerationCssClass,
type ChannelWidthMHz,
type SignalDbm,
type FrequencyBand,
type WifiGeneration,
} from './types.js';
const REFRESH_INTERVAL_SECONDS = 5;
const BACKGROUND_SCAN_INTERVAL_SECONDS = 300;
const PLACEHOLDER = '--' as const;
// WiFi 7 theoretical max: 320 MHz, MCS 13 (4096-QAM 5/6), 4×4 MIMO, GI 0.8µs
const MAX_SPEED_MBPS = 5760;
@ -45,6 +54,14 @@ const SIGNAL_QUALITY_COLORS: Readonly<Record<string, [number, number, number]>>
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',
};
type MenuItemId =
| 'ssid'
| 'generation'
@ -90,12 +107,17 @@ export default class WifiSignalPlusExtension extends Extension {
private label: St.Label | null = null;
private wifiService: WifiInfoService | null = null;
private refreshTimeout: number | null = null;
private backgroundScanTimeout: number | null = null;
private signalGraph: St.DrawingArea | null = null;
private readonly signalHistory: number[] = [];
private readonly menuItems = new Map<
MenuItemId,
{ item: PopupMenu.PopupBaseMenuItem; label: St.Label; value: St.Label; barFill?: St.Widget }
>();
private nearbySeparator: PopupMenu.PopupSeparatorMenuItem | null = null;
private nearbyItems: PopupMenu.PopupSubMenuMenuItem[] = [];
private currentConnectedBssid: string | undefined;
private isMenuOpen = false;
enable(): void {
this.wifiService = new WifiInfoService();
@ -103,9 +125,15 @@ export default class WifiSignalPlusExtension extends Extension {
.init()
.then(() => {
if (!this.wifiService) return;
this.wifiService.requestScan();
this.wifiService.watchDeviceSignals(() => {
this.wifiService?.requestScan();
this.refresh();
});
this.createIndicator();
this.refresh();
this.startRefreshTimer();
this.startBackgroundScanTimer();
})
.catch(e => {
console.error('[WiFi Signal Plus] Failed to initialize:', e);
@ -113,7 +141,10 @@ export default class WifiSignalPlusExtension extends Extension {
}
disable(): void {
this.stopBackgroundScanTimer();
this.stopRefreshTimer();
this.wifiService?.unwatchDeviceSignals();
this.clearNearbyItems();
this.indicator?.destroy();
this.wifiService?.destroy();
@ -124,6 +155,10 @@ export default class WifiSignalPlusExtension extends Extension {
this.signalGraph = null;
this.signalHistory.length = 0;
this.menuItems.clear();
this.nearbySeparator = null;
this.nearbyItems = [];
this.currentConnectedBssid = undefined;
this.isMenuOpen = false;
}
private createIndicator(): void {
@ -156,7 +191,13 @@ export default class WifiSignalPlusExtension extends Extension {
const menu = this.indicator.menu as PopupMenu.PopupMenu;
menu.box.add_style_class_name('wifi-signal-plus-popup');
menu.connect('open-state-changed', (_menu, isOpen: boolean) => {
if (isOpen) this.refresh();
this.isMenuOpen = isOpen;
if (isOpen) {
this.stopBackgroundScanTimer();
this.refresh();
} else {
this.startBackgroundScanTimer();
}
return undefined;
});
@ -177,6 +218,9 @@ export default class WifiSignalPlusExtension extends Extension {
this.addMenuItem(menu, id, label, ITEMS_WITH_BAR.has(id));
}
});
this.nearbySeparator = new PopupMenu.PopupSeparatorMenuItem('Nearby Networks');
menu.addMenuItem(this.nearbySeparator);
}
private addMenuItem(
@ -319,8 +363,13 @@ export default class WifiSignalPlusExtension extends Extension {
if (!this.wifiService || !this.label) return;
const info = await this.wifiService.getConnectionInfo();
this.currentConnectedBssid = isConnected(info) ? info.bssid : undefined;
this.updateIndicatorLabel(info);
this.updateMenuContent(info);
if (this.isMenuOpen) {
this.updateNearbyNetworks();
}
}
private updateIndicatorLabel(info: WifiConnectionInfo): void {
@ -464,6 +513,227 @@ export default class WifiSignalPlusExtension extends Extension {
return `${signalStrength} dBm (${quality})`;
}
private async updateNearbyNetworks(): Promise<void> {
if (!this.wifiService || !this.indicator) return;
const menu = this.indicator.menu as PopupMenu.PopupMenu;
this.clearNearbyItems();
const grouped = await this.wifiService.getAvailableNetworks(this.currentConnectedBssid);
for (const [ssid, networks] of grouped) {
const card = this.createNetworkCard(ssid, networks[0], networks);
menu.addMenuItem(card);
this.nearbyItems.push(card);
}
}
private createNetworkCard(
ssid: string,
bestAp: ScannedNetwork,
allAps: ScannedNetwork[],
): PopupMenu.PopupSubMenuMenuItem {
const card = new PopupMenu.PopupSubMenuMenuItem(ssid);
card.add_style_class_name('wifi-nearby-card');
this.createCardHeader(card, ssid, bestAp, allAps.length);
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,
): void {
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);
card.replace_child(card.label, headerBox);
// 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;
}
}
}
private createCardMetrics(ap: ScannedNetwork, apCount: number): St.BoxLayout {
const box = new St.BoxLayout({
style_class: 'wifi-nearby-card-header',
y_align: Clutter.ActorAlign.CENTER,
});
// Signal % colored
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-card-signal',
y_align: Clutter.ActorAlign.CENTER,
});
signalLabel.set_style(`color: ${signalColor};`);
box.add_child(signalLabel);
// Band badge
const bandBadge = new St.Label({
text: this.formatBandShort(ap.band),
style_class: 'wifi-nearby-badge',
y_align: Clutter.ActorAlign.CENTER,
});
box.add_child(bandBadge);
// 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): PopupMenu.PopupBaseMenuItem {
const item = new PopupMenu.PopupBaseMenuItem({ reactive: false });
item.add_style_class_name('wifi-nearby-ap');
const outerBox = new St.BoxLayout({ vertical: true, x_expand: true });
// Info row: BSSID + details + signal%
const infoRow = new St.BoxLayout({ x_expand: true });
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(`Ch ${ap.channel}`);
if ((ap.bandwidth as number) > 20) {
detailParts.push(`${ap.bandwidth} MHz`);
}
if ((ap.maxBitrate as number) > 0) {
detailParts.push(`${ap.maxBitrate} Mbit/s`);
}
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);
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 formatBandShort(band: FrequencyBand): string {
if (band === '2.4 GHz') return '2.4G';
if (band === '5 GHz') return '5G';
if (band === '6 GHz') return '6G';
return band;
}
private clearNearbyItems(): void {
for (const item of this.nearbyItems) {
item.destroy();
}
this.nearbyItems = [];
}
private startRefreshTimer(): void {
this.stopRefreshTimer();
this.refreshTimeout = GLib.timeout_add_seconds(
@ -482,4 +752,23 @@ export default class WifiSignalPlusExtension extends Extension {
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

@ -141,6 +141,19 @@ export interface ConnectedInfo extends BaseConnectionInfo {
readonly rxBitrate: BitrateMbps | null;
}
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 function isConnected(info: WifiConnectionInfo): info is ConnectedInfo {
@ -157,6 +170,22 @@ export const asMcsIndex = (value: number): McsIndex => value as McsIndex;
export const asSpatialStreams = (value: number): SpatialStreams => value as SpatialStreams;
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';
}
export function createEmptyIwLinkInfo(): IwLinkInfo {
return Object.freeze({
generation: WIFI_GENERATIONS.UNKNOWN,

View file

@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest';
import {
parseIwLinkOutput,
parseIwScanDump,
createEmptyIwLinkInfo,
WIFI_GENERATIONS,
IEEE_STANDARDS,
@ -10,7 +11,7 @@ import {
getGenerationIconFilename,
isKnownGeneration,
} from './wifiGeneration';
import { GUARD_INTERVALS } from './types';
import { GUARD_INTERVALS, asSignalPercent, getSignalQualityFromPercent } from './types';
describe('createEmptyIwLinkInfo', () => {
it('should create an object with all null values and UNKNOWN generation', () => {
@ -425,3 +426,222 @@ describe('GENERATION_CSS_CLASSES', () => {
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');
});
});

View file

@ -300,6 +300,31 @@ function parseHeGuardInterval(line: string, prefix: string): GuardIntervalUs {
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 {
return isKnownGeneration(generation) ? `WiFi ${generation}` : 'WiFi';
}

View file

@ -6,13 +6,15 @@
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import NM from 'gi://NM';
import { parseIwLinkOutput, createEmptyIwLinkInfo } from './wifiGeneration.js';
import { parseIwLinkOutput, parseIwScanDump, createEmptyIwLinkInfo, WIFI_GENERATIONS } from './wifiGeneration.js';
import {
type WifiConnectionInfo,
type ConnectedInfo,
type DisconnectedInfo,
type ScannedNetwork,
type FrequencyMHz,
type FrequencyBand,
type ChannelNumber,
@ -20,6 +22,8 @@ import {
type SignalQuality,
type SecurityProtocol,
type SignalCssClass,
type ChannelWidthMHz,
type WifiGeneration,
SIGNAL_THRESHOLDS,
createDisconnectedInfo,
isConnected,
@ -28,23 +32,29 @@ import {
asSignalPercent,
asBitrateMbps,
asChannelNumber,
asChannelWidthMHz,
} from './types.js';
export {
type WifiConnectionInfo,
type ConnectedInfo,
type DisconnectedInfo,
type ScannedNetwork,
type SignalQuality,
isConnected,
};
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;
export class WifiInfoService {
private client: NM.Client | 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> {
if (this.client) return;
@ -65,8 +75,34 @@ export class WifiInfoService {
}
destroy(): void {
this.unwatchDeviceSignals();
this.client = 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> {
@ -74,12 +110,17 @@ export class WifiInfoService {
return createDisconnectedInfo();
}
const wifiDevice = this.findActiveWifiDevice();
const wifiDevice = this.findWifiDevice();
if (!wifiDevice) {
return createDisconnectedInfo();
}
const interfaceName = wifiDevice.get_iface();
if (wifiDevice.get_state() !== NM.DeviceState.ACTIVATED) {
return createDisconnectedInfo(interfaceName);
}
const activeAp = wifiDevice.get_active_access_point();
if (!activeAp) {
@ -89,6 +130,104 @@ export class WifiInfoService {
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 getAvailableNetworks(excludeBssid?: 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;
const bssid = (ap.get_bssid() ?? '').toLowerCase();
if (!bssid) continue;
if (excludeBssid && bssid === excludeBssid.toLowerCase()) 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(
device: NM.DeviceWifi,
ap: NM.AccessPoint,
@ -121,26 +260,6 @@ export class WifiInfoService {
});
}
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) {
if (!interfaceName) {
return createEmptyIwLinkInfo();
@ -276,3 +395,42 @@ export function formatValue<T>(value: T | null, formatter?: (v: T) => string): s
if (value === null) return PLACEHOLDER;
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

@ -118,3 +118,66 @@
.wifi-signal-poor {
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-nearby-card-ssid {
font-weight: 500;
}
.wifi-nearby-card-signal {
font-weight: bold;
font-size: 0.9em;
min-width: 3em;
}
.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;
}
/* Nearby networks - AP sub-rows */
.wifi-nearby-ap {
min-height: 0;
padding: 3px 8px 3px 30px;
}
.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-signal {
font-weight: bold;
font-size: 0.85em;
}