diff --git a/package.json b/package.json index 86ef91b..3cac7bd 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "build": "tsc && npm run copy-assets", - "copy-assets": "cp metadata.json stylesheet.css 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@music-music.music && cp -r dist ~/.local/share/gnome-shell/extensions/wifi-signal-plus@music-music.music", "nested": "npm run install-extension && MUTTER_DEBUG_DUMMY_MODE_SPECS=1920x1080 dbus-run-session gnome-shell --devkit --wayland", "watch": "tsc --watch", diff --git a/src/extension.ts b/src/extension.ts index e7cb481..f4cc6c1 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -5,6 +5,7 @@ */ import Clutter from 'gi://Clutter'; +import Gio from 'gi://Gio'; import GLib from 'gi://GLib'; import St from 'gi://St'; import { Extension } from 'resource:///org/gnome/shell/extensions/extension.js'; @@ -24,6 +25,7 @@ import { GENERATION_CSS_CLASSES, getGenerationLabel, getGenerationDescription, + getGenerationIconFilename, } from './wifiGeneration.js'; import type { GenerationCssClass, ChannelWidthMHz, SignalDbm } from './types.js'; @@ -69,6 +71,7 @@ const MENU_STRUCTURE: readonly MenuItemConfig[][] = [ 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; @@ -95,6 +98,7 @@ export default class WifiSignalPlusExtension extends Extension { this.indicator = null; this.wifiService = null; + this.icon = null; this.label = null; this.menuItems.clear(); } @@ -103,12 +107,18 @@ export default class WifiSignalPlusExtension extends Extension { this.indicator = new PanelMenu.Button(0.0, this.metadata.name, false); this.indicator.add_style_class_name('wifi-signal-plus-indicator'); + this.icon = new St.Icon({ + style_class: 'system-status-icon', + y_align: Clutter.ActorAlign.CENTER, + }); + this.label = new St.Label({ text: 'WiFi', y_align: Clutter.ActorAlign.CENTER, style_class: 'wifi-signal-plus-label', }); + this.indicator.add_child(this.icon); this.indicator.add_child(this.label); this.buildMenu(); @@ -153,17 +163,31 @@ export default class WifiSignalPlusExtension extends Extension { } private updateIndicatorLabel(info: WifiConnectionInfo): void { - if (!this.label) return; + if (!this.icon || !this.label) return; this.clearGenerationStyles(); if (!isConnected(info)) { + 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.label.set_text(getGenerationLabel(info.generation)); + const iconFilename = getGenerationIconFilename(info.generation); + if (iconFilename) { + const iconPath = GLib.build_filenamev([this.path, 'icons', iconFilename]); + const file = Gio.File.new_for_path(iconPath); + this.icon.gicon = new Gio.FileIcon({ file }); + this.icon.visible = true; + this.label.visible = false; + } else { + this.icon.visible = false; + this.label.visible = true; + this.label.set_text(getGenerationLabel(info.generation)); + } + this.label.add_style_class_name(GENERATION_CSS_CLASSES[info.generation]); } diff --git a/src/icons/wifi-1.svg b/src/icons/wifi-1.svg new file mode 100644 index 0000000..22ff6ab --- /dev/null +++ b/src/icons/wifi-1.svg @@ -0,0 +1 @@ +1 diff --git a/src/icons/wifi-2.svg b/src/icons/wifi-2.svg new file mode 100644 index 0000000..45db761 --- /dev/null +++ b/src/icons/wifi-2.svg @@ -0,0 +1 @@ +2 diff --git a/src/icons/wifi-3.svg b/src/icons/wifi-3.svg new file mode 100644 index 0000000..2233dcb --- /dev/null +++ b/src/icons/wifi-3.svg @@ -0,0 +1 @@ +3 diff --git a/src/icons/wifi-4.png b/src/icons/wifi-4.png new file mode 100644 index 0000000..9e62a95 Binary files /dev/null and b/src/icons/wifi-4.png differ diff --git a/src/icons/wifi-5.png b/src/icons/wifi-5.png new file mode 100644 index 0000000..c40e278 Binary files /dev/null and b/src/icons/wifi-5.png differ diff --git a/src/icons/wifi-6.png b/src/icons/wifi-6.png new file mode 100644 index 0000000..8cbe716 Binary files /dev/null and b/src/icons/wifi-6.png differ diff --git a/src/icons/wifi-7.png b/src/icons/wifi-7.png new file mode 100644 index 0000000..dca98ae Binary files /dev/null and b/src/icons/wifi-7.png differ diff --git a/src/types.ts b/src/types.ts index 0e5f630..49bb545 100644 --- a/src/types.ts +++ b/src/types.ts @@ -17,6 +17,9 @@ export type GuardIntervalUs = Brand; export const WIFI_GENERATIONS = { UNKNOWN: 0, + WIFI_1: 1, + WIFI_2: 2, + WIFI_3: 3, WIFI_4: 4, WIFI_5: 5, WIFI_6: 6, @@ -25,11 +28,14 @@ export const WIFI_GENERATIONS = { export type WifiGeneration = (typeof WIFI_GENERATIONS)[keyof typeof WIFI_GENERATIONS]; -export function isKnownGeneration(gen: WifiGeneration): gen is 4 | 5 | 6 | 7 { - return gen >= WIFI_GENERATIONS.WIFI_4 && gen <= WIFI_GENERATIONS.WIFI_7; +export function isKnownGeneration(gen: WifiGeneration): gen is 1 | 2 | 3 | 4 | 5 | 6 | 7 { + return gen >= WIFI_GENERATIONS.WIFI_1 && gen <= WIFI_GENERATIONS.WIFI_7; } export const IEEE_STANDARDS = { + [WIFI_GENERATIONS.WIFI_1]: '802.11b', + [WIFI_GENERATIONS.WIFI_2]: '802.11a', + [WIFI_GENERATIONS.WIFI_3]: '802.11g', [WIFI_GENERATIONS.WIFI_4]: '802.11n', [WIFI_GENERATIONS.WIFI_5]: '802.11ac', [WIFI_GENERATIONS.WIFI_6]: '802.11ax', @@ -64,10 +70,13 @@ export const SECURITY_PROTOCOLS = [ export type SecurityProtocol = (typeof SECURITY_PROTOCOLS)[number]; -export type GenerationCssClass = `wifi-gen-${4 | 5 | 6 | 7}` | 'wifi-disconnected'; +export type GenerationCssClass = `wifi-gen-${1 | 2 | 3 | 4 | 5 | 6 | 7}` | 'wifi-disconnected'; export type SignalCssClass = `wifi-signal-${Lowercase>}` | ''; export const GENERATION_CSS_CLASSES = { + [WIFI_GENERATIONS.WIFI_1]: 'wifi-gen-1', + [WIFI_GENERATIONS.WIFI_2]: 'wifi-gen-2', + [WIFI_GENERATIONS.WIFI_3]: 'wifi-gen-3', [WIFI_GENERATIONS.WIFI_4]: 'wifi-gen-4', [WIFI_GENERATIONS.WIFI_5]: 'wifi-gen-5', [WIFI_GENERATIONS.WIFI_6]: 'wifi-gen-6', diff --git a/src/wifiGeneration.test.ts b/src/wifiGeneration.test.ts index 393654f..21cf4fe 100644 --- a/src/wifiGeneration.test.ts +++ b/src/wifiGeneration.test.ts @@ -7,6 +7,7 @@ import { GENERATION_CSS_CLASSES, getGenerationLabel, getGenerationDescription, + getGenerationIconFilename, isKnownGeneration, } from './wifiGeneration'; import { GUARD_INTERVALS } from './types'; @@ -37,6 +38,9 @@ describe('createEmptyIwLinkInfo', () => { describe('isKnownGeneration', () => { it('should return true for known generations', () => { + expect(isKnownGeneration(WIFI_GENERATIONS.WIFI_1)).toBe(true); + expect(isKnownGeneration(WIFI_GENERATIONS.WIFI_2)).toBe(true); + expect(isKnownGeneration(WIFI_GENERATIONS.WIFI_3)).toBe(true); expect(isKnownGeneration(WIFI_GENERATIONS.WIFI_4)).toBe(true); expect(isKnownGeneration(WIFI_GENERATIONS.WIFI_5)).toBe(true); expect(isKnownGeneration(WIFI_GENERATIONS.WIFI_6)).toBe(true); @@ -226,8 +230,118 @@ describe('parseIwLinkOutput', () => { }); }); +describe('legacy WiFi detection', () => { + describe('WiFi 2 (802.11a) - 5 GHz legacy', () => { + it('should detect WiFi 2 for 5 GHz without generation markers', () => { + const iwOutput = `Connected to aa:bb:cc:dd:ee:ff (on wlan0) + SSID: LegacyNetwork + freq: 5180 + signal: -60 dBm + tx bitrate: 54.0 MBit/s + rx bitrate: 48.0 MBit/s`; + + const result = parseIwLinkOutput(iwOutput); + + expect(result.generation).toBe(WIFI_GENERATIONS.WIFI_2); + expect(result.standard).toBe('802.11a'); + }); + }); + + describe('WiFi 1 (802.11b) - 2.4 GHz low bitrate', () => { + it('should detect WiFi 1 for 2.4 GHz with bitrate <= 11 Mbps', () => { + const iwOutput = `Connected to aa:bb:cc:dd:ee:ff (on wlan0) + SSID: VeryOldNetwork + freq: 2437 + signal: -70 dBm + tx bitrate: 11.0 MBit/s + rx bitrate: 5.5 MBit/s`; + + const result = parseIwLinkOutput(iwOutput); + + expect(result.generation).toBe(WIFI_GENERATIONS.WIFI_1); + expect(result.standard).toBe('802.11b'); + }); + + it('should detect WiFi 1 for 2.4 GHz with 1 Mbps bitrate', () => { + const iwOutput = `Connected to aa:bb:cc:dd:ee:ff (on wlan0) + freq: 2412 + signal: -80 dBm + tx bitrate: 1.0 MBit/s`; + + const result = parseIwLinkOutput(iwOutput); + + expect(result.generation).toBe(WIFI_GENERATIONS.WIFI_1); + }); + }); + + describe('WiFi 3 (802.11g) - 2.4 GHz high bitrate', () => { + it('should detect WiFi 3 for 2.4 GHz with bitrate > 11 Mbps', () => { + const iwOutput = `Connected to aa:bb:cc:dd:ee:ff (on wlan0) + SSID: OlderNetwork + freq: 2437 + signal: -55 dBm + tx bitrate: 54.0 MBit/s + rx bitrate: 36.0 MBit/s`; + + const result = parseIwLinkOutput(iwOutput); + + expect(result.generation).toBe(WIFI_GENERATIONS.WIFI_3); + expect(result.standard).toBe('802.11g'); + }); + + it('should detect WiFi 3 for 2.4 GHz with 12 Mbps bitrate', () => { + const iwOutput = `Connected to aa:bb:cc:dd:ee:ff (on wlan0) + freq: 2462 + signal: -65 dBm + tx bitrate: 12.0 MBit/s`; + + const result = parseIwLinkOutput(iwOutput); + + expect(result.generation).toBe(WIFI_GENERATIONS.WIFI_3); + }); + }); + + describe('no legacy detection when generation already known', () => { + it('should not override WiFi 4 detection with legacy', () => { + const iwOutput = `Connected to aa:bb:cc:dd:ee:ff (on wlan0) + freq: 2437 + signal: -65 dBm + tx bitrate: 72.2 MBit/s MCS 7 20MHz short GI`; + + const result = parseIwLinkOutput(iwOutput); + + expect(result.generation).toBe(WIFI_GENERATIONS.WIFI_4); + }); + }); + + describe('no legacy detection without enough info', () => { + it('should remain UNKNOWN without frequency', () => { + const iwOutput = `Connected to aa:bb:cc:dd:ee:ff (on wlan0) + signal: -65 dBm + tx bitrate: 54.0 MBit/s`; + + const result = parseIwLinkOutput(iwOutput); + + expect(result.generation).toBe(WIFI_GENERATIONS.UNKNOWN); + }); + + it('should remain UNKNOWN without bitrate on 2.4 GHz', () => { + const iwOutput = `Connected to aa:bb:cc:dd:ee:ff (on wlan0) + freq: 2437 + signal: -65 dBm`; + + const result = parseIwLinkOutput(iwOutput); + + expect(result.generation).toBe(WIFI_GENERATIONS.UNKNOWN); + }); + }); +}); + describe('getGenerationLabel', () => { it('should return "WiFi X" for known generations', () => { + expect(getGenerationLabel(WIFI_GENERATIONS.WIFI_1)).toBe('WiFi 1'); + expect(getGenerationLabel(WIFI_GENERATIONS.WIFI_2)).toBe('WiFi 2'); + expect(getGenerationLabel(WIFI_GENERATIONS.WIFI_3)).toBe('WiFi 3'); expect(getGenerationLabel(WIFI_GENERATIONS.WIFI_4)).toBe('WiFi 4'); expect(getGenerationLabel(WIFI_GENERATIONS.WIFI_5)).toBe('WiFi 5'); expect(getGenerationLabel(WIFI_GENERATIONS.WIFI_6)).toBe('WiFi 6'); @@ -241,6 +355,9 @@ describe('getGenerationLabel', () => { describe('getGenerationDescription', () => { it('should return full description with IEEE standard', () => { + expect(getGenerationDescription(WIFI_GENERATIONS.WIFI_1)).toBe('WiFi 1 (802.11b)'); + expect(getGenerationDescription(WIFI_GENERATIONS.WIFI_2)).toBe('WiFi 2 (802.11a)'); + expect(getGenerationDescription(WIFI_GENERATIONS.WIFI_3)).toBe('WiFi 3 (802.11g)'); expect(getGenerationDescription(WIFI_GENERATIONS.WIFI_4)).toBe('WiFi 4 (802.11n)'); expect(getGenerationDescription(WIFI_GENERATIONS.WIFI_5)).toBe('WiFi 5 (802.11ac)'); expect(getGenerationDescription(WIFI_GENERATIONS.WIFI_6)).toBe('WiFi 6 (802.11ax)'); @@ -252,8 +369,30 @@ describe('getGenerationDescription', () => { }); }); +describe('getGenerationIconFilename', () => { + it('should return PNG filename for WiFi 4-7', () => { + expect(getGenerationIconFilename(WIFI_GENERATIONS.WIFI_4)).toBe('wifi-4.png'); + expect(getGenerationIconFilename(WIFI_GENERATIONS.WIFI_5)).toBe('wifi-5.png'); + expect(getGenerationIconFilename(WIFI_GENERATIONS.WIFI_6)).toBe('wifi-6.png'); + expect(getGenerationIconFilename(WIFI_GENERATIONS.WIFI_7)).toBe('wifi-7.png'); + }); + + it('should return SVG filename for WiFi 1-3', () => { + expect(getGenerationIconFilename(WIFI_GENERATIONS.WIFI_1)).toBe('wifi-1.svg'); + expect(getGenerationIconFilename(WIFI_GENERATIONS.WIFI_2)).toBe('wifi-2.svg'); + expect(getGenerationIconFilename(WIFI_GENERATIONS.WIFI_3)).toBe('wifi-3.svg'); + }); + + it('should return null for UNKNOWN', () => { + expect(getGenerationIconFilename(WIFI_GENERATIONS.UNKNOWN)).toBeNull(); + }); +}); + describe('IEEE_STANDARDS', () => { it('should map all generations to their IEEE standards', () => { + expect(IEEE_STANDARDS[WIFI_GENERATIONS.WIFI_1]).toBe('802.11b'); + expect(IEEE_STANDARDS[WIFI_GENERATIONS.WIFI_2]).toBe('802.11a'); + expect(IEEE_STANDARDS[WIFI_GENERATIONS.WIFI_3]).toBe('802.11g'); expect(IEEE_STANDARDS[WIFI_GENERATIONS.WIFI_4]).toBe('802.11n'); expect(IEEE_STANDARDS[WIFI_GENERATIONS.WIFI_5]).toBe('802.11ac'); expect(IEEE_STANDARDS[WIFI_GENERATIONS.WIFI_6]).toBe('802.11ax'); @@ -264,6 +403,9 @@ describe('IEEE_STANDARDS', () => { describe('GENERATION_CSS_CLASSES', () => { it('should map all generations to CSS classes', () => { + expect(GENERATION_CSS_CLASSES[WIFI_GENERATIONS.WIFI_1]).toBe('wifi-gen-1'); + expect(GENERATION_CSS_CLASSES[WIFI_GENERATIONS.WIFI_2]).toBe('wifi-gen-2'); + expect(GENERATION_CSS_CLASSES[WIFI_GENERATIONS.WIFI_3]).toBe('wifi-gen-3'); expect(GENERATION_CSS_CLASSES[WIFI_GENERATIONS.WIFI_4]).toBe('wifi-gen-4'); expect(GENERATION_CSS_CLASSES[WIFI_GENERATIONS.WIFI_5]).toBe('wifi-gen-5'); expect(GENERATION_CSS_CLASSES[WIFI_GENERATIONS.WIFI_6]).toBe('wifi-gen-6'); @@ -272,8 +414,10 @@ describe('GENERATION_CSS_CLASSES', () => { }); it('should use template literal type pattern', () => { - // Type check: all values should match the GenerationCssClass type const classes = Object.values(GENERATION_CSS_CLASSES); + expect(classes).toContain('wifi-gen-1'); + expect(classes).toContain('wifi-gen-2'); + expect(classes).toContain('wifi-gen-3'); expect(classes).toContain('wifi-gen-4'); expect(classes).toContain('wifi-gen-5'); expect(classes).toContain('wifi-gen-6'); diff --git a/src/wifiGeneration.ts b/src/wifiGeneration.ts index 7aa08e9..5beed42 100644 --- a/src/wifiGeneration.ts +++ b/src/wifiGeneration.ts @@ -81,6 +81,8 @@ export function parseIwLinkOutput(iwOutput: string): IwLinkInfo { parseLine(line.trim(), result); } + detectLegacyGeneration(result); + return freezeResult(result); } @@ -101,6 +103,28 @@ function createMutableResult(): MutableParseResult { }; } +const WIFI_1_MAX_BITRATE = 11; +const FREQ_5GHZ_START = 5000; + +function detectLegacyGeneration(result: MutableParseResult): void { + if (result.generation !== WIFI_GENERATIONS.UNKNOWN) return; + if (result.frequency === null) return; + + if (result.frequency >= FREQ_5GHZ_START) { + result.generation = WIFI_GENERATIONS.WIFI_2; + return; + } + + const maxBitrate = Math.max( + (result.txBitrate as number | null) ?? 0, + (result.rxBitrate as number | null) ?? 0 + ); + if (maxBitrate === 0) return; + + result.generation = + maxBitrate <= WIFI_1_MAX_BITRATE ? WIFI_GENERATIONS.WIFI_1 : WIFI_GENERATIONS.WIFI_3; +} + function freezeResult(result: MutableParseResult): IwLinkInfo { if (isKnownGeneration(result.generation)) { result.standard = IEEE_STANDARDS[result.generation]; @@ -285,3 +309,18 @@ export function getGenerationDescription(generation: WifiGeneration): string { ? `WiFi ${generation} (${IEEE_STANDARDS[generation]})` : 'WiFi'; } + +const GENERATION_ICON_FILENAMES: Record = { + [WIFI_GENERATIONS.WIFI_1]: 'wifi-1.svg', + [WIFI_GENERATIONS.WIFI_2]: 'wifi-2.svg', + [WIFI_GENERATIONS.WIFI_3]: 'wifi-3.svg', + [WIFI_GENERATIONS.WIFI_4]: 'wifi-4.png', + [WIFI_GENERATIONS.WIFI_5]: 'wifi-5.png', + [WIFI_GENERATIONS.WIFI_6]: 'wifi-6.png', + [WIFI_GENERATIONS.WIFI_7]: 'wifi-7.png', + [WIFI_GENERATIONS.UNKNOWN]: null, +} as const; + +export function getGenerationIconFilename(generation: WifiGeneration): string | null { + return GENERATION_ICON_FILENAMES[generation]; +}