Redesign nearby networks as expandable cards with generation icons, signal badges, and AP sub-rows
This commit is contained in:
parent
8b1f1b3973
commit
ce7c6bcbef
7 changed files with 810 additions and 25 deletions
|
|
@ -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=..",
|
||||||
|
|
|
||||||
293
src/extension.ts
293
src/extension.ts
|
|
@ -19,6 +19,7 @@ import {
|
||||||
isConnected,
|
isConnected,
|
||||||
type WifiConnectionInfo,
|
type WifiConnectionInfo,
|
||||||
type ConnectedInfo,
|
type ConnectedInfo,
|
||||||
|
type ScannedNetwork,
|
||||||
} from './wifiInfo.js';
|
} from './wifiInfo.js';
|
||||||
import {
|
import {
|
||||||
GENERATION_CSS_CLASSES,
|
GENERATION_CSS_CLASSES,
|
||||||
|
|
@ -26,9 +27,17 @@ import {
|
||||||
getGenerationDescription,
|
getGenerationDescription,
|
||||||
getGenerationIconFilename,
|
getGenerationIconFilename,
|
||||||
} from './wifiGeneration.js';
|
} 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 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;
|
||||||
|
|
@ -45,6 +54,14 @@ 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',
|
||||||
|
};
|
||||||
|
|
||||||
type MenuItemId =
|
type MenuItemId =
|
||||||
| 'ssid'
|
| 'ssid'
|
||||||
| 'generation'
|
| 'generation'
|
||||||
|
|
@ -90,12 +107,17 @@ export default class WifiSignalPlusExtension extends Extension {
|
||||||
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 nearbySeparator: PopupMenu.PopupSeparatorMenuItem | null = null;
|
||||||
|
private nearbyItems: PopupMenu.PopupSubMenuMenuItem[] = [];
|
||||||
|
private currentConnectedBssid: string | undefined;
|
||||||
|
private isMenuOpen = false;
|
||||||
|
|
||||||
enable(): void {
|
enable(): void {
|
||||||
this.wifiService = new WifiInfoService();
|
this.wifiService = new WifiInfoService();
|
||||||
|
|
@ -103,9 +125,15 @@ export default class WifiSignalPlusExtension extends Extension {
|
||||||
.init()
|
.init()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
if (!this.wifiService) return;
|
if (!this.wifiService) return;
|
||||||
|
this.wifiService.requestScan();
|
||||||
|
this.wifiService.watchDeviceSignals(() => {
|
||||||
|
this.wifiService?.requestScan();
|
||||||
|
this.refresh();
|
||||||
|
});
|
||||||
this.createIndicator();
|
this.createIndicator();
|
||||||
this.refresh();
|
this.refresh();
|
||||||
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);
|
||||||
|
|
@ -113,7 +141,10 @@ export default class WifiSignalPlusExtension extends Extension {
|
||||||
}
|
}
|
||||||
|
|
||||||
disable(): void {
|
disable(): void {
|
||||||
|
this.stopBackgroundScanTimer();
|
||||||
this.stopRefreshTimer();
|
this.stopRefreshTimer();
|
||||||
|
this.wifiService?.unwatchDeviceSignals();
|
||||||
|
this.clearNearbyItems();
|
||||||
this.indicator?.destroy();
|
this.indicator?.destroy();
|
||||||
this.wifiService?.destroy();
|
this.wifiService?.destroy();
|
||||||
|
|
||||||
|
|
@ -124,6 +155,10 @@ 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.nearbySeparator = null;
|
||||||
|
this.nearbyItems = [];
|
||||||
|
this.currentConnectedBssid = undefined;
|
||||||
|
this.isMenuOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private createIndicator(): void {
|
private createIndicator(): void {
|
||||||
|
|
@ -156,7 +191,13 @@ 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.refresh();
|
||||||
|
} else {
|
||||||
|
this.startBackgroundScanTimer();
|
||||||
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -177,6 +218,9 @@ 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.nearbySeparator = new PopupMenu.PopupSeparatorMenuItem('Nearby Networks');
|
||||||
|
menu.addMenuItem(this.nearbySeparator);
|
||||||
}
|
}
|
||||||
|
|
||||||
private addMenuItem(
|
private addMenuItem(
|
||||||
|
|
@ -319,8 +363,13 @@ export default class WifiSignalPlusExtension extends Extension {
|
||||||
if (!this.wifiService || !this.label) return;
|
if (!this.wifiService || !this.label) return;
|
||||||
|
|
||||||
const info = await this.wifiService.getConnectionInfo();
|
const info = await this.wifiService.getConnectionInfo();
|
||||||
|
this.currentConnectedBssid = isConnected(info) ? info.bssid : undefined;
|
||||||
this.updateIndicatorLabel(info);
|
this.updateIndicatorLabel(info);
|
||||||
this.updateMenuContent(info);
|
this.updateMenuContent(info);
|
||||||
|
|
||||||
|
if (this.isMenuOpen) {
|
||||||
|
this.updateNearbyNetworks();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateIndicatorLabel(info: WifiConnectionInfo): void {
|
private updateIndicatorLabel(info: WifiConnectionInfo): void {
|
||||||
|
|
@ -464,6 +513,227 @@ export default class WifiSignalPlusExtension extends Extension {
|
||||||
return `${signalStrength} dBm (${quality})`;
|
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 {
|
private startRefreshTimer(): void {
|
||||||
this.stopRefreshTimer();
|
this.stopRefreshTimer();
|
||||||
this.refreshTimeout = GLib.timeout_add_seconds(
|
this.refreshTimeout = GLib.timeout_add_seconds(
|
||||||
|
|
@ -482,4 +752,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
29
src/types.ts
29
src/types.ts
|
|
@ -141,6 +141,19 @@ export interface ConnectedInfo extends BaseConnectionInfo {
|
||||||
readonly rxBitrate: BitrateMbps | null;
|
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 type WifiConnectionInfo = DisconnectedInfo | ConnectedInfo;
|
||||||
|
|
||||||
export function isConnected(info: WifiConnectionInfo): info is 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 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';
|
||||||
|
}
|
||||||
|
|
||||||
export function createEmptyIwLinkInfo(): IwLinkInfo {
|
export function createEmptyIwLinkInfo(): IwLinkInfo {
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
generation: WIFI_GENERATIONS.UNKNOWN,
|
generation: WIFI_GENERATIONS.UNKNOWN,
|
||||||
|
|
|
||||||
|
|
@ -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,7 @@ import {
|
||||||
getGenerationIconFilename,
|
getGenerationIconFilename,
|
||||||
isKnownGeneration,
|
isKnownGeneration,
|
||||||
} from './wifiGeneration';
|
} from './wifiGeneration';
|
||||||
import { GUARD_INTERVALS } from './types';
|
import { GUARD_INTERVALS, asSignalPercent, getSignalQualityFromPercent } 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 +426,222 @@ 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
202
src/wifiInfo.ts
202
src/wifiInfo.ts
|
|
@ -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,104 @@ 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 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(
|
private async buildConnectedInfo(
|
||||||
device: NM.DeviceWifi,
|
device: NM.DeviceWifi,
|
||||||
ap: NM.AccessPoint,
|
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) {
|
private async executeIwLink(interfaceName: string | null) {
|
||||||
if (!interfaceName) {
|
if (!interfaceName) {
|
||||||
return createEmptyIwLinkInfo();
|
return createEmptyIwLinkInfo();
|
||||||
|
|
@ -276,3 +395,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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -118,3 +118,66 @@
|
||||||
.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-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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue