From ce7c6bcbef80b44b2bf6150185306be888d6c632 Mon Sep 17 00:00:00 2001 From: Jalil Arfaoui Date: Thu, 26 Feb 2026 12:52:25 +0100 Subject: [PATCH] Redesign nearby networks as expandable cards with generation icons, signal badges, and AP sub-rows --- package.json | 1 + src/extension.ts | 293 ++++++++++++++++++++++++++++++++++++- src/types.ts | 29 ++++ src/wifiGeneration.test.ts | 222 +++++++++++++++++++++++++++- src/wifiGeneration.ts | 25 ++++ src/wifiInfo.ts | 202 ++++++++++++++++++++++--- stylesheet.css | 63 ++++++++ 7 files changed, 810 insertions(+), 25 deletions(-) diff --git a/package.json b/package.json index 119154c..e64c4b3 100644 --- a/package.json +++ b/package.json @@ -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=..", diff --git a/src/extension.ts b/src/extension.ts index 3b825b3..c6dd0de 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -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> Poor: [0.88, 0.11, 0.14], }; +const SIGNAL_QUALITY_BAR_COLORS: Readonly> = { + 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 { + 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; + } + } } diff --git a/src/types.ts b/src/types.ts index 49bb545..8a60504 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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, diff --git a/src/wifiGeneration.test.ts b/src/wifiGeneration.test.ts index 21cf4fe..e735db8 100644 --- a/src/wifiGeneration.test.ts +++ b/src/wifiGeneration.test.ts @@ -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'); + }); +}); diff --git a/src/wifiGeneration.ts b/src/wifiGeneration.ts index 5beed42..ccd7a65 100644 --- a/src/wifiGeneration.ts +++ b/src/wifiGeneration.ts @@ -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 { + const result = new Map(); + 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'; } diff --git a/src/wifiInfo.ts b/src/wifiInfo.ts index 74bc04e..893e13f 100644 --- a/src/wifiInfo.ts +++ b/src/wifiInfo.ts @@ -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 | null = null; + private watchedDevice: NM.DeviceWifi | null = null; + private deviceSignalIds: number[] = []; + private generationMap = new Map(); async init(): Promise { 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 { @@ -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> { + 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 { + 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(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 { + const groups = new Map(); + + 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); +} diff --git a/stylesheet.css b/stylesheet.css index 4a54901..1c133bc 100644 --- a/stylesheet.css +++ b/stylesheet.css @@ -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; +}