Compare commits
15 commits
b5099781eb
...
896e513a17
| Author | SHA1 | Date | |
|---|---|---|---|
| 896e513a17 | |||
| 9db21cd228 | |||
| b186c99efb | |||
| 8fa946085d | |||
| 913d63a0c4 | |||
| fffe358a3c | |||
| 7fbced9d25 | |||
| be3ad57b67 | |||
| 7c771939e2 | |||
| 956f4b5916 | |||
| d0ad901aab | |||
| 41dd546394 | |||
| 6d27c67cbd | |||
| ce7c6bcbef | |||
| 8b1f1b3973 |
8 changed files with 1241 additions and 63 deletions
|
|
@ -3,7 +3,7 @@
|
||||||
"name": "WiFi Signal Plus",
|
"name": "WiFi Signal Plus",
|
||||||
"description": "Displays WiFi generation (4/5/6/7) in the top bar with detailed connection info on hover",
|
"description": "Displays WiFi generation (4/5/6/7) in the top bar with detailed connection info on hover",
|
||||||
"shell-version": ["45", "46", "47", "48", "49"],
|
"shell-version": ["45", "46", "47", "48", "49"],
|
||||||
"version": 4,
|
"version": 5,
|
||||||
"url": "https://github.com/JalilArfaoui/gnome-extension-wifi-signal-plus",
|
"url": "https://github.com/JalilArfaoui/gnome-extension-wifi-signal-plus",
|
||||||
"donations": {
|
"donations": {
|
||||||
"liberapay": "Jalil"
|
"liberapay": "Jalil"
|
||||||
|
|
|
||||||
|
|
@ -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=..",
|
||||||
|
|
|
||||||
602
src/extension.ts
602
src/extension.ts
|
|
@ -11,6 +11,7 @@ import St from 'gi://St';
|
||||||
import { Extension } from 'resource:///org/gnome/shell/extensions/extension.js';
|
import { Extension } from 'resource:///org/gnome/shell/extensions/extension.js';
|
||||||
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
|
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
|
||||||
import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js';
|
import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js';
|
||||||
|
import * as BoxPointer from 'resource:///org/gnome/shell/ui/boxpointer.js';
|
||||||
import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js';
|
import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -19,17 +20,26 @@ import {
|
||||||
isConnected,
|
isConnected,
|
||||||
type WifiConnectionInfo,
|
type WifiConnectionInfo,
|
||||||
type ConnectedInfo,
|
type ConnectedInfo,
|
||||||
|
type ScannedNetwork,
|
||||||
} from './wifiInfo.js';
|
} from './wifiInfo.js';
|
||||||
import {
|
import {
|
||||||
WIFI_GENERATIONS,
|
|
||||||
GENERATION_CSS_CLASSES,
|
GENERATION_CSS_CLASSES,
|
||||||
getGenerationLabel,
|
getGenerationLabel,
|
||||||
getGenerationDescription,
|
getGenerationDescription,
|
||||||
getGenerationIconFilename,
|
getGenerationIconFilename,
|
||||||
} from './wifiGeneration.js';
|
} from './wifiGeneration.js';
|
||||||
import type { GenerationCssClass, ChannelWidthMHz, SignalDbm } from './types.js';
|
import {
|
||||||
|
getSignalQualityFromPercent,
|
||||||
|
getSpeedQuality,
|
||||||
|
type GenerationCssClass,
|
||||||
|
type ChannelWidthMHz,
|
||||||
|
type SignalDbm,
|
||||||
|
type SpeedQuality,
|
||||||
|
type WifiGeneration,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
const REFRESH_INTERVAL_SECONDS = 5;
|
const REFRESH_INTERVAL_SECONDS = 5;
|
||||||
|
const BACKGROUND_SCAN_INTERVAL_SECONDS = 300;
|
||||||
const PLACEHOLDER = '--' as const;
|
const PLACEHOLDER = '--' as const;
|
||||||
// WiFi 7 theoretical max: 320 MHz, MCS 13 (4096-QAM 5/6), 4×4 MIMO, GI 0.8µs
|
// WiFi 7 theoretical max: 320 MHz, MCS 13 (4096-QAM 5/6), 4×4 MIMO, GI 0.8µs
|
||||||
const MAX_SPEED_MBPS = 5760;
|
const MAX_SPEED_MBPS = 5760;
|
||||||
|
|
@ -46,10 +56,24 @@ const SIGNAL_QUALITY_COLORS: Readonly<Record<string, [number, number, number]>>
|
||||||
Poor: [0.88, 0.11, 0.14],
|
Poor: [0.88, 0.11, 0.14],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SIGNAL_QUALITY_BAR_COLORS: Readonly<Record<string, string>> = {
|
||||||
|
Excellent: '#33d17a',
|
||||||
|
Good: '#8ff0a4',
|
||||||
|
Fair: '#f6d32d',
|
||||||
|
Weak: '#ff7800',
|
||||||
|
Poor: '#e01b24',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SPEED_QUALITY_COLORS: Readonly<Record<SpeedQuality, string>> = {
|
||||||
|
Excellent: '#c061cb',
|
||||||
|
VeryGood: '#62a0ea',
|
||||||
|
Good: '#33d17a',
|
||||||
|
OK: '#f6d32d',
|
||||||
|
Weak: '#ff7800',
|
||||||
|
Poor: '#e01b24',
|
||||||
|
};
|
||||||
|
|
||||||
type MenuItemId =
|
type MenuItemId =
|
||||||
| 'ssid'
|
|
||||||
| 'generation'
|
|
||||||
| 'band'
|
|
||||||
| 'bitrate'
|
| 'bitrate'
|
||||||
| 'channelWidth'
|
| 'channelWidth'
|
||||||
| 'mcs'
|
| 'mcs'
|
||||||
|
|
@ -63,12 +87,6 @@ interface MenuItemConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
const MENU_STRUCTURE: readonly MenuItemConfig[][] = [
|
const MENU_STRUCTURE: readonly MenuItemConfig[][] = [
|
||||||
// Section: Connection
|
|
||||||
[
|
|
||||||
{ id: 'ssid', label: 'Network' },
|
|
||||||
{ id: 'generation', label: 'Generation' },
|
|
||||||
{ id: 'band', label: 'Band' },
|
|
||||||
],
|
|
||||||
// Section: Performance
|
// Section: Performance
|
||||||
[
|
[
|
||||||
{ id: 'bitrate', label: 'Speed' },
|
{ id: 'bitrate', label: 'Speed' },
|
||||||
|
|
@ -85,28 +103,57 @@ const MENU_STRUCTURE: readonly MenuItemConfig[][] = [
|
||||||
|
|
||||||
const ITEMS_WITH_BAR: ReadonlySet<MenuItemId> = new Set(['bitrate', 'channelWidth']);
|
const ITEMS_WITH_BAR: ReadonlySet<MenuItemId> = new Set(['bitrate', 'channelWidth']);
|
||||||
|
|
||||||
|
interface NearbyNetworkCard extends PopupMenu.PopupSubMenuMenuItem {
|
||||||
|
_ssid: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default class WifiSignalPlusExtension extends Extension {
|
export default class WifiSignalPlusExtension extends Extension {
|
||||||
private indicator: PanelMenu.Button | null = null;
|
private indicator: PanelMenu.Button | null = null;
|
||||||
private icon: St.Icon | null = null;
|
private icon: St.Icon | null = null;
|
||||||
private label: St.Label | null = null;
|
private label: St.Label | null = null;
|
||||||
private wifiService: WifiInfoService | null = null;
|
private wifiService: WifiInfoService | null = null;
|
||||||
private refreshTimeout: number | null = null;
|
private refreshTimeout: number | null = null;
|
||||||
|
private backgroundScanTimeout: number | null = null;
|
||||||
private signalGraph: St.DrawingArea | null = null;
|
private signalGraph: St.DrawingArea | null = null;
|
||||||
private readonly signalHistory: number[] = [];
|
private readonly signalHistory: number[] = [];
|
||||||
private readonly menuItems = new Map<
|
private readonly menuItems = new Map<
|
||||||
MenuItemId,
|
MenuItemId,
|
||||||
{ item: PopupMenu.PopupBaseMenuItem; label: St.Label; value: St.Label; barFill?: St.Widget }
|
{ item: PopupMenu.PopupBaseMenuItem; label: St.Label; value: St.Label; barFill?: St.Widget }
|
||||||
>();
|
>();
|
||||||
|
private headerSsidLabel: St.Label | null = null;
|
||||||
|
private headerGenerationLabel: St.Label | null = null;
|
||||||
|
private headerBandLabel: St.Label | null = null;
|
||||||
|
private headerIcon: St.Icon | null = null;
|
||||||
|
private accessPointsSeparator: PopupMenu.PopupSeparatorMenuItem | null = null;
|
||||||
|
private accessPointsSection: PopupMenu.PopupMenuSection | null = null;
|
||||||
|
private accessPointsItems: PopupMenu.PopupBaseMenuItem[] = [];
|
||||||
|
private accessPointsUpdatePending = false;
|
||||||
|
private nearbySeparator: PopupMenu.PopupSeparatorMenuItem | null = null;
|
||||||
|
private nearbySection: PopupMenu.PopupMenuSection | null = null;
|
||||||
|
private nearbyItems: NearbyNetworkCard[] = [];
|
||||||
|
private nearbyUpdatePending = false;
|
||||||
|
private currentConnectedSsid: string | undefined;
|
||||||
|
private currentConnectedBssid: string | undefined;
|
||||||
|
private isMenuOpen = false;
|
||||||
|
private enableEpoch = 0;
|
||||||
|
|
||||||
enable(): void {
|
enable(): void {
|
||||||
|
const epoch = ++this.enableEpoch;
|
||||||
this.wifiService = new WifiInfoService();
|
this.wifiService = new WifiInfoService();
|
||||||
this.wifiService
|
this.wifiService
|
||||||
.init()
|
.init()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
if (epoch !== this.enableEpoch) return;
|
||||||
if (!this.wifiService) return;
|
if (!this.wifiService) return;
|
||||||
|
this.wifiService.requestScan();
|
||||||
|
this.wifiService.watchDeviceSignals(() => {
|
||||||
|
this.wifiService?.requestScan();
|
||||||
|
this.scheduleRefresh();
|
||||||
|
});
|
||||||
this.createIndicator();
|
this.createIndicator();
|
||||||
this.refresh();
|
this.scheduleRefresh();
|
||||||
this.startRefreshTimer();
|
this.startRefreshTimer();
|
||||||
|
this.startBackgroundScanTimer();
|
||||||
})
|
})
|
||||||
.catch(e => {
|
.catch(e => {
|
||||||
console.error('[WiFi Signal Plus] Failed to initialize:', e);
|
console.error('[WiFi Signal Plus] Failed to initialize:', e);
|
||||||
|
|
@ -114,7 +161,11 @@ export default class WifiSignalPlusExtension extends Extension {
|
||||||
}
|
}
|
||||||
|
|
||||||
disable(): void {
|
disable(): void {
|
||||||
|
this.stopBackgroundScanTimer();
|
||||||
this.stopRefreshTimer();
|
this.stopRefreshTimer();
|
||||||
|
this.wifiService?.unwatchDeviceSignals();
|
||||||
|
this.clearAccessPointsItems();
|
||||||
|
this.clearNearbyItems();
|
||||||
this.indicator?.destroy();
|
this.indicator?.destroy();
|
||||||
this.wifiService?.destroy();
|
this.wifiService?.destroy();
|
||||||
|
|
||||||
|
|
@ -125,6 +176,22 @@ export default class WifiSignalPlusExtension extends Extension {
|
||||||
this.signalGraph = null;
|
this.signalGraph = null;
|
||||||
this.signalHistory.length = 0;
|
this.signalHistory.length = 0;
|
||||||
this.menuItems.clear();
|
this.menuItems.clear();
|
||||||
|
this.headerSsidLabel = null;
|
||||||
|
this.headerGenerationLabel = null;
|
||||||
|
this.headerBandLabel = null;
|
||||||
|
this.headerIcon = null;
|
||||||
|
this.accessPointsSeparator = null;
|
||||||
|
this.accessPointsSection = null;
|
||||||
|
this.accessPointsItems = [];
|
||||||
|
this.accessPointsUpdatePending = false;
|
||||||
|
this.nearbySeparator = null;
|
||||||
|
this.nearbySection = null;
|
||||||
|
this.nearbyItems = [];
|
||||||
|
this.refreshPending = false;
|
||||||
|
this.nearbyUpdatePending = false;
|
||||||
|
this.currentConnectedSsid = undefined;
|
||||||
|
this.currentConnectedBssid = undefined;
|
||||||
|
this.isMenuOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private createIndicator(): void {
|
private createIndicator(): void {
|
||||||
|
|
@ -157,18 +224,24 @@ export default class WifiSignalPlusExtension extends Extension {
|
||||||
const menu = this.indicator.menu as PopupMenu.PopupMenu;
|
const menu = this.indicator.menu as PopupMenu.PopupMenu;
|
||||||
menu.box.add_style_class_name('wifi-signal-plus-popup');
|
menu.box.add_style_class_name('wifi-signal-plus-popup');
|
||||||
menu.connect('open-state-changed', (_menu, isOpen: boolean) => {
|
menu.connect('open-state-changed', (_menu, isOpen: boolean) => {
|
||||||
if (isOpen) this.refresh();
|
this.isMenuOpen = isOpen;
|
||||||
|
if (isOpen) {
|
||||||
|
this.stopBackgroundScanTimer();
|
||||||
|
this.scheduleRefresh();
|
||||||
|
} else {
|
||||||
|
this.startBackgroundScanTimer();
|
||||||
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
const sectionHeaders = ['', 'Performance', 'Signal'];
|
this.addConnectionHeader(menu);
|
||||||
|
|
||||||
const SIGNAL_SECTION_INDEX = 2;
|
const sectionHeaders = ['Performance', 'Signal'];
|
||||||
|
|
||||||
|
const SIGNAL_SECTION_INDEX = 1;
|
||||||
|
|
||||||
MENU_STRUCTURE.forEach((section, index) => {
|
MENU_STRUCTURE.forEach((section, index) => {
|
||||||
if (sectionHeaders[index]) {
|
menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem(sectionHeaders[index]));
|
||||||
menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem(sectionHeaders[index]));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (index === SIGNAL_SECTION_INDEX) {
|
if (index === SIGNAL_SECTION_INDEX) {
|
||||||
this.addSignalGraph(menu);
|
this.addSignalGraph(menu);
|
||||||
|
|
@ -178,6 +251,60 @@ export default class WifiSignalPlusExtension extends Extension {
|
||||||
this.addMenuItem(menu, id, label, ITEMS_WITH_BAR.has(id));
|
this.addMenuItem(menu, id, label, ITEMS_WITH_BAR.has(id));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.accessPointsSeparator = new PopupMenu.PopupSeparatorMenuItem('Access Points');
|
||||||
|
this.accessPointsSeparator.visible = false;
|
||||||
|
menu.addMenuItem(this.accessPointsSeparator);
|
||||||
|
this.accessPointsSection = new PopupMenu.PopupMenuSection();
|
||||||
|
this.accessPointsSection.actor.visible = false;
|
||||||
|
menu.addMenuItem(this.accessPointsSection);
|
||||||
|
|
||||||
|
this.nearbySeparator = new PopupMenu.PopupSeparatorMenuItem('Nearby Networks');
|
||||||
|
menu.addMenuItem(this.nearbySeparator);
|
||||||
|
|
||||||
|
this.nearbySection = new PopupMenu.PopupMenuSection();
|
||||||
|
menu.addMenuItem(this.nearbySection);
|
||||||
|
}
|
||||||
|
|
||||||
|
private addConnectionHeader(menu: PopupMenu.PopupMenu): void {
|
||||||
|
const item = new PopupMenu.PopupBaseMenuItem({ reactive: false });
|
||||||
|
item.add_style_class_name('wifi-connection-header');
|
||||||
|
|
||||||
|
const leftBox = new St.BoxLayout({
|
||||||
|
vertical: true,
|
||||||
|
x_expand: true,
|
||||||
|
y_align: Clutter.ActorAlign.CENTER,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.headerSsidLabel = new St.Label({
|
||||||
|
text: PLACEHOLDER,
|
||||||
|
style_class: 'wifi-connection-header-ssid',
|
||||||
|
});
|
||||||
|
leftBox.add_child(this.headerSsidLabel);
|
||||||
|
|
||||||
|
this.headerGenerationLabel = new St.Label({
|
||||||
|
text: PLACEHOLDER,
|
||||||
|
style_class: 'wifi-connection-header-generation',
|
||||||
|
});
|
||||||
|
leftBox.add_child(this.headerGenerationLabel);
|
||||||
|
|
||||||
|
this.headerBandLabel = new St.Label({
|
||||||
|
text: PLACEHOLDER,
|
||||||
|
style_class: 'wifi-connection-header-band',
|
||||||
|
});
|
||||||
|
leftBox.add_child(this.headerBandLabel);
|
||||||
|
|
||||||
|
item.add_child(leftBox);
|
||||||
|
|
||||||
|
this.headerIcon = new St.Icon({
|
||||||
|
icon_size: 48,
|
||||||
|
style_class: 'wifi-connection-header-icon',
|
||||||
|
y_align: Clutter.ActorAlign.CENTER,
|
||||||
|
visible: false,
|
||||||
|
});
|
||||||
|
item.add_child(this.headerIcon);
|
||||||
|
|
||||||
|
menu.addMenuItem(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
private addMenuItem(
|
private addMenuItem(
|
||||||
|
|
@ -316,27 +443,63 @@ export default class WifiSignalPlusExtension extends Extension {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async refresh(): Promise<void> {
|
private updateHeaderIcon(generation: WifiGeneration): void {
|
||||||
if (!this.wifiService || !this.label) return;
|
if (!this.headerIcon) return;
|
||||||
|
|
||||||
const info = await this.wifiService.getConnectionInfo();
|
const iconFilename = getGenerationIconFilename(generation);
|
||||||
this.updateIndicatorLabel(info);
|
if (!iconFilename) {
|
||||||
this.updateMenuContent(info);
|
this.headerIcon.visible = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconPath = GLib.build_filenamev([this.path, 'icons', iconFilename]);
|
||||||
|
const file = Gio.File.new_for_path(iconPath);
|
||||||
|
this.headerIcon.gicon = new Gio.FileIcon({ file });
|
||||||
|
this.headerIcon.visible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private refreshPending = false;
|
||||||
|
|
||||||
|
private scheduleRefresh(): void {
|
||||||
|
this.refresh().catch(e => {
|
||||||
|
console.error('[WiFi Signal Plus] Refresh failed:', e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refresh(): Promise<void> {
|
||||||
|
if (!this.wifiService || !this.label || this.refreshPending) return;
|
||||||
|
|
||||||
|
this.refreshPending = true;
|
||||||
|
try {
|
||||||
|
const info = await this.wifiService.getConnectionInfo();
|
||||||
|
if (!this.wifiService) return;
|
||||||
|
|
||||||
|
this.currentConnectedSsid = isConnected(info) ? info.ssid : undefined;
|
||||||
|
this.currentConnectedBssid = isConnected(info) ? info.bssid : undefined;
|
||||||
|
this.updateIndicatorLabel(info);
|
||||||
|
this.updateMenuContent(info);
|
||||||
|
|
||||||
|
if (this.isMenuOpen) {
|
||||||
|
await this.updateAccessPoints();
|
||||||
|
await this.updateNearbyNetworks();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.refreshPending = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateIndicatorLabel(info: WifiConnectionInfo): void {
|
private updateIndicatorLabel(info: WifiConnectionInfo): void {
|
||||||
if (!this.icon || !this.label) return;
|
if (!this.indicator || !this.icon || !this.label) return;
|
||||||
|
|
||||||
this.clearGenerationStyles();
|
this.clearGenerationStyles();
|
||||||
|
|
||||||
if (!isConnected(info)) {
|
if (!isConnected(info)) {
|
||||||
this.icon.visible = false;
|
this.indicator.visible = false;
|
||||||
this.label.visible = true;
|
|
||||||
this.label.set_text('WiFi --');
|
|
||||||
this.label.add_style_class_name(GENERATION_CSS_CLASSES[WIFI_GENERATIONS.UNKNOWN]);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.indicator.visible = true;
|
||||||
|
|
||||||
const iconFilename = getGenerationIconFilename(info.generation);
|
const iconFilename = getGenerationIconFilename(info.generation);
|
||||||
if (iconFilename) {
|
if (iconFilename) {
|
||||||
const iconPath = GLib.build_filenamev([this.path, 'icons', iconFilename]);
|
const iconPath = GLib.build_filenamev([this.path, 'icons', iconFilename]);
|
||||||
|
|
@ -372,21 +535,31 @@ export default class WifiSignalPlusExtension extends Extension {
|
||||||
}
|
}
|
||||||
|
|
||||||
private showDisconnectedState(): void {
|
private showDisconnectedState(): void {
|
||||||
|
this.headerSsidLabel?.set_text('Not connected');
|
||||||
|
this.headerGenerationLabel?.set_text('');
|
||||||
|
this.headerBandLabel?.set_text('');
|
||||||
|
if (this.headerIcon) {
|
||||||
|
this.headerIcon.visible = false;
|
||||||
|
}
|
||||||
|
|
||||||
for (const section of MENU_STRUCTURE) {
|
for (const section of MENU_STRUCTURE) {
|
||||||
for (const { id } of section) {
|
for (const { id } of section) {
|
||||||
const value = id === 'ssid' ? 'Not connected' : PLACEHOLDER;
|
this.updateMenuItem(id, PLACEHOLDER, 0);
|
||||||
this.updateMenuItem(id, value, 0);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.clearAccessPointsItems();
|
||||||
|
this.setAccessPointsVisible(false);
|
||||||
|
|
||||||
this.signalHistory.length = 0;
|
this.signalHistory.length = 0;
|
||||||
this.signalGraph?.queue_repaint();
|
this.signalGraph?.queue_repaint();
|
||||||
}
|
}
|
||||||
|
|
||||||
private showConnectedState(info: ConnectedInfo): void {
|
private showConnectedState(info: ConnectedInfo): void {
|
||||||
this.updateMenuItem('ssid', info.ssid);
|
this.headerSsidLabel?.set_text(info.ssid);
|
||||||
this.updateMenuItem('generation', getGenerationDescription(info.generation));
|
this.headerGenerationLabel?.set_text(getGenerationDescription(info.generation));
|
||||||
this.updateMenuItem('band', this.formatBand(info));
|
this.headerBandLabel?.set_text(this.formatBand(info));
|
||||||
|
this.updateHeaderIcon(info.generation);
|
||||||
this.updateMenuItem(
|
this.updateMenuItem(
|
||||||
'bitrate',
|
'bitrate',
|
||||||
this.formatBitrate(info),
|
this.formatBitrate(info),
|
||||||
|
|
@ -428,17 +601,27 @@ export default class WifiSignalPlusExtension extends Extension {
|
||||||
}
|
}
|
||||||
|
|
||||||
private formatBitrate(info: ConnectedInfo): string {
|
private formatBitrate(info: ConnectedInfo): string {
|
||||||
const { txBitrate, rxBitrate, bitrate } = info;
|
const { txBitrate, rxBitrate, bitrate, maxBitrate } = info;
|
||||||
|
|
||||||
|
let speed: string;
|
||||||
if (txBitrate !== null && rxBitrate !== null) {
|
if (txBitrate !== null && rxBitrate !== null) {
|
||||||
const tx = txBitrate as number;
|
const tx = txBitrate as number;
|
||||||
const rx = rxBitrate as number;
|
const rx = rxBitrate as number;
|
||||||
return tx === rx ? `${tx} Mbit/s` : `↑${tx} ↓${rx} Mbit/s`;
|
speed = tx === rx ? `${tx} Mbit/s` : `↑${tx} ↓${rx} Mbit/s`;
|
||||||
|
} else if (txBitrate !== null) {
|
||||||
|
speed = `↑${txBitrate} Mbit/s`;
|
||||||
|
} else if (rxBitrate !== null) {
|
||||||
|
speed = `↓${rxBitrate} Mbit/s`;
|
||||||
|
} else {
|
||||||
|
speed = `${bitrate} Mbit/s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (txBitrate !== null) return `↑${txBitrate} Mbit/s`;
|
const max = maxBitrate as number;
|
||||||
if (rxBitrate !== null) return `↓${rxBitrate} Mbit/s`;
|
if (max > 0) {
|
||||||
return `${bitrate} Mbit/s`;
|
speed += ` (max ${max})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return speed;
|
||||||
}
|
}
|
||||||
|
|
||||||
private formatChannelWidth(width: ChannelWidthMHz | null): string {
|
private formatChannelWidth(width: ChannelWidthMHz | null): string {
|
||||||
|
|
@ -466,13 +649,335 @@ export default class WifiSignalPlusExtension extends Extension {
|
||||||
return `${signalStrength} dBm (${quality})`;
|
return `${signalStrength} dBm (${quality})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async updateAccessPoints(): Promise<void> {
|
||||||
|
if (!this.wifiService || !this.accessPointsSection || this.accessPointsUpdatePending) return;
|
||||||
|
|
||||||
|
if (!this.currentConnectedSsid) {
|
||||||
|
this.clearAccessPointsItems();
|
||||||
|
this.setAccessPointsVisible(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.accessPointsUpdatePending = true;
|
||||||
|
let accessPoints: ScannedNetwork[];
|
||||||
|
try {
|
||||||
|
accessPoints = await this.wifiService.getAccessPointsForSsid(this.currentConnectedSsid);
|
||||||
|
} finally {
|
||||||
|
this.accessPointsUpdatePending = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.clearAccessPointsItems();
|
||||||
|
|
||||||
|
if (accessPoints.length <= 1) {
|
||||||
|
this.setAccessPointsVisible(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setAccessPointsVisible(true);
|
||||||
|
|
||||||
|
for (const ap of accessPoints) {
|
||||||
|
const isActive = ap.bssid === this.currentConnectedBssid?.toLowerCase();
|
||||||
|
const row = this.createApRow(ap, isActive ? 'connected' : 'spacer');
|
||||||
|
this.accessPointsSection.addMenuItem(row);
|
||||||
|
this.accessPointsItems.push(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setAccessPointsVisible(visible: boolean): void {
|
||||||
|
if (this.accessPointsSeparator) {
|
||||||
|
this.accessPointsSeparator.visible = visible;
|
||||||
|
}
|
||||||
|
if (this.accessPointsSection) {
|
||||||
|
this.accessPointsSection.actor.visible = visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearAccessPointsItems(): void {
|
||||||
|
for (const item of this.accessPointsItems) {
|
||||||
|
item.destroy();
|
||||||
|
}
|
||||||
|
this.accessPointsItems = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateNearbyNetworks(): Promise<void> {
|
||||||
|
if (!this.wifiService || !this.nearbySection || this.nearbyUpdatePending) return;
|
||||||
|
|
||||||
|
this.nearbyUpdatePending = true;
|
||||||
|
let grouped: Map<string, ScannedNetwork[]>;
|
||||||
|
try {
|
||||||
|
grouped = await this.wifiService.getAvailableNetworks(this.currentConnectedSsid);
|
||||||
|
} finally {
|
||||||
|
this.nearbyUpdatePending = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expandedSsids = new Set(
|
||||||
|
this.nearbyItems
|
||||||
|
.filter(card => card.menu.isOpen)
|
||||||
|
.map(card => card._ssid),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.clearNearbyItems();
|
||||||
|
|
||||||
|
for (const [ssid, networks] of grouped) {
|
||||||
|
const card = this.createNetworkCard(ssid, networks[0], networks);
|
||||||
|
this.nearbySection.addMenuItem(card);
|
||||||
|
this.nearbyItems.push(card);
|
||||||
|
|
||||||
|
if (expandedSsids.has(ssid)) {
|
||||||
|
card.menu.open(BoxPointer.PopupAnimation.NONE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createNetworkCard(
|
||||||
|
ssid: string,
|
||||||
|
bestAp: ScannedNetwork,
|
||||||
|
allAps: ScannedNetwork[],
|
||||||
|
): NearbyNetworkCard {
|
||||||
|
const card = new PopupMenu.PopupSubMenuMenuItem(ssid) as NearbyNetworkCard;
|
||||||
|
card._ssid = ssid;
|
||||||
|
card.add_style_class_name('wifi-nearby-card');
|
||||||
|
|
||||||
|
const barTrack = this.createCardHeader(card, ssid, bestAp, allAps.length);
|
||||||
|
|
||||||
|
card.menu.connect('open-state-changed', (_menu: PopupMenu.PopupSubMenu, isOpen: boolean): undefined => {
|
||||||
|
barTrack.visible = !isOpen;
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const ap of allAps) {
|
||||||
|
const row = this.createApRow(ap);
|
||||||
|
card.menu.addMenuItem(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createCardHeader(
|
||||||
|
card: PopupMenu.PopupSubMenuMenuItem,
|
||||||
|
ssid: string,
|
||||||
|
bestAp: ScannedNetwork,
|
||||||
|
apCount: number,
|
||||||
|
): St.Widget {
|
||||||
|
const headerBox = new St.BoxLayout({
|
||||||
|
style_class: 'wifi-nearby-card-header',
|
||||||
|
x_expand: true,
|
||||||
|
y_align: Clutter.ActorAlign.CENTER,
|
||||||
|
});
|
||||||
|
|
||||||
|
const genIcon = this.createGenerationIcon(bestAp.generation);
|
||||||
|
if (genIcon) {
|
||||||
|
headerBox.add_child(genIcon);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ssidLabel = new St.Label({
|
||||||
|
text: ssid,
|
||||||
|
style_class: 'wifi-nearby-card-ssid',
|
||||||
|
x_expand: true,
|
||||||
|
y_align: Clutter.ActorAlign.CENTER,
|
||||||
|
});
|
||||||
|
headerBox.add_child(ssidLabel);
|
||||||
|
|
||||||
|
const metricsBox = this.createCardMetrics(bestAp, apCount);
|
||||||
|
headerBox.add_child(metricsBox);
|
||||||
|
|
||||||
|
const outerBox = new St.BoxLayout({ vertical: true, x_expand: true });
|
||||||
|
outerBox.add_child(headerBox);
|
||||||
|
|
||||||
|
const quality = getSignalQualityFromPercent(bestAp.signalPercent);
|
||||||
|
const signalColor = SIGNAL_QUALITY_BAR_COLORS[quality] ?? '#ffffff';
|
||||||
|
|
||||||
|
const barTrack = new St.Widget({
|
||||||
|
style_class: 'wifi-bar-track',
|
||||||
|
x_expand: true,
|
||||||
|
});
|
||||||
|
const barFill = new St.Widget({ style_class: 'wifi-bar-fill' });
|
||||||
|
barFill.set_style(`background-color: ${signalColor};`);
|
||||||
|
barTrack.add_child(barFill);
|
||||||
|
outerBox.add_child(barTrack);
|
||||||
|
|
||||||
|
barTrack.connect('notify::allocation', () => {
|
||||||
|
const trackWidth = barTrack.width;
|
||||||
|
if (trackWidth > 0) {
|
||||||
|
barFill.set_width(Math.round(((bestAp.signalPercent as number) / 100) * trackWidth));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
card.replace_child(card.label, outerBox);
|
||||||
|
|
||||||
|
// Remove the expander (identified by .popup-menu-item-expander)
|
||||||
|
for (const child of card.get_children()) {
|
||||||
|
const widget = child as St.Widget;
|
||||||
|
if (widget.has_style_class_name?.('popup-menu-item-expander')) {
|
||||||
|
card.remove_child(child);
|
||||||
|
child.destroy();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return barTrack;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createCardMetrics(ap: ScannedNetwork, apCount: number): St.BoxLayout {
|
||||||
|
const box = new St.BoxLayout({
|
||||||
|
style_class: 'wifi-nearby-card-header',
|
||||||
|
y_align: Clutter.ActorAlign.CENTER,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Security badge
|
||||||
|
const secBadge = new St.Label({
|
||||||
|
text: ap.security,
|
||||||
|
style_class: ap.security === 'Open'
|
||||||
|
? 'wifi-nearby-badge wifi-nearby-badge-open'
|
||||||
|
: 'wifi-nearby-badge',
|
||||||
|
y_align: Clutter.ActorAlign.CENTER,
|
||||||
|
});
|
||||||
|
box.add_child(secBadge);
|
||||||
|
|
||||||
|
// AP count
|
||||||
|
if (apCount > 1) {
|
||||||
|
const countLabel = new St.Label({
|
||||||
|
text: `×${apCount}`,
|
||||||
|
style_class: 'wifi-nearby-card-count',
|
||||||
|
y_align: Clutter.ActorAlign.CENTER,
|
||||||
|
});
|
||||||
|
box.add_child(countLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createApRow(ap: ScannedNetwork, connectedIndicator: 'connected' | 'spacer' | 'none' = 'none'): PopupMenu.PopupBaseMenuItem {
|
||||||
|
const item = new PopupMenu.PopupBaseMenuItem({ reactive: false });
|
||||||
|
item.add_style_class_name('wifi-nearby-ap');
|
||||||
|
|
||||||
|
if (connectedIndicator === 'connected') {
|
||||||
|
const connectedIcon = new St.Icon({
|
||||||
|
icon_name: 'emblem-ok-symbolic',
|
||||||
|
icon_size: 12,
|
||||||
|
style_class: 'wifi-ap-connected-icon',
|
||||||
|
y_align: Clutter.ActorAlign.CENTER,
|
||||||
|
});
|
||||||
|
item.add_child(connectedIcon);
|
||||||
|
} else if (connectedIndicator === 'spacer') {
|
||||||
|
const spacer = new St.Widget({ style_class: 'wifi-ap-icon-spacer' });
|
||||||
|
item.add_child(spacer);
|
||||||
|
}
|
||||||
|
|
||||||
|
const outerBox = new St.BoxLayout({ vertical: true, x_expand: true });
|
||||||
|
|
||||||
|
// Info row: generation + BSSID + details + signal%
|
||||||
|
const infoRow = new St.BoxLayout({ x_expand: true });
|
||||||
|
|
||||||
|
const genIconFilename = getGenerationIconFilename(ap.generation);
|
||||||
|
if (genIconFilename) {
|
||||||
|
const iconPath = GLib.build_filenamev([this.path, 'icons', genIconFilename]);
|
||||||
|
const genIcon = new St.Icon({
|
||||||
|
gicon: new Gio.FileIcon({ file: Gio.File.new_for_path(iconPath) }),
|
||||||
|
style_class: 'wifi-ap-gen-icon',
|
||||||
|
y_align: Clutter.ActorAlign.CENTER,
|
||||||
|
});
|
||||||
|
infoRow.add_child(genIcon);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bssidLabel = new St.Label({
|
||||||
|
text: ap.bssid.toUpperCase(),
|
||||||
|
style_class: 'wifi-nearby-ap-bssid',
|
||||||
|
y_align: Clutter.ActorAlign.CENTER,
|
||||||
|
});
|
||||||
|
infoRow.add_child(bssidLabel);
|
||||||
|
|
||||||
|
const detailParts: string[] = [];
|
||||||
|
detailParts.push(ap.band);
|
||||||
|
detailParts.push(`Ch ${ap.channel}`);
|
||||||
|
if ((ap.bandwidth as number) > 20) {
|
||||||
|
detailParts.push(`${ap.bandwidth} MHz`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailsLabel = new St.Label({
|
||||||
|
text: detailParts.join(' · '),
|
||||||
|
style_class: 'wifi-nearby-ap-details',
|
||||||
|
x_expand: true,
|
||||||
|
y_align: Clutter.ActorAlign.CENTER,
|
||||||
|
});
|
||||||
|
infoRow.add_child(detailsLabel);
|
||||||
|
|
||||||
|
if ((ap.maxBitrate as number) > 0) {
|
||||||
|
const speedQuality = getSpeedQuality(ap.maxBitrate);
|
||||||
|
const speedLabel = new St.Label({
|
||||||
|
text: `${ap.maxBitrate} Mbit/s`,
|
||||||
|
style_class: 'wifi-nearby-ap-speed',
|
||||||
|
y_align: Clutter.ActorAlign.CENTER,
|
||||||
|
});
|
||||||
|
speedLabel.set_style(`color: ${SPEED_QUALITY_COLORS[speedQuality]};`);
|
||||||
|
infoRow.add_child(speedLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
const quality = getSignalQualityFromPercent(ap.signalPercent);
|
||||||
|
const signalColor = SIGNAL_QUALITY_BAR_COLORS[quality] ?? '#ffffff';
|
||||||
|
|
||||||
|
const signalLabel = new St.Label({
|
||||||
|
text: `${ap.signalPercent}%`,
|
||||||
|
style_class: 'wifi-nearby-ap-signal',
|
||||||
|
y_align: Clutter.ActorAlign.CENTER,
|
||||||
|
});
|
||||||
|
signalLabel.set_style(`color: ${signalColor};`);
|
||||||
|
infoRow.add_child(signalLabel);
|
||||||
|
|
||||||
|
outerBox.add_child(infoRow);
|
||||||
|
|
||||||
|
// Signal bar
|
||||||
|
const barTrack = new St.Widget({
|
||||||
|
style_class: 'wifi-bar-track',
|
||||||
|
x_expand: true,
|
||||||
|
});
|
||||||
|
const barFill = new St.Widget({
|
||||||
|
style_class: 'wifi-bar-fill',
|
||||||
|
});
|
||||||
|
barFill.set_style(`background-color: ${signalColor};`);
|
||||||
|
barTrack.add_child(barFill);
|
||||||
|
outerBox.add_child(barTrack);
|
||||||
|
|
||||||
|
// Set bar width after allocation
|
||||||
|
barTrack.connect('notify::allocation', () => {
|
||||||
|
const trackWidth = barTrack.width;
|
||||||
|
if (trackWidth > 0) {
|
||||||
|
barFill.set_width(Math.round(((ap.signalPercent as number) / 100) * trackWidth));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
item.add_child(outerBox);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createGenerationIcon(generation: WifiGeneration): St.Icon | null {
|
||||||
|
const iconFilename = getGenerationIconFilename(generation);
|
||||||
|
if (!iconFilename) return null;
|
||||||
|
|
||||||
|
const iconPath = GLib.build_filenamev([this.path, 'icons', iconFilename]);
|
||||||
|
const file = Gio.File.new_for_path(iconPath);
|
||||||
|
|
||||||
|
return new St.Icon({
|
||||||
|
gicon: new Gio.FileIcon({ file }),
|
||||||
|
icon_size: 16,
|
||||||
|
style_class: 'wifi-nearby-card-icon',
|
||||||
|
y_align: Clutter.ActorAlign.CENTER,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearNearbyItems(): void {
|
||||||
|
for (const item of this.nearbyItems) {
|
||||||
|
item.destroy();
|
||||||
|
}
|
||||||
|
this.nearbyItems = [];
|
||||||
|
}
|
||||||
|
|
||||||
private startRefreshTimer(): void {
|
private startRefreshTimer(): void {
|
||||||
this.stopRefreshTimer();
|
this.stopRefreshTimer();
|
||||||
this.refreshTimeout = GLib.timeout_add_seconds(
|
this.refreshTimeout = GLib.timeout_add_seconds(
|
||||||
GLib.PRIORITY_DEFAULT,
|
GLib.PRIORITY_DEFAULT,
|
||||||
REFRESH_INTERVAL_SECONDS,
|
REFRESH_INTERVAL_SECONDS,
|
||||||
() => {
|
() => {
|
||||||
this.refresh();
|
this.scheduleRefresh();
|
||||||
return GLib.SOURCE_CONTINUE;
|
return GLib.SOURCE_CONTINUE;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
@ -484,4 +989,23 @@ export default class WifiSignalPlusExtension extends Extension {
|
||||||
this.refreshTimeout = null;
|
this.refreshTimeout = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private startBackgroundScanTimer(): void {
|
||||||
|
this.stopBackgroundScanTimer();
|
||||||
|
this.backgroundScanTimeout = GLib.timeout_add_seconds(
|
||||||
|
GLib.PRIORITY_DEFAULT,
|
||||||
|
BACKGROUND_SCAN_INTERVAL_SECONDS,
|
||||||
|
() => {
|
||||||
|
this.wifiService?.requestScan();
|
||||||
|
return GLib.SOURCE_CONTINUE;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopBackgroundScanTimer(): void {
|
||||||
|
if (this.backgroundScanTimeout !== null) {
|
||||||
|
GLib.source_remove(this.backgroundScanTimeout);
|
||||||
|
this.backgroundScanTimeout = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
51
src/types.ts
51
src/types.ts
|
|
@ -51,6 +51,9 @@ export type FrequencyBand = (typeof FREQUENCY_BANDS)[number];
|
||||||
export const SIGNAL_QUALITIES = ['Excellent', 'Good', 'Fair', 'Weak', 'Poor', 'Unknown'] as const;
|
export const SIGNAL_QUALITIES = ['Excellent', 'Good', 'Fair', 'Weak', 'Poor', 'Unknown'] as const;
|
||||||
export type SignalQuality = (typeof SIGNAL_QUALITIES)[number];
|
export type SignalQuality = (typeof SIGNAL_QUALITIES)[number];
|
||||||
|
|
||||||
|
export const SPEED_QUALITIES = ['Excellent', 'VeryGood', 'Good', 'OK', 'Weak', 'Poor'] as const;
|
||||||
|
export type SpeedQuality = (typeof SPEED_QUALITIES)[number];
|
||||||
|
|
||||||
export const SIGNAL_THRESHOLDS = {
|
export const SIGNAL_THRESHOLDS = {
|
||||||
Excellent: -50,
|
Excellent: -50,
|
||||||
Good: -60,
|
Good: -60,
|
||||||
|
|
@ -139,6 +142,20 @@ export interface ConnectedInfo extends BaseConnectionInfo {
|
||||||
readonly channelWidth: ChannelWidthMHz | null;
|
readonly channelWidth: ChannelWidthMHz | null;
|
||||||
readonly txBitrate: BitrateMbps | null;
|
readonly txBitrate: BitrateMbps | null;
|
||||||
readonly rxBitrate: BitrateMbps | null;
|
readonly rxBitrate: BitrateMbps | null;
|
||||||
|
readonly maxBitrate: BitrateMbps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScannedNetwork {
|
||||||
|
readonly ssid: string;
|
||||||
|
readonly bssid: string;
|
||||||
|
readonly frequency: FrequencyMHz;
|
||||||
|
readonly channel: ChannelNumber;
|
||||||
|
readonly band: FrequencyBand;
|
||||||
|
readonly bandwidth: ChannelWidthMHz;
|
||||||
|
readonly maxBitrate: BitrateMbps;
|
||||||
|
readonly signalPercent: SignalPercent;
|
||||||
|
readonly security: SecurityProtocol;
|
||||||
|
readonly generation: WifiGeneration;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WifiConnectionInfo = DisconnectedInfo | ConnectedInfo;
|
export type WifiConnectionInfo = DisconnectedInfo | ConnectedInfo;
|
||||||
|
|
@ -157,6 +174,40 @@ export const asMcsIndex = (value: number): McsIndex => value as McsIndex;
|
||||||
export const asSpatialStreams = (value: number): SpatialStreams => value as SpatialStreams;
|
export const asSpatialStreams = (value: number): SpatialStreams => value as SpatialStreams;
|
||||||
export const asGuardIntervalUs = (value: number): GuardIntervalUs => value as GuardIntervalUs;
|
export const asGuardIntervalUs = (value: number): GuardIntervalUs => value as GuardIntervalUs;
|
||||||
|
|
||||||
|
const SIGNAL_PERCENT_THRESHOLDS = {
|
||||||
|
Excellent: 80,
|
||||||
|
Good: 60,
|
||||||
|
Fair: 40,
|
||||||
|
Weak: 20,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function getSignalQualityFromPercent(signalPercent: SignalPercent): SignalQuality {
|
||||||
|
const pct = signalPercent as number;
|
||||||
|
if (pct >= SIGNAL_PERCENT_THRESHOLDS.Excellent) return 'Excellent';
|
||||||
|
if (pct >= SIGNAL_PERCENT_THRESHOLDS.Good) return 'Good';
|
||||||
|
if (pct >= SIGNAL_PERCENT_THRESHOLDS.Fair) return 'Fair';
|
||||||
|
if (pct >= SIGNAL_PERCENT_THRESHOLDS.Weak) return 'Weak';
|
||||||
|
return 'Poor';
|
||||||
|
}
|
||||||
|
|
||||||
|
const SPEED_THRESHOLDS = {
|
||||||
|
Excellent: 1000,
|
||||||
|
VeryGood: 300,
|
||||||
|
Good: 100,
|
||||||
|
OK: 50,
|
||||||
|
Weak: 20,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function getSpeedQuality(bitrate: BitrateMbps): SpeedQuality {
|
||||||
|
const mbps = bitrate as number;
|
||||||
|
if (mbps >= SPEED_THRESHOLDS.Excellent) return 'Excellent';
|
||||||
|
if (mbps >= SPEED_THRESHOLDS.VeryGood) return 'VeryGood';
|
||||||
|
if (mbps >= SPEED_THRESHOLDS.Good) return 'Good';
|
||||||
|
if (mbps >= SPEED_THRESHOLDS.OK) return 'OK';
|
||||||
|
if (mbps >= SPEED_THRESHOLDS.Weak) return 'Weak';
|
||||||
|
return 'Poor';
|
||||||
|
}
|
||||||
|
|
||||||
export function createEmptyIwLinkInfo(): IwLinkInfo {
|
export function createEmptyIwLinkInfo(): IwLinkInfo {
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
generation: WIFI_GENERATIONS.UNKNOWN,
|
generation: WIFI_GENERATIONS.UNKNOWN,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import {
|
import {
|
||||||
parseIwLinkOutput,
|
parseIwLinkOutput,
|
||||||
|
parseIwScanDump,
|
||||||
createEmptyIwLinkInfo,
|
createEmptyIwLinkInfo,
|
||||||
WIFI_GENERATIONS,
|
WIFI_GENERATIONS,
|
||||||
IEEE_STANDARDS,
|
IEEE_STANDARDS,
|
||||||
|
|
@ -10,7 +11,13 @@ import {
|
||||||
getGenerationIconFilename,
|
getGenerationIconFilename,
|
||||||
isKnownGeneration,
|
isKnownGeneration,
|
||||||
} from './wifiGeneration';
|
} from './wifiGeneration';
|
||||||
import { GUARD_INTERVALS } from './types';
|
import {
|
||||||
|
GUARD_INTERVALS,
|
||||||
|
asBitrateMbps,
|
||||||
|
asSignalPercent,
|
||||||
|
getSignalQualityFromPercent,
|
||||||
|
getSpeedQuality,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
describe('createEmptyIwLinkInfo', () => {
|
describe('createEmptyIwLinkInfo', () => {
|
||||||
it('should create an object with all null values and UNKNOWN generation', () => {
|
it('should create an object with all null values and UNKNOWN generation', () => {
|
||||||
|
|
@ -425,3 +432,272 @@ describe('GENERATION_CSS_CLASSES', () => {
|
||||||
expect(classes).toContain('wifi-disconnected');
|
expect(classes).toContain('wifi-disconnected');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('parseIwScanDump', () => {
|
||||||
|
it('should return an empty map for empty input', () => {
|
||||||
|
const result = parseIwScanDump('');
|
||||||
|
expect(result.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect WiFi 6 via HE capabilities', () => {
|
||||||
|
const output = `BSS ae:8b:a9:51:30:23(on wlp192s0) -- associated
|
||||||
|
\tlast seen: 340 ms ago
|
||||||
|
\tTSF: 123456789 usec (0d, 00:00:00)
|
||||||
|
\tfreq: 5220
|
||||||
|
\tbeacon interval: 100 TUs
|
||||||
|
\tcapability: ESS Privacy ShortSlotTime (0x0411)
|
||||||
|
\tsignal: -39.00 dBm
|
||||||
|
\tSSID: MyNetwork
|
||||||
|
\tHT capabilities:
|
||||||
|
\t\tCapabilities: 0x0963
|
||||||
|
\tHT operation:
|
||||||
|
\t\t * primary channel: 44
|
||||||
|
\tVHT capabilities:
|
||||||
|
\t\tVHT Capabilities (0x338b79b2):
|
||||||
|
\tVHT operation:
|
||||||
|
\t\t * channel width: 1 (80 MHz)
|
||||||
|
\tHE capabilities:
|
||||||
|
\t\tHE MAC Capabilities (0x000801185018):
|
||||||
|
\t\tHE PHY Capabilities (0x043c2e090f):`;
|
||||||
|
|
||||||
|
const result = parseIwScanDump(output);
|
||||||
|
|
||||||
|
expect(result.size).toBe(1);
|
||||||
|
expect(result.get('ae:8b:a9:51:30:23')).toBe(WIFI_GENERATIONS.WIFI_6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect WiFi 7 via EHT capabilities', () => {
|
||||||
|
const output = `BSS 11:22:33:44:55:66(on wlan0)
|
||||||
|
\tfreq: 6115
|
||||||
|
\tsignal: -45.00 dBm
|
||||||
|
\tSSID: WiFi7Network
|
||||||
|
\tHT capabilities:
|
||||||
|
\t\tCapabilities: 0x0963
|
||||||
|
\tVHT capabilities:
|
||||||
|
\t\tVHT Capabilities (0x338b79b2):
|
||||||
|
\tHE capabilities:
|
||||||
|
\t\tHE MAC Capabilities (0x000801185018):
|
||||||
|
\tEHT capabilities:
|
||||||
|
\t\tEHT MAC Capabilities (0x0000):`;
|
||||||
|
|
||||||
|
const result = parseIwScanDump(output);
|
||||||
|
|
||||||
|
expect(result.size).toBe(1);
|
||||||
|
expect(result.get('11:22:33:44:55:66')).toBe(WIFI_GENERATIONS.WIFI_7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect WiFi 5 via VHT capabilities', () => {
|
||||||
|
const output = `BSS aa:bb:cc:dd:ee:ff(on wlan0)
|
||||||
|
\tfreq: 5180
|
||||||
|
\tsignal: -55.00 dBm
|
||||||
|
\tSSID: AcNetwork
|
||||||
|
\tHT capabilities:
|
||||||
|
\t\tCapabilities: 0x0963
|
||||||
|
\tHT operation:
|
||||||
|
\t\t * primary channel: 36
|
||||||
|
\tVHT capabilities:
|
||||||
|
\t\tVHT Capabilities (0x338b79b2):
|
||||||
|
\tVHT operation:
|
||||||
|
\t\t * channel width: 1 (80 MHz)`;
|
||||||
|
|
||||||
|
const result = parseIwScanDump(output);
|
||||||
|
|
||||||
|
expect(result.size).toBe(1);
|
||||||
|
expect(result.get('aa:bb:cc:dd:ee:ff')).toBe(WIFI_GENERATIONS.WIFI_5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect WiFi 4 via HT capabilities only', () => {
|
||||||
|
const output = `BSS 00:11:22:33:44:55(on wlan0)
|
||||||
|
\tfreq: 2437
|
||||||
|
\tsignal: -65.00 dBm
|
||||||
|
\tSSID: OldRouter
|
||||||
|
\tHT capabilities:
|
||||||
|
\t\tCapabilities: 0x0963
|
||||||
|
\tHT operation:
|
||||||
|
\t\t * primary channel: 6`;
|
||||||
|
|
||||||
|
const result = parseIwScanDump(output);
|
||||||
|
|
||||||
|
expect(result.size).toBe(1);
|
||||||
|
expect(result.get('00:11:22:33:44:55')).toBe(WIFI_GENERATIONS.WIFI_4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return UNKNOWN for BSS without any capabilities', () => {
|
||||||
|
const output = `BSS ff:ee:dd:cc:bb:aa(on wlan0)
|
||||||
|
\tfreq: 2412
|
||||||
|
\tsignal: -80.00 dBm
|
||||||
|
\tSSID: LegacyAP`;
|
||||||
|
|
||||||
|
const result = parseIwScanDump(output);
|
||||||
|
|
||||||
|
expect(result.size).toBe(1);
|
||||||
|
expect(result.get('ff:ee:dd:cc:bb:aa')).toBe(WIFI_GENERATIONS.UNKNOWN);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse multiple BSS blocks', () => {
|
||||||
|
const output = `BSS aa:bb:cc:dd:ee:01(on wlan0)
|
||||||
|
\tfreq: 5180
|
||||||
|
\tSSID: FastNetwork
|
||||||
|
\tHT capabilities:
|
||||||
|
\t\tCapabilities: 0x0963
|
||||||
|
\tVHT capabilities:
|
||||||
|
\t\tVHT Capabilities (0x338b79b2):
|
||||||
|
\tHE capabilities:
|
||||||
|
\t\tHE MAC Capabilities (0x000801185018):
|
||||||
|
BSS aa:bb:cc:dd:ee:02(on wlan0)
|
||||||
|
\tfreq: 2437
|
||||||
|
\tSSID: SlowNetwork
|
||||||
|
\tHT capabilities:
|
||||||
|
\t\tCapabilities: 0x0963
|
||||||
|
BSS aa:bb:cc:dd:ee:03(on wlan0)
|
||||||
|
\tfreq: 2412
|
||||||
|
\tSSID: LegacyNetwork`;
|
||||||
|
|
||||||
|
const result = parseIwScanDump(output);
|
||||||
|
|
||||||
|
expect(result.size).toBe(3);
|
||||||
|
expect(result.get('aa:bb:cc:dd:ee:01')).toBe(WIFI_GENERATIONS.WIFI_6);
|
||||||
|
expect(result.get('aa:bb:cc:dd:ee:02')).toBe(WIFI_GENERATIONS.WIFI_4);
|
||||||
|
expect(result.get('aa:bb:cc:dd:ee:03')).toBe(WIFI_GENERATIONS.UNKNOWN);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should normalize BSSID to lowercase', () => {
|
||||||
|
const output = `BSS AA:BB:CC:DD:EE:FF(on wlan0)
|
||||||
|
\tfreq: 5180
|
||||||
|
\tSSID: UpperCaseNetwork
|
||||||
|
\tHE capabilities:
|
||||||
|
\t\tHE MAC Capabilities (0x000801185018):`;
|
||||||
|
|
||||||
|
const result = parseIwScanDump(output);
|
||||||
|
|
||||||
|
expect(result.get('aa:bb:cc:dd:ee:ff')).toBe(WIFI_GENERATIONS.WIFI_6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pick the highest generation when multiple capabilities present', () => {
|
||||||
|
const output = `BSS aa:bb:cc:dd:ee:ff(on wlan0)
|
||||||
|
\tfreq: 5220
|
||||||
|
\tSSID: DualCapNetwork
|
||||||
|
\tHT capabilities:
|
||||||
|
\t\tCapabilities: 0x0963
|
||||||
|
\tHT operation:
|
||||||
|
\t\t * primary channel: 44
|
||||||
|
\tVHT capabilities:
|
||||||
|
\t\tVHT Capabilities (0x338b79b2):
|
||||||
|
\tHE capabilities:
|
||||||
|
\t\tHE MAC Capabilities (0x000801185018):`;
|
||||||
|
|
||||||
|
const result = parseIwScanDump(output);
|
||||||
|
|
||||||
|
expect(result.get('aa:bb:cc:dd:ee:ff')).toBe(WIFI_GENERATIONS.WIFI_6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect WiFi 5 via VHT operation without VHT capabilities', () => {
|
||||||
|
const output = `BSS aa:bb:cc:dd:ee:ff(on wlan0)
|
||||||
|
\tfreq: 5180
|
||||||
|
\tSSID: VhtOpOnly
|
||||||
|
\tHT capabilities:
|
||||||
|
\t\tCapabilities: 0x0963
|
||||||
|
\tVHT operation:
|
||||||
|
\t\t * channel width: 1 (80 MHz)`;
|
||||||
|
|
||||||
|
const result = parseIwScanDump(output);
|
||||||
|
|
||||||
|
expect(result.get('aa:bb:cc:dd:ee:ff')).toBe(WIFI_GENERATIONS.WIFI_5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect WiFi 4 via HT operation without HT capabilities', () => {
|
||||||
|
const output = `BSS aa:bb:cc:dd:ee:ff(on wlan0)
|
||||||
|
\tfreq: 2437
|
||||||
|
\tSSID: HtOpOnly
|
||||||
|
\tHT operation:
|
||||||
|
\t\t * primary channel: 6`;
|
||||||
|
|
||||||
|
const result = parseIwScanDump(output);
|
||||||
|
|
||||||
|
expect(result.get('aa:bb:cc:dd:ee:ff')).toBe(WIFI_GENERATIONS.WIFI_4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getSignalQualityFromPercent', () => {
|
||||||
|
it('should return Poor for 0%', () => {
|
||||||
|
expect(getSignalQualityFromPercent(asSignalPercent(0))).toBe('Poor');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return Poor for 19%', () => {
|
||||||
|
expect(getSignalQualityFromPercent(asSignalPercent(19))).toBe('Poor');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return Weak for 20%', () => {
|
||||||
|
expect(getSignalQualityFromPercent(asSignalPercent(20))).toBe('Weak');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return Fair for 40%', () => {
|
||||||
|
expect(getSignalQualityFromPercent(asSignalPercent(40))).toBe('Fair');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return Fair for 59%', () => {
|
||||||
|
expect(getSignalQualityFromPercent(asSignalPercent(59))).toBe('Fair');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return Good for 60%', () => {
|
||||||
|
expect(getSignalQualityFromPercent(asSignalPercent(60))).toBe('Good');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return Excellent for 80%', () => {
|
||||||
|
expect(getSignalQualityFromPercent(asSignalPercent(80))).toBe('Excellent');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return Excellent for 100%', () => {
|
||||||
|
expect(getSignalQualityFromPercent(asSignalPercent(100))).toBe('Excellent');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getSpeedQuality', () => {
|
||||||
|
it('should return Poor for 0 Mbit/s', () => {
|
||||||
|
expect(getSpeedQuality(asBitrateMbps(0))).toBe('Poor');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return Poor for 19 Mbit/s', () => {
|
||||||
|
expect(getSpeedQuality(asBitrateMbps(19))).toBe('Poor');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return Weak for 20 Mbit/s', () => {
|
||||||
|
expect(getSpeedQuality(asBitrateMbps(20))).toBe('Weak');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return Weak for 49 Mbit/s', () => {
|
||||||
|
expect(getSpeedQuality(asBitrateMbps(49))).toBe('Weak');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return OK for 50 Mbit/s', () => {
|
||||||
|
expect(getSpeedQuality(asBitrateMbps(50))).toBe('OK');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return OK for 99 Mbit/s', () => {
|
||||||
|
expect(getSpeedQuality(asBitrateMbps(99))).toBe('OK');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return Good for 100 Mbit/s', () => {
|
||||||
|
expect(getSpeedQuality(asBitrateMbps(100))).toBe('Good');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return Good for 299 Mbit/s', () => {
|
||||||
|
expect(getSpeedQuality(asBitrateMbps(299))).toBe('Good');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return VeryGood for 300 Mbit/s', () => {
|
||||||
|
expect(getSpeedQuality(asBitrateMbps(300))).toBe('VeryGood');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return VeryGood for 999 Mbit/s', () => {
|
||||||
|
expect(getSpeedQuality(asBitrateMbps(999))).toBe('VeryGood');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return Excellent for 1000 Mbit/s', () => {
|
||||||
|
expect(getSpeedQuality(asBitrateMbps(1000))).toBe('Excellent');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return Excellent for 2400 Mbit/s', () => {
|
||||||
|
expect(getSpeedQuality(asBitrateMbps(2400))).toBe('Excellent');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
243
src/wifiInfo.ts
243
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,144 @@ export class WifiInfoService {
|
||||||
return this.buildConnectedInfo(wifiDevice, activeAp, interfaceName);
|
return this.buildConnectedInfo(wifiDevice, activeAp, interfaceName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
requestScan(): void {
|
||||||
|
const device = this.findWifiDevice();
|
||||||
|
if (!device) return;
|
||||||
|
|
||||||
|
device.request_scan_async(null).catch(() => {
|
||||||
|
// Rate-limited or permission denied - use cached results
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAccessPointsForSsid(ssid: string): Promise<ScannedNetwork[]> {
|
||||||
|
if (!this.client) return [];
|
||||||
|
|
||||||
|
const wifiDevice = this.findWifiDevice();
|
||||||
|
if (!wifiDevice) return [];
|
||||||
|
|
||||||
|
const accessPoints = wifiDevice.get_access_points();
|
||||||
|
const bestByBssid = new Map<string, ScannedNetwork>();
|
||||||
|
|
||||||
|
for (const ap of accessPoints) {
|
||||||
|
const apSsid = this.decodeSsid(ap.get_ssid());
|
||||||
|
if (apSsid !== ssid) continue;
|
||||||
|
|
||||||
|
const bssid = (ap.get_bssid() ?? '').toLowerCase();
|
||||||
|
if (!bssid) continue;
|
||||||
|
|
||||||
|
const strength = ap.get_strength();
|
||||||
|
const existing = bestByBssid.get(bssid);
|
||||||
|
if (existing && (existing.signalPercent as number) >= strength) continue;
|
||||||
|
|
||||||
|
const frequency = asFrequencyMHz(ap.get_frequency());
|
||||||
|
const generation = this.generationMap.get(bssid) ?? WIFI_GENERATIONS.UNKNOWN;
|
||||||
|
|
||||||
|
bestByBssid.set(bssid, Object.freeze({
|
||||||
|
ssid: apSsid,
|
||||||
|
bssid,
|
||||||
|
frequency,
|
||||||
|
channel: frequencyToChannel(frequency),
|
||||||
|
band: frequencyToBand(frequency),
|
||||||
|
bandwidth: getApBandwidth(ap),
|
||||||
|
maxBitrate: asBitrateMbps(ap.get_max_bitrate() / 1000),
|
||||||
|
signalPercent: asSignalPercent(strength),
|
||||||
|
security: getSecurityProtocol(ap),
|
||||||
|
generation,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortBySignalStrength([...bestByBssid.values()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAvailableNetworks(excludeSsid?: string): Promise<Map<string, ScannedNetwork[]>> {
|
||||||
|
if (!this.client) return new Map();
|
||||||
|
|
||||||
|
const wifiDevice = this.findWifiDevice();
|
||||||
|
if (!wifiDevice) return new Map();
|
||||||
|
|
||||||
|
const accessPoints = wifiDevice.get_access_points();
|
||||||
|
const lastScanSec = wifiDevice.get_last_scan() / 1000;
|
||||||
|
|
||||||
|
const networks: ScannedNetwork[] = [];
|
||||||
|
|
||||||
|
for (const ap of accessPoints) {
|
||||||
|
if (isStaleAccessPoint(ap, lastScanSec)) continue;
|
||||||
|
|
||||||
|
const ssid = this.decodeSsid(ap.get_ssid());
|
||||||
|
if (!ssid) continue;
|
||||||
|
if (excludeSsid && ssid === excludeSsid) continue;
|
||||||
|
|
||||||
|
const bssid = (ap.get_bssid() ?? '').toLowerCase();
|
||||||
|
if (!bssid) continue;
|
||||||
|
|
||||||
|
const frequency = asFrequencyMHz(ap.get_frequency());
|
||||||
|
const generation = this.generationMap.get(bssid) ?? WIFI_GENERATIONS.UNKNOWN;
|
||||||
|
|
||||||
|
networks.push(Object.freeze({
|
||||||
|
ssid,
|
||||||
|
bssid,
|
||||||
|
frequency,
|
||||||
|
channel: frequencyToChannel(frequency),
|
||||||
|
band: frequencyToBand(frequency),
|
||||||
|
bandwidth: getApBandwidth(ap),
|
||||||
|
maxBitrate: asBitrateMbps(ap.get_max_bitrate() / 1000),
|
||||||
|
signalPercent: asSignalPercent(ap.get_strength()),
|
||||||
|
security: getSecurityProtocol(ap),
|
||||||
|
generation,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return groupBySSID(sortBySignalStrength(networks));
|
||||||
|
}
|
||||||
|
|
||||||
|
private findWifiDevice(): NM.DeviceWifi | null {
|
||||||
|
if (!this.client) return null;
|
||||||
|
|
||||||
|
const devices = this.client.get_devices();
|
||||||
|
|
||||||
|
for (const device of devices) {
|
||||||
|
if (device instanceof NM.DeviceWifi && device.get_state() === NM.DeviceState.ACTIVATED) {
|
||||||
|
return device;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const device of devices) {
|
||||||
|
if (device instanceof NM.DeviceWifi) {
|
||||||
|
return device;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private onScanCompleted(): void {
|
||||||
|
const device = this.findWifiDevice();
|
||||||
|
if (!device) return;
|
||||||
|
|
||||||
|
const interfaceName = device.get_iface();
|
||||||
|
if (!interfaceName) return;
|
||||||
|
|
||||||
|
this.executeIwScanDump(interfaceName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async executeIwScanDump(interfaceName: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const proc = Gio.Subprocess.new(
|
||||||
|
['iw', 'dev', interfaceName, 'scan', 'dump'],
|
||||||
|
Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE,
|
||||||
|
);
|
||||||
|
const [stdout] = await proc.communicate_utf8_async(null, null);
|
||||||
|
if (proc.get_successful() && stdout) {
|
||||||
|
const freshMap = parseIwScanDump(stdout);
|
||||||
|
if (freshMap.size > 0) {
|
||||||
|
this.generationMap = freshMap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// iw scan dump not available or insufficient permissions - graceful degradation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async buildConnectedInfo(
|
private async buildConnectedInfo(
|
||||||
device: NM.DeviceWifi,
|
device: NM.DeviceWifi,
|
||||||
ap: NM.AccessPoint,
|
ap: NM.AccessPoint,
|
||||||
|
|
@ -118,29 +297,10 @@ export class WifiInfoService {
|
||||||
channelWidth: iwInfo.channelWidth,
|
channelWidth: iwInfo.channelWidth,
|
||||||
txBitrate: iwInfo.txBitrate,
|
txBitrate: iwInfo.txBitrate,
|
||||||
rxBitrate: iwInfo.rxBitrate,
|
rxBitrate: iwInfo.rxBitrate,
|
||||||
|
maxBitrate: asBitrateMbps(ap.get_max_bitrate() / 1000),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private findActiveWifiDevice(): NM.DeviceWifi | null {
|
|
||||||
if (!this.client) return null;
|
|
||||||
|
|
||||||
const devices = this.client.get_devices();
|
|
||||||
|
|
||||||
for (const device of devices) {
|
|
||||||
if (device instanceof NM.DeviceWifi && device.get_state() === NM.DeviceState.ACTIVATED) {
|
|
||||||
return device;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const device of devices) {
|
|
||||||
if (device instanceof NM.DeviceWifi) {
|
|
||||||
return device;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async executeIwLink(interfaceName: string | null) {
|
private async executeIwLink(interfaceName: string | null) {
|
||||||
if (!interfaceName) {
|
if (!interfaceName) {
|
||||||
return createEmptyIwLinkInfo();
|
return createEmptyIwLinkInfo();
|
||||||
|
|
@ -276,3 +436,42 @@ export function formatValue<T>(value: T | null, formatter?: (v: T) => string): s
|
||||||
if (value === null) return PLACEHOLDER;
|
if (value === null) return PLACEHOLDER;
|
||||||
return formatter ? formatter(value) : String(value);
|
return formatter ? formatter(value) : String(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function sortBySignalStrength(networks: ScannedNetwork[]): ScannedNetwork[] {
|
||||||
|
return [...networks].sort(
|
||||||
|
(a, b) => (b.signalPercent as number) - (a.signalPercent as number),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupBySSID(networks: ScannedNetwork[]): Map<string, ScannedNetwork[]> {
|
||||||
|
const groups = new Map<string, ScannedNetwork[]>();
|
||||||
|
|
||||||
|
for (const network of networks) {
|
||||||
|
const existing = groups.get(network.ssid);
|
||||||
|
if (existing) {
|
||||||
|
existing.push(network);
|
||||||
|
} else {
|
||||||
|
groups.set(network.ssid, [network]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STALE_AP_TOLERANCE_SECONDS = 10;
|
||||||
|
|
||||||
|
function isStaleAccessPoint(ap: NM.AccessPoint, lastScanSec: number): boolean {
|
||||||
|
if (lastScanSec <= 0) return false;
|
||||||
|
|
||||||
|
const lastSeen = ap.get_last_seen();
|
||||||
|
if (lastSeen === -1) return true;
|
||||||
|
|
||||||
|
return lastSeen < lastScanSec - STALE_AP_TOLERANCE_SECONDS;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_BANDWIDTH_MHZ = 20;
|
||||||
|
|
||||||
|
function getApBandwidth(ap: NM.AccessPoint): ChannelWidthMHz {
|
||||||
|
const bandwidth = (ap as unknown as { get_bandwidth?: () => number }).get_bandwidth?.();
|
||||||
|
return asChannelWidthMHz(bandwidth && bandwidth > 0 ? bandwidth : DEFAULT_BANDWIDTH_MHZ);
|
||||||
|
}
|
||||||
|
|
|
||||||
102
stylesheet.css
102
stylesheet.css
|
|
@ -72,6 +72,30 @@
|
||||||
color: rgba(255, 255, 255, 0.7);
|
color: rgba(255, 255, 255, 0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Connection header */
|
||||||
|
.wifi-connection-header {
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-connection-header-ssid {
|
||||||
|
font-size: 1.2em;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-connection-header-generation {
|
||||||
|
font-size: 0.95em;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-connection-header-band {
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-connection-header-icon {
|
||||||
|
margin-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.wifi-popup-value {
|
.wifi-popup-value {
|
||||||
font-size: 1em;
|
font-size: 1em;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|
@ -118,3 +142,81 @@
|
||||||
.wifi-signal-poor {
|
.wifi-signal-poor {
|
||||||
color: #e01b24;
|
color: #e01b24;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Nearby networks - Card header */
|
||||||
|
.wifi-nearby-card {
|
||||||
|
margin: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-nearby-card-header {
|
||||||
|
spacing: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-nearby-card-icon {
|
||||||
|
icon-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-ap-gen-icon {
|
||||||
|
icon-size: 14px;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-nearby-card-ssid {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-nearby-card-count {
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nearby networks - Badges */
|
||||||
|
.wifi-nearby-badge {
|
||||||
|
font-size: 0.8em;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-nearby-badge-open {
|
||||||
|
background-color: rgba(224, 27, 36, 0.3);
|
||||||
|
color: #e01b24;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Access Points - connected AP icon */
|
||||||
|
.wifi-ap-connected-icon {
|
||||||
|
color: #33d17a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Access Points - spacer matching icon width for non-connected rows */
|
||||||
|
.wifi-ap-icon-spacer {
|
||||||
|
width: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nearby networks - AP sub-rows */
|
||||||
|
.wifi-nearby-ap {
|
||||||
|
min-height: 0;
|
||||||
|
padding: 3px 8px 3px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-nearby-ap-bssid {
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
min-width: 10em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-nearby-ap-details {
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-nearby-ap-speed {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 0.85em;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-nearby-ap-signal {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue