Compare commits
No commits in common. "896e513a17521f33cb449c732648cb0a3ed10ef9" and "b5099781eb59c397f450c70d8f99d56d06869572" have entirely different histories.
896e513a17
...
b5099781eb
8 changed files with 62 additions and 1240 deletions
|
|
@ -3,7 +3,7 @@
|
|||
"name": "WiFi Signal Plus",
|
||||
"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"],
|
||||
"version": 5,
|
||||
"version": 4,
|
||||
"url": "https://github.com/JalilArfaoui/gnome-extension-wifi-signal-plus",
|
||||
"donations": {
|
||||
"liberapay": "Jalil"
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@
|
|||
"copy-assets": "cp metadata.json stylesheet.css dist/ && cp -r src/icons dist/",
|
||||
"install-extension": "npm run build && rm -rf ~/.local/share/gnome-shell/extensions/wifi-signal-plus@jalil.arfaoui.net && cp -r dist ~/.local/share/gnome-shell/extensions/wifi-signal-plus@jalil.arfaoui.net",
|
||||
"nested": "npm run install-extension && MUTTER_DEBUG_DUMMY_MODE_SPECS=1920x1080 dbus-run-session gnome-shell --devkit --wayland",
|
||||
"nested:safe": "npm run build && rm -rf /tmp/wifi-signal-plus-test && mkdir -p /tmp/wifi-signal-plus-test/gnome-shell/extensions && cp -r dist /tmp/wifi-signal-plus-test/gnome-shell/extensions/wifi-signal-plus@jalil.arfaoui.net && XDG_DATA_HOME=/tmp/wifi-signal-plus-test MUTTER_DEBUG_DUMMY_MODE_SPECS=1920x1080 dbus-run-session -- gnome-shell --devkit --wayland",
|
||||
"reload": "npm run install-extension && gnome-extensions disable wifi-signal-plus@jalil.arfaoui.net 2>/dev/null; gnome-extensions enable wifi-signal-plus@jalil.arfaoui.net",
|
||||
"watch": "tsc --watch",
|
||||
"pack": "npm run build && cd dist && gnome-extensions pack --extra-source=icons --extra-source=wifiInfo.js --extra-source=wifiGeneration.js --extra-source=types.js --force --out-dir=..",
|
||||
|
|
|
|||
600
src/extension.ts
600
src/extension.ts
|
|
@ -11,7 +11,6 @@ import St from 'gi://St';
|
|||
import { Extension } from 'resource:///org/gnome/shell/extensions/extension.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 BoxPointer from 'resource:///org/gnome/shell/ui/boxpointer.js';
|
||||
import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js';
|
||||
|
||||
import {
|
||||
|
|
@ -20,26 +19,17 @@ import {
|
|||
isConnected,
|
||||
type WifiConnectionInfo,
|
||||
type ConnectedInfo,
|
||||
type ScannedNetwork,
|
||||
} from './wifiInfo.js';
|
||||
import {
|
||||
WIFI_GENERATIONS,
|
||||
GENERATION_CSS_CLASSES,
|
||||
getGenerationLabel,
|
||||
getGenerationDescription,
|
||||
getGenerationIconFilename,
|
||||
} from './wifiGeneration.js';
|
||||
import {
|
||||
getSignalQualityFromPercent,
|
||||
getSpeedQuality,
|
||||
type GenerationCssClass,
|
||||
type ChannelWidthMHz,
|
||||
type SignalDbm,
|
||||
type SpeedQuality,
|
||||
type WifiGeneration,
|
||||
} from './types.js';
|
||||
import type { GenerationCssClass, ChannelWidthMHz, SignalDbm } from './types.js';
|
||||
|
||||
const REFRESH_INTERVAL_SECONDS = 5;
|
||||
const BACKGROUND_SCAN_INTERVAL_SECONDS = 300;
|
||||
const PLACEHOLDER = '--' as const;
|
||||
// WiFi 7 theoretical max: 320 MHz, MCS 13 (4096-QAM 5/6), 4×4 MIMO, GI 0.8µs
|
||||
const MAX_SPEED_MBPS = 5760;
|
||||
|
|
@ -56,24 +46,10 @@ const SIGNAL_QUALITY_COLORS: Readonly<Record<string, [number, number, number]>>
|
|||
Poor: [0.88, 0.11, 0.14],
|
||||
};
|
||||
|
||||
const SIGNAL_QUALITY_BAR_COLORS: Readonly<Record<string, string>> = {
|
||||
Excellent: '#33d17a',
|
||||
Good: '#8ff0a4',
|
||||
Fair: '#f6d32d',
|
||||
Weak: '#ff7800',
|
||||
Poor: '#e01b24',
|
||||
};
|
||||
|
||||
const SPEED_QUALITY_COLORS: Readonly<Record<SpeedQuality, string>> = {
|
||||
Excellent: '#c061cb',
|
||||
VeryGood: '#62a0ea',
|
||||
Good: '#33d17a',
|
||||
OK: '#f6d32d',
|
||||
Weak: '#ff7800',
|
||||
Poor: '#e01b24',
|
||||
};
|
||||
|
||||
type MenuItemId =
|
||||
| 'ssid'
|
||||
| 'generation'
|
||||
| 'band'
|
||||
| 'bitrate'
|
||||
| 'channelWidth'
|
||||
| 'mcs'
|
||||
|
|
@ -87,6 +63,12 @@ interface MenuItemConfig {
|
|||
}
|
||||
|
||||
const MENU_STRUCTURE: readonly MenuItemConfig[][] = [
|
||||
// Section: Connection
|
||||
[
|
||||
{ id: 'ssid', label: 'Network' },
|
||||
{ id: 'generation', label: 'Generation' },
|
||||
{ id: 'band', label: 'Band' },
|
||||
],
|
||||
// Section: Performance
|
||||
[
|
||||
{ id: 'bitrate', label: 'Speed' },
|
||||
|
|
@ -103,57 +85,28 @@ const MENU_STRUCTURE: readonly MenuItemConfig[][] = [
|
|||
|
||||
const ITEMS_WITH_BAR: ReadonlySet<MenuItemId> = new Set(['bitrate', 'channelWidth']);
|
||||
|
||||
interface NearbyNetworkCard extends PopupMenu.PopupSubMenuMenuItem {
|
||||
_ssid: string;
|
||||
}
|
||||
|
||||
export default class WifiSignalPlusExtension extends Extension {
|
||||
private indicator: PanelMenu.Button | null = null;
|
||||
private icon: St.Icon | null = null;
|
||||
private label: St.Label | null = null;
|
||||
private wifiService: WifiInfoService | null = null;
|
||||
private refreshTimeout: number | null = null;
|
||||
private backgroundScanTimeout: number | null = null;
|
||||
private signalGraph: St.DrawingArea | null = null;
|
||||
private readonly signalHistory: number[] = [];
|
||||
private readonly menuItems = new Map<
|
||||
MenuItemId,
|
||||
{ item: PopupMenu.PopupBaseMenuItem; label: St.Label; value: St.Label; barFill?: St.Widget }
|
||||
>();
|
||||
private 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 {
|
||||
const epoch = ++this.enableEpoch;
|
||||
this.wifiService = new WifiInfoService();
|
||||
this.wifiService
|
||||
.init()
|
||||
.then(() => {
|
||||
if (epoch !== this.enableEpoch) return;
|
||||
if (!this.wifiService) return;
|
||||
this.wifiService.requestScan();
|
||||
this.wifiService.watchDeviceSignals(() => {
|
||||
this.wifiService?.requestScan();
|
||||
this.scheduleRefresh();
|
||||
});
|
||||
this.createIndicator();
|
||||
this.scheduleRefresh();
|
||||
this.refresh();
|
||||
this.startRefreshTimer();
|
||||
this.startBackgroundScanTimer();
|
||||
})
|
||||
.catch(e => {
|
||||
console.error('[WiFi Signal Plus] Failed to initialize:', e);
|
||||
|
|
@ -161,11 +114,7 @@ export default class WifiSignalPlusExtension extends Extension {
|
|||
}
|
||||
|
||||
disable(): void {
|
||||
this.stopBackgroundScanTimer();
|
||||
this.stopRefreshTimer();
|
||||
this.wifiService?.unwatchDeviceSignals();
|
||||
this.clearAccessPointsItems();
|
||||
this.clearNearbyItems();
|
||||
this.indicator?.destroy();
|
||||
this.wifiService?.destroy();
|
||||
|
||||
|
|
@ -176,22 +125,6 @@ export default class WifiSignalPlusExtension extends Extension {
|
|||
this.signalGraph = null;
|
||||
this.signalHistory.length = 0;
|
||||
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 {
|
||||
|
|
@ -224,24 +157,18 @@ export default class WifiSignalPlusExtension extends Extension {
|
|||
const menu = this.indicator.menu as PopupMenu.PopupMenu;
|
||||
menu.box.add_style_class_name('wifi-signal-plus-popup');
|
||||
menu.connect('open-state-changed', (_menu, isOpen: boolean) => {
|
||||
this.isMenuOpen = isOpen;
|
||||
if (isOpen) {
|
||||
this.stopBackgroundScanTimer();
|
||||
this.scheduleRefresh();
|
||||
} else {
|
||||
this.startBackgroundScanTimer();
|
||||
}
|
||||
if (isOpen) this.refresh();
|
||||
return undefined;
|
||||
});
|
||||
|
||||
this.addConnectionHeader(menu);
|
||||
const sectionHeaders = ['', 'Performance', 'Signal'];
|
||||
|
||||
const sectionHeaders = ['Performance', 'Signal'];
|
||||
|
||||
const SIGNAL_SECTION_INDEX = 1;
|
||||
const SIGNAL_SECTION_INDEX = 2;
|
||||
|
||||
MENU_STRUCTURE.forEach((section, index) => {
|
||||
menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem(sectionHeaders[index]));
|
||||
if (sectionHeaders[index]) {
|
||||
menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem(sectionHeaders[index]));
|
||||
}
|
||||
|
||||
if (index === SIGNAL_SECTION_INDEX) {
|
||||
this.addSignalGraph(menu);
|
||||
|
|
@ -251,60 +178,6 @@ export default class WifiSignalPlusExtension extends Extension {
|
|||
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(
|
||||
|
|
@ -443,63 +316,27 @@ export default class WifiSignalPlusExtension extends Extension {
|
|||
}
|
||||
}
|
||||
|
||||
private updateHeaderIcon(generation: WifiGeneration): void {
|
||||
if (!this.headerIcon) return;
|
||||
|
||||
const iconFilename = getGenerationIconFilename(generation);
|
||||
if (!iconFilename) {
|
||||
this.headerIcon.visible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const iconPath = GLib.build_filenamev([this.path, 'icons', iconFilename]);
|
||||
const file = Gio.File.new_for_path(iconPath);
|
||||
this.headerIcon.gicon = new Gio.FileIcon({ file });
|
||||
this.headerIcon.visible = true;
|
||||
}
|
||||
|
||||
private refreshPending = false;
|
||||
|
||||
private scheduleRefresh(): void {
|
||||
this.refresh().catch(e => {
|
||||
console.error('[WiFi Signal Plus] Refresh failed:', e);
|
||||
});
|
||||
}
|
||||
|
||||
private async refresh(): Promise<void> {
|
||||
if (!this.wifiService || !this.label || this.refreshPending) return;
|
||||
if (!this.wifiService || !this.label) 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;
|
||||
}
|
||||
const info = await this.wifiService.getConnectionInfo();
|
||||
this.updateIndicatorLabel(info);
|
||||
this.updateMenuContent(info);
|
||||
}
|
||||
|
||||
private updateIndicatorLabel(info: WifiConnectionInfo): void {
|
||||
if (!this.indicator || !this.icon || !this.label) return;
|
||||
if (!this.icon || !this.label) return;
|
||||
|
||||
this.clearGenerationStyles();
|
||||
|
||||
if (!isConnected(info)) {
|
||||
this.indicator.visible = false;
|
||||
this.icon.visible = false;
|
||||
this.label.visible = true;
|
||||
this.label.set_text('WiFi --');
|
||||
this.label.add_style_class_name(GENERATION_CSS_CLASSES[WIFI_GENERATIONS.UNKNOWN]);
|
||||
return;
|
||||
}
|
||||
|
||||
this.indicator.visible = true;
|
||||
|
||||
const iconFilename = getGenerationIconFilename(info.generation);
|
||||
if (iconFilename) {
|
||||
const iconPath = GLib.build_filenamev([this.path, 'icons', iconFilename]);
|
||||
|
|
@ -535,31 +372,21 @@ export default class WifiSignalPlusExtension extends Extension {
|
|||
}
|
||||
|
||||
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 { id } of section) {
|
||||
this.updateMenuItem(id, PLACEHOLDER, 0);
|
||||
const value = id === 'ssid' ? 'Not connected' : PLACEHOLDER;
|
||||
this.updateMenuItem(id, value, 0);
|
||||
}
|
||||
}
|
||||
|
||||
this.clearAccessPointsItems();
|
||||
this.setAccessPointsVisible(false);
|
||||
|
||||
this.signalHistory.length = 0;
|
||||
this.signalGraph?.queue_repaint();
|
||||
}
|
||||
|
||||
private showConnectedState(info: ConnectedInfo): void {
|
||||
this.headerSsidLabel?.set_text(info.ssid);
|
||||
this.headerGenerationLabel?.set_text(getGenerationDescription(info.generation));
|
||||
this.headerBandLabel?.set_text(this.formatBand(info));
|
||||
this.updateHeaderIcon(info.generation);
|
||||
this.updateMenuItem('ssid', info.ssid);
|
||||
this.updateMenuItem('generation', getGenerationDescription(info.generation));
|
||||
this.updateMenuItem('band', this.formatBand(info));
|
||||
this.updateMenuItem(
|
||||
'bitrate',
|
||||
this.formatBitrate(info),
|
||||
|
|
@ -601,27 +428,17 @@ export default class WifiSignalPlusExtension extends Extension {
|
|||
}
|
||||
|
||||
private formatBitrate(info: ConnectedInfo): string {
|
||||
const { txBitrate, rxBitrate, bitrate, maxBitrate } = info;
|
||||
const { txBitrate, rxBitrate, bitrate } = info;
|
||||
|
||||
let speed: string;
|
||||
if (txBitrate !== null && rxBitrate !== null) {
|
||||
const tx = txBitrate as number;
|
||||
const rx = rxBitrate as number;
|
||||
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`;
|
||||
return tx === rx ? `${tx} Mbit/s` : `↑${tx} ↓${rx} Mbit/s`;
|
||||
}
|
||||
|
||||
const max = maxBitrate as number;
|
||||
if (max > 0) {
|
||||
speed += ` (max ${max})`;
|
||||
}
|
||||
|
||||
return speed;
|
||||
if (txBitrate !== null) return `↑${txBitrate} Mbit/s`;
|
||||
if (rxBitrate !== null) return `↓${rxBitrate} Mbit/s`;
|
||||
return `${bitrate} Mbit/s`;
|
||||
}
|
||||
|
||||
private formatChannelWidth(width: ChannelWidthMHz | null): string {
|
||||
|
|
@ -649,335 +466,13 @@ export default class WifiSignalPlusExtension extends Extension {
|
|||
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 {
|
||||
this.stopRefreshTimer();
|
||||
this.refreshTimeout = GLib.timeout_add_seconds(
|
||||
GLib.PRIORITY_DEFAULT,
|
||||
REFRESH_INTERVAL_SECONDS,
|
||||
() => {
|
||||
this.scheduleRefresh();
|
||||
this.refresh();
|
||||
return GLib.SOURCE_CONTINUE;
|
||||
}
|
||||
);
|
||||
|
|
@ -989,23 +484,4 @@ export default class WifiSignalPlusExtension extends Extension {
|
|||
this.refreshTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
private startBackgroundScanTimer(): void {
|
||||
this.stopBackgroundScanTimer();
|
||||
this.backgroundScanTimeout = GLib.timeout_add_seconds(
|
||||
GLib.PRIORITY_DEFAULT,
|
||||
BACKGROUND_SCAN_INTERVAL_SECONDS,
|
||||
() => {
|
||||
this.wifiService?.requestScan();
|
||||
return GLib.SOURCE_CONTINUE;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private stopBackgroundScanTimer(): void {
|
||||
if (this.backgroundScanTimeout !== null) {
|
||||
GLib.source_remove(this.backgroundScanTimeout);
|
||||
this.backgroundScanTimeout = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
51
src/types.ts
51
src/types.ts
|
|
@ -51,9 +51,6 @@ export type FrequencyBand = (typeof FREQUENCY_BANDS)[number];
|
|||
export const SIGNAL_QUALITIES = ['Excellent', 'Good', 'Fair', 'Weak', 'Poor', 'Unknown'] as const;
|
||||
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 = {
|
||||
Excellent: -50,
|
||||
Good: -60,
|
||||
|
|
@ -142,20 +139,6 @@ export interface ConnectedInfo extends BaseConnectionInfo {
|
|||
readonly channelWidth: ChannelWidthMHz | null;
|
||||
readonly txBitrate: 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;
|
||||
|
|
@ -174,40 +157,6 @@ export const asMcsIndex = (value: number): McsIndex => value as McsIndex;
|
|||
export const asSpatialStreams = (value: number): SpatialStreams => value as SpatialStreams;
|
||||
export const asGuardIntervalUs = (value: number): GuardIntervalUs => value as GuardIntervalUs;
|
||||
|
||||
const SIGNAL_PERCENT_THRESHOLDS = {
|
||||
Excellent: 80,
|
||||
Good: 60,
|
||||
Fair: 40,
|
||||
Weak: 20,
|
||||
} as const;
|
||||
|
||||
export function getSignalQualityFromPercent(signalPercent: SignalPercent): SignalQuality {
|
||||
const pct = signalPercent as number;
|
||||
if (pct >= SIGNAL_PERCENT_THRESHOLDS.Excellent) return 'Excellent';
|
||||
if (pct >= SIGNAL_PERCENT_THRESHOLDS.Good) return 'Good';
|
||||
if (pct >= SIGNAL_PERCENT_THRESHOLDS.Fair) return 'Fair';
|
||||
if (pct >= SIGNAL_PERCENT_THRESHOLDS.Weak) return 'Weak';
|
||||
return 'Poor';
|
||||
}
|
||||
|
||||
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 {
|
||||
return Object.freeze({
|
||||
generation: WIFI_GENERATIONS.UNKNOWN,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
parseIwLinkOutput,
|
||||
parseIwScanDump,
|
||||
createEmptyIwLinkInfo,
|
||||
WIFI_GENERATIONS,
|
||||
IEEE_STANDARDS,
|
||||
|
|
@ -11,13 +10,7 @@ import {
|
|||
getGenerationIconFilename,
|
||||
isKnownGeneration,
|
||||
} from './wifiGeneration';
|
||||
import {
|
||||
GUARD_INTERVALS,
|
||||
asBitrateMbps,
|
||||
asSignalPercent,
|
||||
getSignalQualityFromPercent,
|
||||
getSpeedQuality,
|
||||
} from './types';
|
||||
import { GUARD_INTERVALS } from './types';
|
||||
|
||||
describe('createEmptyIwLinkInfo', () => {
|
||||
it('should create an object with all null values and UNKNOWN generation', () => {
|
||||
|
|
@ -432,272 +425,3 @@ describe('GENERATION_CSS_CLASSES', () => {
|
|||
expect(classes).toContain('wifi-disconnected');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseIwScanDump', () => {
|
||||
it('should return an empty map for empty input', () => {
|
||||
const result = parseIwScanDump('');
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should detect WiFi 6 via HE capabilities', () => {
|
||||
const output = `BSS ae:8b:a9:51:30:23(on wlp192s0) -- associated
|
||||
\tlast seen: 340 ms ago
|
||||
\tTSF: 123456789 usec (0d, 00:00:00)
|
||||
\tfreq: 5220
|
||||
\tbeacon interval: 100 TUs
|
||||
\tcapability: ESS Privacy ShortSlotTime (0x0411)
|
||||
\tsignal: -39.00 dBm
|
||||
\tSSID: MyNetwork
|
||||
\tHT capabilities:
|
||||
\t\tCapabilities: 0x0963
|
||||
\tHT operation:
|
||||
\t\t * primary channel: 44
|
||||
\tVHT capabilities:
|
||||
\t\tVHT Capabilities (0x338b79b2):
|
||||
\tVHT operation:
|
||||
\t\t * channel width: 1 (80 MHz)
|
||||
\tHE capabilities:
|
||||
\t\tHE MAC Capabilities (0x000801185018):
|
||||
\t\tHE PHY Capabilities (0x043c2e090f):`;
|
||||
|
||||
const result = parseIwScanDump(output);
|
||||
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.get('ae:8b:a9:51:30:23')).toBe(WIFI_GENERATIONS.WIFI_6);
|
||||
});
|
||||
|
||||
it('should detect WiFi 7 via EHT capabilities', () => {
|
||||
const output = `BSS 11:22:33:44:55:66(on wlan0)
|
||||
\tfreq: 6115
|
||||
\tsignal: -45.00 dBm
|
||||
\tSSID: WiFi7Network
|
||||
\tHT capabilities:
|
||||
\t\tCapabilities: 0x0963
|
||||
\tVHT capabilities:
|
||||
\t\tVHT Capabilities (0x338b79b2):
|
||||
\tHE capabilities:
|
||||
\t\tHE MAC Capabilities (0x000801185018):
|
||||
\tEHT capabilities:
|
||||
\t\tEHT MAC Capabilities (0x0000):`;
|
||||
|
||||
const result = parseIwScanDump(output);
|
||||
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.get('11:22:33:44:55:66')).toBe(WIFI_GENERATIONS.WIFI_7);
|
||||
});
|
||||
|
||||
it('should detect WiFi 5 via VHT capabilities', () => {
|
||||
const output = `BSS aa:bb:cc:dd:ee:ff(on wlan0)
|
||||
\tfreq: 5180
|
||||
\tsignal: -55.00 dBm
|
||||
\tSSID: AcNetwork
|
||||
\tHT capabilities:
|
||||
\t\tCapabilities: 0x0963
|
||||
\tHT operation:
|
||||
\t\t * primary channel: 36
|
||||
\tVHT capabilities:
|
||||
\t\tVHT Capabilities (0x338b79b2):
|
||||
\tVHT operation:
|
||||
\t\t * channel width: 1 (80 MHz)`;
|
||||
|
||||
const result = parseIwScanDump(output);
|
||||
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.get('aa:bb:cc:dd:ee:ff')).toBe(WIFI_GENERATIONS.WIFI_5);
|
||||
});
|
||||
|
||||
it('should detect WiFi 4 via HT capabilities only', () => {
|
||||
const output = `BSS 00:11:22:33:44:55(on wlan0)
|
||||
\tfreq: 2437
|
||||
\tsignal: -65.00 dBm
|
||||
\tSSID: OldRouter
|
||||
\tHT capabilities:
|
||||
\t\tCapabilities: 0x0963
|
||||
\tHT operation:
|
||||
\t\t * primary channel: 6`;
|
||||
|
||||
const result = parseIwScanDump(output);
|
||||
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.get('00:11:22:33:44:55')).toBe(WIFI_GENERATIONS.WIFI_4);
|
||||
});
|
||||
|
||||
it('should return UNKNOWN for BSS without any capabilities', () => {
|
||||
const output = `BSS ff:ee:dd:cc:bb:aa(on wlan0)
|
||||
\tfreq: 2412
|
||||
\tsignal: -80.00 dBm
|
||||
\tSSID: LegacyAP`;
|
||||
|
||||
const result = parseIwScanDump(output);
|
||||
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.get('ff:ee:dd:cc:bb:aa')).toBe(WIFI_GENERATIONS.UNKNOWN);
|
||||
});
|
||||
|
||||
it('should parse multiple BSS blocks', () => {
|
||||
const output = `BSS aa:bb:cc:dd:ee:01(on wlan0)
|
||||
\tfreq: 5180
|
||||
\tSSID: FastNetwork
|
||||
\tHT capabilities:
|
||||
\t\tCapabilities: 0x0963
|
||||
\tVHT capabilities:
|
||||
\t\tVHT Capabilities (0x338b79b2):
|
||||
\tHE capabilities:
|
||||
\t\tHE MAC Capabilities (0x000801185018):
|
||||
BSS aa:bb:cc:dd:ee:02(on wlan0)
|
||||
\tfreq: 2437
|
||||
\tSSID: SlowNetwork
|
||||
\tHT capabilities:
|
||||
\t\tCapabilities: 0x0963
|
||||
BSS aa:bb:cc:dd:ee:03(on wlan0)
|
||||
\tfreq: 2412
|
||||
\tSSID: LegacyNetwork`;
|
||||
|
||||
const result = parseIwScanDump(output);
|
||||
|
||||
expect(result.size).toBe(3);
|
||||
expect(result.get('aa:bb:cc:dd:ee:01')).toBe(WIFI_GENERATIONS.WIFI_6);
|
||||
expect(result.get('aa:bb:cc:dd:ee:02')).toBe(WIFI_GENERATIONS.WIFI_4);
|
||||
expect(result.get('aa:bb:cc:dd:ee:03')).toBe(WIFI_GENERATIONS.UNKNOWN);
|
||||
});
|
||||
|
||||
it('should normalize BSSID to lowercase', () => {
|
||||
const output = `BSS AA:BB:CC:DD:EE:FF(on wlan0)
|
||||
\tfreq: 5180
|
||||
\tSSID: UpperCaseNetwork
|
||||
\tHE capabilities:
|
||||
\t\tHE MAC Capabilities (0x000801185018):`;
|
||||
|
||||
const result = parseIwScanDump(output);
|
||||
|
||||
expect(result.get('aa:bb:cc:dd:ee:ff')).toBe(WIFI_GENERATIONS.WIFI_6);
|
||||
});
|
||||
|
||||
it('should pick the highest generation when multiple capabilities present', () => {
|
||||
const output = `BSS aa:bb:cc:dd:ee:ff(on wlan0)
|
||||
\tfreq: 5220
|
||||
\tSSID: DualCapNetwork
|
||||
\tHT capabilities:
|
||||
\t\tCapabilities: 0x0963
|
||||
\tHT operation:
|
||||
\t\t * primary channel: 44
|
||||
\tVHT capabilities:
|
||||
\t\tVHT Capabilities (0x338b79b2):
|
||||
\tHE capabilities:
|
||||
\t\tHE MAC Capabilities (0x000801185018):`;
|
||||
|
||||
const result = parseIwScanDump(output);
|
||||
|
||||
expect(result.get('aa:bb:cc:dd:ee:ff')).toBe(WIFI_GENERATIONS.WIFI_6);
|
||||
});
|
||||
|
||||
it('should detect WiFi 5 via VHT operation without VHT capabilities', () => {
|
||||
const output = `BSS aa:bb:cc:dd:ee:ff(on wlan0)
|
||||
\tfreq: 5180
|
||||
\tSSID: VhtOpOnly
|
||||
\tHT capabilities:
|
||||
\t\tCapabilities: 0x0963
|
||||
\tVHT operation:
|
||||
\t\t * channel width: 1 (80 MHz)`;
|
||||
|
||||
const result = parseIwScanDump(output);
|
||||
|
||||
expect(result.get('aa:bb:cc:dd:ee:ff')).toBe(WIFI_GENERATIONS.WIFI_5);
|
||||
});
|
||||
|
||||
it('should detect WiFi 4 via HT operation without HT capabilities', () => {
|
||||
const output = `BSS aa:bb:cc:dd:ee:ff(on wlan0)
|
||||
\tfreq: 2437
|
||||
\tSSID: HtOpOnly
|
||||
\tHT operation:
|
||||
\t\t * primary channel: 6`;
|
||||
|
||||
const result = parseIwScanDump(output);
|
||||
|
||||
expect(result.get('aa:bb:cc:dd:ee:ff')).toBe(WIFI_GENERATIONS.WIFI_4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSignalQualityFromPercent', () => {
|
||||
it('should return Poor for 0%', () => {
|
||||
expect(getSignalQualityFromPercent(asSignalPercent(0))).toBe('Poor');
|
||||
});
|
||||
|
||||
it('should return Poor for 19%', () => {
|
||||
expect(getSignalQualityFromPercent(asSignalPercent(19))).toBe('Poor');
|
||||
});
|
||||
|
||||
it('should return Weak for 20%', () => {
|
||||
expect(getSignalQualityFromPercent(asSignalPercent(20))).toBe('Weak');
|
||||
});
|
||||
|
||||
it('should return Fair for 40%', () => {
|
||||
expect(getSignalQualityFromPercent(asSignalPercent(40))).toBe('Fair');
|
||||
});
|
||||
|
||||
it('should return Fair for 59%', () => {
|
||||
expect(getSignalQualityFromPercent(asSignalPercent(59))).toBe('Fair');
|
||||
});
|
||||
|
||||
it('should return Good for 60%', () => {
|
||||
expect(getSignalQualityFromPercent(asSignalPercent(60))).toBe('Good');
|
||||
});
|
||||
|
||||
it('should return Excellent for 80%', () => {
|
||||
expect(getSignalQualityFromPercent(asSignalPercent(80))).toBe('Excellent');
|
||||
});
|
||||
|
||||
it('should return Excellent for 100%', () => {
|
||||
expect(getSignalQualityFromPercent(asSignalPercent(100))).toBe('Excellent');
|
||||
});
|
||||
});
|
||||
|
||||
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,31 +300,6 @@ function parseHeGuardInterval(line: string, prefix: string): GuardIntervalUs {
|
|||
return HE_GI_INDEX_MAP[giIndex] ?? GUARD_INTERVALS.NORMAL;
|
||||
}
|
||||
|
||||
export function parseIwScanDump(output: string): Map<string, WifiGeneration> {
|
||||
const result = new Map<string, WifiGeneration>();
|
||||
if (!output) return result;
|
||||
|
||||
const bssBlocks = output.split(/^BSS /m);
|
||||
|
||||
for (const block of bssBlocks) {
|
||||
const bssidMatch = block.match(/^([0-9a-f:]{17})/i);
|
||||
if (!bssidMatch) continue;
|
||||
|
||||
const bssid = bssidMatch[1].toLowerCase();
|
||||
result.set(bssid, detectScanGeneration(block));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function detectScanGeneration(block: string): WifiGeneration {
|
||||
if (block.includes('EHT capabilities')) return WIFI_GENERATIONS.WIFI_7;
|
||||
if (block.includes('HE capabilities')) return WIFI_GENERATIONS.WIFI_6;
|
||||
if (block.includes('VHT capabilities') || block.includes('VHT operation')) return WIFI_GENERATIONS.WIFI_5;
|
||||
if (block.includes('HT capabilities') || block.includes('HT operation')) return WIFI_GENERATIONS.WIFI_4;
|
||||
return WIFI_GENERATIONS.UNKNOWN;
|
||||
}
|
||||
|
||||
export function getGenerationLabel(generation: WifiGeneration): string {
|
||||
return isKnownGeneration(generation) ? `WiFi ${generation}` : 'WiFi';
|
||||
}
|
||||
|
|
|
|||
243
src/wifiInfo.ts
243
src/wifiInfo.ts
|
|
@ -6,15 +6,13 @@
|
|||
|
||||
import Gio from 'gi://Gio';
|
||||
import GLib from 'gi://GLib';
|
||||
import GObject from 'gi://GObject';
|
||||
import NM from 'gi://NM';
|
||||
|
||||
import { parseIwLinkOutput, parseIwScanDump, createEmptyIwLinkInfo, WIFI_GENERATIONS } from './wifiGeneration.js';
|
||||
import { parseIwLinkOutput, createEmptyIwLinkInfo } from './wifiGeneration.js';
|
||||
import {
|
||||
type WifiConnectionInfo,
|
||||
type ConnectedInfo,
|
||||
type DisconnectedInfo,
|
||||
type ScannedNetwork,
|
||||
type FrequencyMHz,
|
||||
type FrequencyBand,
|
||||
type ChannelNumber,
|
||||
|
|
@ -22,8 +20,6 @@ import {
|
|||
type SignalQuality,
|
||||
type SecurityProtocol,
|
||||
type SignalCssClass,
|
||||
type ChannelWidthMHz,
|
||||
type WifiGeneration,
|
||||
SIGNAL_THRESHOLDS,
|
||||
createDisconnectedInfo,
|
||||
isConnected,
|
||||
|
|
@ -32,29 +28,23 @@ import {
|
|||
asSignalPercent,
|
||||
asBitrateMbps,
|
||||
asChannelNumber,
|
||||
asChannelWidthMHz,
|
||||
} from './types.js';
|
||||
|
||||
export {
|
||||
type WifiConnectionInfo,
|
||||
type ConnectedInfo,
|
||||
type DisconnectedInfo,
|
||||
type ScannedNetwork,
|
||||
type SignalQuality,
|
||||
isConnected,
|
||||
};
|
||||
|
||||
Gio._promisify(Gio.Subprocess.prototype, 'communicate_utf8_async', 'communicate_utf8_finish');
|
||||
Gio._promisify(NM.DeviceWifi.prototype, 'request_scan_async', 'request_scan_finish');
|
||||
|
||||
const PLACEHOLDER = '--' as const;
|
||||
|
||||
export class WifiInfoService {
|
||||
private client: NM.Client | null = null;
|
||||
private initPromise: Promise<void> | null = null;
|
||||
private watchedDevice: NM.DeviceWifi | null = null;
|
||||
private deviceSignalIds: number[] = [];
|
||||
private generationMap = new Map<string, WifiGeneration>();
|
||||
|
||||
async init(): Promise<void> {
|
||||
if (this.client) return;
|
||||
|
|
@ -75,34 +65,8 @@ export class WifiInfoService {
|
|||
}
|
||||
|
||||
destroy(): void {
|
||||
this.unwatchDeviceSignals();
|
||||
this.client = null;
|
||||
this.initPromise = null;
|
||||
this.generationMap.clear();
|
||||
}
|
||||
|
||||
watchDeviceSignals(callback: () => void): void {
|
||||
this.unwatchDeviceSignals();
|
||||
|
||||
const device = this.findWifiDevice();
|
||||
if (!device) return;
|
||||
|
||||
this.watchedDevice = device;
|
||||
this.deviceSignalIds = [
|
||||
device.connect('state-changed', () => callback()),
|
||||
device.connect('notify::active-access-point', () => callback()),
|
||||
device.connect('notify::last-scan', () => this.onScanCompleted()),
|
||||
];
|
||||
}
|
||||
|
||||
unwatchDeviceSignals(): void {
|
||||
if (this.watchedDevice) {
|
||||
for (const id of this.deviceSignalIds) {
|
||||
GObject.signal_handler_disconnect(this.watchedDevice, id);
|
||||
}
|
||||
}
|
||||
this.watchedDevice = null;
|
||||
this.deviceSignalIds = [];
|
||||
}
|
||||
|
||||
async getConnectionInfo(): Promise<WifiConnectionInfo> {
|
||||
|
|
@ -110,17 +74,12 @@ export class WifiInfoService {
|
|||
return createDisconnectedInfo();
|
||||
}
|
||||
|
||||
const wifiDevice = this.findWifiDevice();
|
||||
const wifiDevice = this.findActiveWifiDevice();
|
||||
if (!wifiDevice) {
|
||||
return createDisconnectedInfo();
|
||||
}
|
||||
|
||||
const interfaceName = wifiDevice.get_iface();
|
||||
|
||||
if (wifiDevice.get_state() !== NM.DeviceState.ACTIVATED) {
|
||||
return createDisconnectedInfo(interfaceName);
|
||||
}
|
||||
|
||||
const activeAp = wifiDevice.get_active_access_point();
|
||||
|
||||
if (!activeAp) {
|
||||
|
|
@ -130,144 +89,6 @@ export class WifiInfoService {
|
|||
return this.buildConnectedInfo(wifiDevice, activeAp, interfaceName);
|
||||
}
|
||||
|
||||
requestScan(): void {
|
||||
const device = this.findWifiDevice();
|
||||
if (!device) return;
|
||||
|
||||
device.request_scan_async(null).catch(() => {
|
||||
// Rate-limited or permission denied - use cached results
|
||||
});
|
||||
}
|
||||
|
||||
async 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(
|
||||
device: NM.DeviceWifi,
|
||||
ap: NM.AccessPoint,
|
||||
|
|
@ -297,10 +118,29 @@ export class WifiInfoService {
|
|||
channelWidth: iwInfo.channelWidth,
|
||||
txBitrate: iwInfo.txBitrate,
|
||||
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) {
|
||||
if (!interfaceName) {
|
||||
return createEmptyIwLinkInfo();
|
||||
|
|
@ -436,42 +276,3 @@ export function formatValue<T>(value: T | null, formatter?: (v: T) => string): s
|
|||
if (value === null) return PLACEHOLDER;
|
||||
return formatter ? formatter(value) : String(value);
|
||||
}
|
||||
|
||||
export function sortBySignalStrength(networks: ScannedNetwork[]): ScannedNetwork[] {
|
||||
return [...networks].sort(
|
||||
(a, b) => (b.signalPercent as number) - (a.signalPercent as number),
|
||||
);
|
||||
}
|
||||
|
||||
export function groupBySSID(networks: ScannedNetwork[]): Map<string, ScannedNetwork[]> {
|
||||
const groups = new Map<string, ScannedNetwork[]>();
|
||||
|
||||
for (const network of networks) {
|
||||
const existing = groups.get(network.ssid);
|
||||
if (existing) {
|
||||
existing.push(network);
|
||||
} else {
|
||||
groups.set(network.ssid, [network]);
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
const STALE_AP_TOLERANCE_SECONDS = 10;
|
||||
|
||||
function isStaleAccessPoint(ap: NM.AccessPoint, lastScanSec: number): boolean {
|
||||
if (lastScanSec <= 0) return false;
|
||||
|
||||
const lastSeen = ap.get_last_seen();
|
||||
if (lastSeen === -1) return true;
|
||||
|
||||
return lastSeen < lastScanSec - STALE_AP_TOLERANCE_SECONDS;
|
||||
}
|
||||
|
||||
const DEFAULT_BANDWIDTH_MHZ = 20;
|
||||
|
||||
function getApBandwidth(ap: NM.AccessPoint): ChannelWidthMHz {
|
||||
const bandwidth = (ap as unknown as { get_bandwidth?: () => number }).get_bandwidth?.();
|
||||
return asChannelWidthMHz(bandwidth && bandwidth > 0 ? bandwidth : DEFAULT_BANDWIDTH_MHZ);
|
||||
}
|
||||
|
|
|
|||
102
stylesheet.css
102
stylesheet.css
|
|
@ -72,30 +72,6 @@
|
|||
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 {
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
|
|
@ -142,81 +118,3 @@
|
|||
.wifi-signal-poor {
|
||||
color: #e01b24;
|
||||
}
|
||||
|
||||
/* Nearby networks - Card header */
|
||||
.wifi-nearby-card {
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.wifi-nearby-card-header {
|
||||
spacing: 8px;
|
||||
}
|
||||
|
||||
.wifi-nearby-card-icon {
|
||||
icon-size: 16px;
|
||||
}
|
||||
|
||||
.wifi-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