diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fac4b68..8aacf49 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,25 +14,21 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install build dependencies + run: python -m pip install --upgrade pip pyinstaller rich + - name: Resolve version from tag id: ver shell: bash run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" - - name: Install ps2exe - shell: pwsh - run: Install-Module -Name ps2exe -Scope CurrentUser -Force -AllowClobber - - name: Build executable - shell: pwsh - run: | - Import-Module ps2exe - $ver = '${{ steps.ver.outputs.version }}' - Invoke-ps2exe -inputFile src/netswitch.ps1 -outputFile netswitch.exe ` - -title "netswitch" -description "NIC IP/DHCP toggle - made by engelgardt" ` - -company "engelgardt" -version "$ver.0" ` - -iconFile assets/icon.ico ` - -requireAdmin + run: python -m PyInstaller --onefile --uac-admin --console --name netswitch --icon assets/icon.ico --paths src netswitch-launcher.py - name: Package portable folder shell: pwsh @@ -40,7 +36,7 @@ jobs: $ver = '${{ steps.ver.outputs.version }}' $folder = "netswitch-v$ver" New-Item -ItemType Directory -Path $folder | Out-Null - Copy-Item netswitch.exe $folder/ + Copy-Item dist/netswitch.exe $folder/ @" netswitch v$ver - portable edition made by engelgardt @@ -49,6 +45,7 @@ jobs: USAGE Double-click netswitch.exe. + Pick the language on first run (1 - English, 2 - Russian). Accept the UAC prompt. Pick the NIC, then choose mode (Static / DHCP). Press Enter to exit. @@ -56,7 +53,7 @@ jobs: NOTES - Nothing is installed. Delete the folder to remove. - Requires Windows 10/11. - - PowerShell is bundled into the exe via ps2exe; nothing extra needed. + - Language can be changed any time by editing 'language = en/ru' in config.ini. "@ | Out-File -FilePath "$folder/README.txt" -Encoding UTF8 Compress-Archive -Path $folder -DestinationPath "netswitch-portable-v$ver.zip" diff --git a/.gitignore b/.gitignore index e515396..e160ba9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,22 @@ -# Build output / staging -*.exe +# PyInstaller build artifacts +build/ +dist/ +*.spec + +# Python cache +__pycache__/ +*.py[cod] + +# Local build cache (prod/test/old portable folders per version) +builds/ + +# Legacy staging folders (kept for compatibility with old checkouts) portable-v*/ -# Local backup of release archives +# Stray standalone exe (we never commit binaries — they live in GitHub releases) +*.exe + +# Local backup of release archives (kept locally for history, not in repo) releases/ # Editor / OS junk diff --git a/CHANGELOG.md b/CHANGELOG.md index 4effc95..e6c7629 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and ## [Unreleased] +## [1.2.0] - 2026-05-18 +### Changed +- **Rewrote netswitch in Python** (was PowerShell + ps2exe). The single-file `.ps1` script is gone, replaced by a small `netswitch/` package (`app.py`, `config.py`, `i18n.py`, `network.py`, `platform_win.py`, `update_check.py`) that mirrors the layout used by `dhcpsrv`. CI now builds via PyInstaller instead of ps2exe. +- Output is now a Rich-styled console (coloured banner, current-config table) instead of plain `Write-Host`. +- The `Update available (vX.Y.Z)` notice in the header is a clickable terminal hyperlink to the GitHub releases page (OSC 8). Modern terminals render it as a link; older consoles show plain text. +### Removed +- `src/netswitch.ps1` and the ps2exe build step. If you specifically need a tiny PowerShell version, check out tag `v1.1.0`. + ## [1.1.0] - 2026-05-18 ### Added - Russian UI translation. On first launch the application asks which language to use (`1) English`, `2) Русский`) and writes the answer to a fresh `config.ini` next to `netswitch.exe`. To change the language later, edit `language = en` / `language = ru` in that file — the comment at the top of the file explains how, in both languages. @@ -36,7 +44,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and - Auto-update check on startup: polls GitHub `/releases/latest` with a 3-second timeout and offers to open the download page if a newer version exists. Silent on offline / API errors. - MIT licensed. -[Unreleased]: https://github.com/Engelgardt23/netswitch/compare/v1.1.0...HEAD +[Unreleased]: https://github.com/Engelgardt23/netswitch/compare/v1.2.0...HEAD +[1.2.0]: https://github.com/Engelgardt23/netswitch/compare/v1.1.0...v1.2.0 [1.1.0]: https://github.com/Engelgardt23/netswitch/compare/v1.0.3...v1.1.0 [1.0.3]: https://github.com/Engelgardt23/netswitch/compare/v1.0.2...v1.0.3 [1.0.2]: https://github.com/Engelgardt23/netswitch/compare/v1.0.1...v1.0.2 diff --git a/README.md b/README.md index e1d4752..2c352e4 100644 --- a/README.md +++ b/README.md @@ -38,11 +38,9 @@ On every launch the tool calls GitHub's `/releases/latest` (3-second timeout). I ## Build from source -The script is a single `netswitch.ps1`. To rebuild the bundled `.exe`: - ``` -Install-Module ps2exe -Scope CurrentUser -Invoke-ps2exe -inputFile netswitch.ps1 -outputFile netswitch.exe -requireAdmin -title "netswitch" -version 1.0.0.0 +python -m pip install rich pyinstaller +python -m PyInstaller --onefile --uac-admin --console --name netswitch --icon assets/icon.ico --paths src netswitch-launcher.py ``` ## License diff --git a/README.ru.md b/README.ru.md index 591f945..f3ed398 100644 --- a/README.ru.md +++ b/README.ru.md @@ -50,11 +50,9 @@ language = ru ## Сборка из исходников -Скрипт один — `netswitch.ps1`. Для пересборки `.exe`: - ``` -Install-Module ps2exe -Scope CurrentUser -Invoke-ps2exe -inputFile netswitch.ps1 -outputFile netswitch.exe -requireAdmin -title "netswitch" -version 1.1.0.0 +python -m pip install rich pyinstaller +python -m PyInstaller --onefile --uac-admin --console --name netswitch --icon assets/icon.ico --paths src netswitch-launcher.py ``` ## Лицензия diff --git a/netswitch-launcher.py b/netswitch-launcher.py new file mode 100644 index 0000000..df2cea7 --- /dev/null +++ b/netswitch-launcher.py @@ -0,0 +1,12 @@ +""" +PyInstaller entry point — sits at the repo root and uses an *absolute* import +so the bundled exe doesn't need relative-import resolution at runtime. + +For dev work without an install use `python -m netswitch` instead. +""" + +from netswitch.app import main + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..81c41ce --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,26 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "netswitch" +description = "Portable Windows NIC IP / DHCP toggle." +readme = "README.md" +requires-python = ">=3.10" +license = { text = "MIT" } +authors = [{ name = "engelgardt" }] +dependencies = ["rich>=13"] +dynamic = ["version"] + +[project.urls] +Homepage = "https://github.com/Engelgardt23/netswitch" +Issues = "https://github.com/Engelgardt23/netswitch/issues" + +[project.scripts] +netswitch = "netswitch.app:main" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.dynamic] +version = { attr = "netswitch.__version__" } diff --git a/src/netswitch.ps1 b/src/netswitch.ps1 deleted file mode 100644 index 062e4b8..0000000 --- a/src/netswitch.ps1 +++ /dev/null @@ -1,263 +0,0 @@ -# netswitch - quick NIC IP / DHCP toggle -# made by engelgardt - -$NetswitchVersion = '1.1.0' -$GithubRepo = 'Engelgardt23/netswitch' - -$ErrorActionPreference = 'Stop' - -# --- Self-elevate if not admin (no-op when launched from the ps2exe build which already requests admin) --- -$me = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() -if (-not $me.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { - if ($PSCommandPath) { - Start-Process -FilePath 'powershell.exe' ` - -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File',"`"$PSCommandPath`"") ` - -Verb RunAs - } else { - Start-Process -FilePath ([System.Diagnostics.Process]::GetCurrentProcess().MainModule.FileName) -Verb RunAs - } - exit -} - -# --- Locate config.ini next to the exe / script --- -function Get-AppDir { - if ($PSCommandPath) { return (Split-Path $PSCommandPath -Parent) } - try { - return Split-Path ([System.Diagnostics.Process]::GetCurrentProcess().MainModule.FileName) -Parent - } catch { - return (Get-Location).Path - } -} -$AppDir = Get-AppDir -$ConfigPath = Join-Path $AppDir 'config.ini' - -# --- Bilingual UI strings --- -$STR = @{ - en = @{ - no_adapters = 'No physical wired adapters found.' - press_enter = 'Press Enter to exit' - available_adapters = 'Available adapters:' - select_adapter = 'Select adapter number' - invalid_selection = 'Invalid selection.' - selected = 'Selected: {0}' - mode_header = 'Mode:' - mode_static = ' 1) Static IP' - mode_dhcp = ' 2) DHCP' - mode_choice = 'Choice [1]' - setting_dhcp = 'Setting {0} to DHCP...' - done = 'Done.' - ip_prompt = 'IP address [10.10.10.1]' - mask_prompt = 'Subnet mask [255.255.255.0]' - gw_prompt = 'Gateway (Enter to skip)' - setting_static = 'Setting {0} -> {1} / {2}{3}' - via_gw = ' via {0}' - current_config = 'Current IPv4 config:' - update_available = 'update available ({0})' - lang_select = 'Select language / Выберите язык:' - lang_en = ' 1) English' - lang_ru = ' 2) Русский' - lang_invalid = 'Please enter 1 or 2 / Введите 1 или 2' - banner_subtitle = 'NIC IP/DHCP toggle' - } - ru = @{ - no_adapters = 'Подходящие проводные адаптеры не найдены.' - press_enter = 'Нажмите Enter для выхода' - available_adapters = 'Доступные адаптеры:' - select_adapter = 'Введите номер адаптера' - invalid_selection = 'Неверный выбор.' - selected = 'Выбрано: {0}' - mode_header = 'Режим:' - mode_static = ' 1) Статический IP' - mode_dhcp = ' 2) DHCP' - mode_choice = 'Выбор [1]' - setting_dhcp = 'Перевожу {0} в режим DHCP...' - done = 'Готово.' - ip_prompt = 'IP-адрес [10.10.10.1]' - mask_prompt = 'Маска подсети [255.255.255.0]' - gw_prompt = 'Шлюз (Enter — пропустить)' - setting_static = 'Назначаю {0} -> {1} / {2}{3}' - via_gw = ' через {0}' - current_config = 'Текущая конфигурация IPv4:' - update_available = 'доступно обновление ({0})' - lang_select = 'Select language / Выберите язык:' - lang_en = ' 1) English' - lang_ru = ' 2) Русский' - lang_invalid = 'Please enter 1 or 2 / Введите 1 или 2' - banner_subtitle = 'переключатель NIC IP/DHCP' - } -} - -# --- First-run language prompt + config write --- -function Read-Language { - Write-Host '' - Write-Host $STR.en.lang_select - Write-Host $STR.en.lang_en - Write-Host $STR.en.lang_ru - while ($true) { - $c = (Read-Host '>').Trim() - if ($c -eq '1') { return 'en' } - if ($c -eq '2') { return 'ru' } - Write-Host $STR.en.lang_invalid -ForegroundColor Yellow - } -} - -function Write-DefaultConfig([string]$lang) { - $header = @" -# --------------------------------------------------------------------------- -# netswitch configuration -# -# To change the interface language, edit the 'language' value below. -# Valid values: en, ru -# -# Чтобы сменить язык интерфейса, измените значение 'language' ниже. -# Допустимые значения: en, ru -# --------------------------------------------------------------------------- - -[General] -language = $lang -"@ - try { Set-Content -Path $ConfigPath -Value $header -Encoding UTF8 } catch { } -} - -function Read-Config { - if (-not (Test-Path $ConfigPath)) { - $l = Read-Language - Write-DefaultConfig $l - return @{ language = $l } - } - $lang = 'en' - foreach ($line in (Get-Content $ConfigPath -ErrorAction SilentlyContinue)) { - if ($line -match '^\s*language\s*=\s*([a-zA-Z]+)\s*$') { - $v = $matches[1].ToLower() - if ($v -eq 'ru' -or $v -eq 'en') { $lang = $v } - } - } - return @{ language = $lang } -} - -$config = Read-Config -$L = $STR[$config.language] - -# --- Update check (silent: returns latest tag if newer, else empty) --- -function Get-NetswitchUpdate { - try { - $url = "https://api.github.com/repos/$GithubRepo/releases/latest" - $r = Invoke-RestMethod -Uri $url -TimeoutSec 3 -Headers @{ 'User-Agent' = "netswitch/$NetswitchVersion" } - $latest = ($r.tag_name -as [string]) -replace '^v','' - if (-not $latest) { return '' } - $toTuple = { param($s) - $parts = ($s -split '\.') | ForEach-Object { - $n = 0; [void][int]::TryParse($_, [ref]$n); $n - } - while ($parts.Count -lt 3) { $parts += 0 } - ,$parts[0..2] - } - $LV = & $toTuple $latest - $CV = & $toTuple $NetswitchVersion - for ($i = 0; $i -lt 3; $i++) { - if ($LV[$i] -gt $CV[$i]) { return $r.tag_name } - if ($LV[$i] -lt $CV[$i]) { return '' } - } - return '' - } catch { - return '' - } -} -$latestTag = Get-NetswitchUpdate - -# --- Banner --- -Write-Host '' -Write-Host '==============================================' -ForegroundColor Cyan -Write-Host (" netswitch v$NetswitchVersion - " + $L.banner_subtitle) -ForegroundColor Cyan -Write-Host '==============================================' -ForegroundColor Cyan -if ($latestTag) { - $msg = ($L.update_available -f $latestTag) - $w = 0 - try { $w = $Host.UI.RawUI.WindowSize.Width } catch { $w = 0 } - if ($w -lt ($msg.Length + 2)) { $w = $msg.Length + 2 } - $pad = $w - $msg.Length - 1 - Write-Host ((' ' * [Math]::Max(0, $pad)) + $msg) -ForegroundColor DarkGray -} -Write-Host '' - -# --- Pick adapter (physical wired only) --- -$skipDescriptionPattern = 'VPN|Virtual|AnyConnect|TAP-|TUN-|Bluetooth|Loopback|WAN Miniport|Hyper-V|VMware|VirtualBox|WireGuard|OpenVPN|Tailscale|ZeroTier' -$skipMediaTypes = @('Native 802.11', 'Wireless WAN') - -$adapters = @(Get-NetAdapter | Where-Object { - $_.Status -notin @('Disabled','Not Present') -and - -not $_.Virtual -and - $_.MediaType -notin $skipMediaTypes -and - $_.InterfaceDescription -notmatch $skipDescriptionPattern -and - $_.Name -notmatch $skipDescriptionPattern -} | Sort-Object ifIndex) - -if ($adapters.Count -eq 0) { - Write-Host $L.no_adapters -ForegroundColor Red - Read-Host $L.press_enter; exit 1 -} - -Write-Host $L.available_adapters -for ($i = 0; $i -lt $adapters.Count; $i++) { - $a = $adapters[$i] - $ips = (Get-NetIPAddress -InterfaceIndex $a.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue | - Where-Object { $_.PrefixOrigin -ne 'WellKnown' }).IPAddress -join ', ' - Write-Host (" {0}) [{1,-12}] {2} ({3}) {4}" -f ($i + 1), $a.Status, $a.Name, $a.InterfaceDescription, $ips) -} - -do { - $sel = (Read-Host $L.select_adapter).Trim() - $valid = ($sel -match '^\d+$') -and ([int]$sel -ge 1) -and ([int]$sel -le $adapters.Count) - if (-not $valid) { Write-Host $L.invalid_selection -ForegroundColor Red } -} while (-not $valid) -$nic = $adapters[[int]$sel - 1] -Write-Host '' -Write-Host ($L.selected -f $nic.Name) -ForegroundColor Green - -# --- Mode --- -Write-Host '' -Write-Host $L.mode_header -Write-Host $L.mode_static -Write-Host $L.mode_dhcp -$modeChoice = Read-Host $L.mode_choice -if ([string]::IsNullOrWhiteSpace($modeChoice)) { $modeChoice = '1' } - -if ($modeChoice.Trim() -eq '2') { - # --- DHCP --- - Write-Host '' - Write-Host ($L.setting_dhcp -f $nic.Name) -ForegroundColor Yellow - $null = & netsh interface ipv4 set address name="$($nic.Name)" source=dhcp 2>&1 - $null = & netsh interface ipv4 set dnsservers name="$($nic.Name)" source=dhcp 2>&1 - Write-Host $L.done -ForegroundColor Green -} -else { - # --- Static --- - $ip = Read-Host $L.ip_prompt - if ([string]::IsNullOrWhiteSpace($ip)) { $ip = '10.10.10.1' } - - $mask = Read-Host $L.mask_prompt - if ([string]::IsNullOrWhiteSpace($mask)) { $mask = '255.255.255.0' } - - $gw = Read-Host $L.gw_prompt - - $gwTail = if ([string]::IsNullOrWhiteSpace($gw)) { '' } else { ($L.via_gw -f $gw) } - - Write-Host '' - Write-Host ($L.setting_static -f $nic.Name, $ip, $mask, $gwTail) -ForegroundColor Yellow - - if ([string]::IsNullOrWhiteSpace($gw)) { - $null = & netsh interface ipv4 set address name="$($nic.Name)" static $ip $mask 2>&1 - } else { - $null = & netsh interface ipv4 set address name="$($nic.Name)" static $ip $mask $gw 2>&1 - } - Write-Host $L.done -ForegroundColor Green -} - -# --- Show current state --- -Write-Host '' -Write-Host $L.current_config -ForegroundColor Cyan -Get-NetIPAddress -InterfaceIndex $nic.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue | - Where-Object { $_.PrefixOrigin -ne 'WellKnown' } | - Format-Table IPAddress, PrefixLength, PrefixOrigin -AutoSize - -Read-Host $L.press_enter diff --git a/src/netswitch/__init__.py b/src/netswitch/__init__.py new file mode 100644 index 0000000..cbe925a --- /dev/null +++ b/src/netswitch/__init__.py @@ -0,0 +1,10 @@ +""" +netswitch - portable Windows NIC IP / DHCP toggle. +made by engelgardt + +The single source of truth for the project version. Bump this before tagging +a release; CI reads the tag, the code reads this constant. +""" + +__version__ = "1.2.0" +GITHUB_REPO = "Engelgardt23/netswitch" diff --git a/src/netswitch/__main__.py b/src/netswitch/__main__.py new file mode 100644 index 0000000..5b044f2 --- /dev/null +++ b/src/netswitch/__main__.py @@ -0,0 +1,11 @@ +"""Entry point for `python -m netswitch` from a checked-out / installed package. + +The PyInstaller-bundled exe uses `netswitch-launcher.py` at the repo root instead, +because PyInstaller runs the bundled script as a standalone module — relative +imports fail there.""" + +from .app import main + + +if __name__ == "__main__": + main() diff --git a/src/netswitch/app.py b/src/netswitch/app.py new file mode 100644 index 0000000..bbe7fa5 --- /dev/null +++ b/src/netswitch/app.py @@ -0,0 +1,123 @@ +""" +netswitch entry: language prompt + admin elevation + banner + adapter picker + +mode (static/DHCP) + apply via netsh + show resulting config. +""" + +from __future__ import annotations +import sys + +from rich.console import Console +from rich.prompt import Prompt +from rich.table import Table + +from . import __version__, GITHUB_REPO +from .platform_win import enable_vt, require_admin +from .config import load_config +from .i18n import set_language, t +from .update_check import check_for_update +from .network import list_adapters, set_static_ip, revert_to_dhcp, get_current_ipv4 + + +def _pick_adapter(console: Console) -> dict | None: + adapters = list_adapters() + if not adapters: + console.print(f"[red]{t('no_adapters')}[/]") + return None + + console.print(t("available_adapters")) + for i, a in enumerate(adapters, 1): + ip = a.get("IPv4") or "—" + console.print(f" {i}) [{a['Status']:<12}] {a['Name']} ({a['Description']}) {ip}") + + while True: + s = Prompt.ask(t("select_adapter")).strip() + if s.isdigit() and 1 <= int(s) <= len(adapters): + return adapters[int(s) - 1] + console.print(f"[red]{t('invalid_selection')}[/]") + + +def _show_current(console: Console, nic: dict) -> None: + console.print() + console.print(f"[cyan]{t('current_config')}[/]") + rows = get_current_ipv4(int(nic["ifIndex"])) + if not rows: + console.print(f"[dim]{t('no_ip')}[/]") + return + tbl = Table(show_edge=False, pad_edge=False) + tbl.add_column(t("col_ip")) + tbl.add_column(t("col_prefix"), justify="right") + tbl.add_column(t("col_origin")) + for r in rows: + tbl.add_row(str(r.get("ip") or "—"), + str(r.get("prefix_len") or "—"), + str(r.get("prefix_origin") or "—")) + console.print(tbl) + + +def main() -> None: + enable_vt() + + # Language prompt before admin elevation so the user doesn't have to answer + # it twice across the UAC bounce. + cfg_data = load_config() + set_language(cfg_data["language"]) + + require_admin() + + console = Console(log_path=False) + + title = f"[bold cyan]netswitch v{__version__}[/] {t('tagline')}" + latest = check_for_update() + if latest: + release_url = f"https://github.com/{GITHUB_REPO}/releases/latest" + notice = t("update_available", tag=latest) + header = Table.grid(expand=True) + header.add_column(justify="left", ratio=1) + header.add_column(justify="right") + header.add_row(title, f"[dim][link={release_url}]{notice}[/link][/]") + console.print(header) + else: + console.print(title) + console.print() + + nic = _pick_adapter(console) + if not nic: + input(t("press_enter")); return + + console.print() + console.print(f"[green]{t('selected', name=nic['Name'])}[/]") + console.print() + + console.print(t("mode_header")) + console.print(t("mode_static")) + console.print(t("mode_dhcp")) + + try: + choice = Prompt.ask(t("mode_choice"), default="1").strip() or "1" + except (EOFError, KeyboardInterrupt): + return + + if choice == "2": + console.print() + console.print(f"[yellow]{t('setting_dhcp', name=nic['Name'])}[/]") + revert_to_dhcp(nic["Name"]) + console.print(f"[green]{t('done')}[/]") + else: + ip = (Prompt.ask(t("ip_prompt"), default="10.10.10.1").strip() or "10.10.10.1") + mask = (Prompt.ask(t("mask_prompt"), default="255.255.255.0").strip() or "255.255.255.0") + gw = Prompt.ask(t("gw_prompt"), default="").strip() + + gw_tail = t("via_gw", gw=gw) if gw else "" + console.print() + console.print(f"[yellow]{t('setting_static', name=nic['Name'], ip=ip, mask=mask, gw_tail=gw_tail)}[/]") + set_static_ip(nic["Name"], ip, mask, gw) + console.print(f"[green]{t('done')}[/]") + + _show_current(console, nic) + + console.print() + input(t("press_enter")) + + +if __name__ == "__main__": + main() diff --git a/src/netswitch/config.py b/src/netswitch/config.py new file mode 100644 index 0000000..3ec5cdd --- /dev/null +++ b/src/netswitch/config.py @@ -0,0 +1,88 @@ +""" +config.ini handling next to the executable. + +On first run the file does not exist — we ask the user which language to use +and write the answer alongside the exe. On every subsequent run we just read +it. +""" + +from __future__ import annotations +import configparser +import sys +from pathlib import Path + + +SUPPORTED_LANGS = ("en", "ru") +DEFAULT_LANG = "en" + +CONFIG_HEADER = """\ +# --------------------------------------------------------------------------- +# netswitch configuration +# +# To change the interface language, edit the 'language' value below. +# Valid values: en, ru +# +# Чтобы сменить язык интерфейса, измените значение 'language' ниже. +# Допустимые значения: en, ru +# --------------------------------------------------------------------------- + +""" + + +def app_dir() -> Path: + """Directory holding the running executable (or source folder when run via python).""" + if getattr(sys, "frozen", False): + return Path(sys.executable).resolve().parent + return Path(__file__).resolve().parent + + +def config_path() -> Path: + return app_dir() / "config.ini" + + +def _ask_language() -> str: + print() + print("Select language / Выберите язык:") + print(" 1) English") + print(" 2) Русский") + while True: + try: + choice = input("> ").strip() + except (EOFError, KeyboardInterrupt): + return DEFAULT_LANG + if choice == "1": + return "en" + if choice == "2": + return "ru" + print("Please enter 1 or 2 / Введите 1 или 2") + + +def _write_config(lang: str) -> None: + path = config_path() + cp = configparser.ConfigParser() + cp["General"] = {"language": lang} + with path.open("w", encoding="utf-8") as f: + f.write(CONFIG_HEADER) + cp.write(f) + + +def load_config() -> dict: + path = config_path() + if not path.exists(): + lang = _ask_language() + try: + _write_config(lang) + except OSError: + pass + return {"language": lang} + + cp = configparser.ConfigParser() + try: + cp.read(path, encoding="utf-8") + except (configparser.Error, OSError): + return {"language": DEFAULT_LANG} + + lang = (cp.get("General", "language", fallback=DEFAULT_LANG) or DEFAULT_LANG).strip().lower() + if lang not in SUPPORTED_LANGS: + lang = DEFAULT_LANG + return {"language": lang} diff --git a/src/netswitch/i18n.py b/src/netswitch/i18n.py new file mode 100644 index 0000000..08ec960 --- /dev/null +++ b/src/netswitch/i18n.py @@ -0,0 +1,83 @@ +""" +Tiny in-memory translation table. +""" + +from __future__ import annotations + + +_lang = "en" + +STRINGS: dict[str, dict[str, str]] = { + "en": { + "tagline": "- NIC IP/DHCP toggle", + "update_available": "Update available ({tag})", + "no_adapters": "No physical wired adapters found.", + "press_enter": "Press Enter to exit", + "available_adapters": "Available adapters:", + "select_adapter": "Select adapter number", + "invalid_selection": "Invalid selection.", + "selected": "Selected: {name}", + "mode_header": "Mode:", + "mode_static": " 1) Static IP", + "mode_dhcp": " 2) DHCP", + "mode_choice": "Choice [1]", + "setting_dhcp": "Setting {name} to DHCP...", + "done": "Done.", + "ip_prompt": "IP address [10.10.10.1]", + "mask_prompt": "Subnet mask [255.255.255.0]", + "gw_prompt": "Gateway (Enter to skip)", + "setting_static": "Setting {name} -> {ip} / {mask}{gw_tail}", + "via_gw": " via {gw}", + "current_config": "Current IPv4 config:", + "col_ip": "IP", + "col_prefix": "Prefix", + "col_origin": "Origin", + "no_ip": "(no IPv4 addresses)", + }, + "ru": { + "tagline": "— переключатель NIC IP/DHCP", + "update_available": "Доступно обновление ({tag})", + "no_adapters": "Подходящие проводные адаптеры не найдены.", + "press_enter": "Нажмите Enter для выхода", + "available_adapters": "Доступные адаптеры:", + "select_adapter": "Введите номер адаптера", + "invalid_selection": "Неверный выбор.", + "selected": "Выбрано: {name}", + "mode_header": "Режим:", + "mode_static": " 1) Статический IP", + "mode_dhcp": " 2) DHCP", + "mode_choice": "Выбор [1]", + "setting_dhcp": "Перевожу {name} в режим DHCP...", + "done": "Готово.", + "ip_prompt": "IP-адрес [10.10.10.1]", + "mask_prompt": "Маска подсети [255.255.255.0]", + "gw_prompt": "Шлюз (Enter — пропустить)", + "setting_static": "Назначаю {name} -> {ip} / {mask}{gw_tail}", + "via_gw": " через {gw}", + "current_config": "Текущая конфигурация IPv4:", + "col_ip": "IP", + "col_prefix": "Префикс", + "col_origin": "Источник", + "no_ip": "(нет IPv4-адресов)", + }, +} + + +def set_language(lang: str) -> None: + global _lang + if lang in STRINGS: + _lang = lang + + +def language() -> str: + return _lang + + +def t(key: str, **params) -> str: + s = STRINGS.get(_lang, {}).get(key) or STRINGS["en"].get(key, key) + if params: + try: + return s.format(**params) + except (KeyError, IndexError): + return s + return s diff --git a/src/netswitch/network.py b/src/netswitch/network.py new file mode 100644 index 0000000..bb7213f --- /dev/null +++ b/src/netswitch/network.py @@ -0,0 +1,107 @@ +""" +Network plumbing: enumerate physical NICs, set / revert IP via netsh, dump +current IPv4 config for the chosen NIC. +""" + +from __future__ import annotations +import json +import os +import subprocess +from typing import Any + +CREATE_NO_WINDOW = 0x08000000 if os.name == "nt" else 0 + +# Adapters we never show in the picker — same blacklist as dhcpsrv. +SKIP_DESCRIPTION = ( + "VPN", "Virtual", "AnyConnect", "TAP-", "TUN-", "Bluetooth", "Loopback", + "WAN Miniport", "Hyper-V", "VMware", "VirtualBox", "WireGuard", "OpenVPN", + "Tailscale", "ZeroTier", +) +SKIP_MEDIA = ("Native 802.11", "Wireless WAN") + + +def _run_ps(cmd: str, timeout: int = 15) -> subprocess.CompletedProcess: + return subprocess.run( + ["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", cmd], + capture_output=True, text=True, timeout=timeout, + creationflags=CREATE_NO_WINDOW, + ) + + +def _run_netsh(args: list[str], timeout: int = 15) -> subprocess.CompletedProcess: + return subprocess.run( + ["netsh", *args], + capture_output=True, text=True, timeout=timeout, + creationflags=CREATE_NO_WINDOW, + ) + + +def list_adapters() -> list[dict[str, Any]]: + """Return physical wired adapters only — skip wireless / VPN / virtual.""" + cmd = ( + r"Get-NetAdapter | ForEach-Object {" + r" $ip = (Get-NetIPAddress -InterfaceIndex $_.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue | " + r" Where-Object PrefixOrigin -ne 'WellKnown' | Select-Object -ExpandProperty IPAddress) -join ','; " + r" [pscustomobject]@{" + r" Name=$_.Name; Description=$_.InterfaceDescription; Status=$_.Status; " + r" Virtual=[bool]$_.Virtual; MediaType=$_.MediaType; ifIndex=$_.ifIndex; IPv4=$ip" + r" }} | ConvertTo-Json -Depth 3 -Compress" + ) + r = _run_ps(cmd, timeout=20) + if r.returncode != 0 or not r.stdout.strip(): + return [] + data = json.loads(r.stdout) + if isinstance(data, dict): + data = [data] + + out: list[dict[str, Any]] = [] + for a in data: + if a.get("Status") in ("Disabled", "Not Present"): + continue + if a.get("Virtual"): + continue + if a.get("MediaType") in SKIP_MEDIA: + continue + haystack = ((a.get("Description") or "") + " " + (a.get("Name") or "")).lower() + if any(k.lower() in haystack for k in SKIP_DESCRIPTION): + continue + out.append(a) + out.sort(key=lambda x: x["ifIndex"]) + return out + + +def set_static_ip(nic_name: str, ip: str, mask: str, gw: str = "") -> None: + args = ["interface", "ipv4", "set", "address", f"name={nic_name}", "static", ip, mask] + if gw: + args.append(gw) + _run_netsh(args) + + +def revert_to_dhcp(nic_name: str) -> None: + _run_netsh(["interface", "ipv4", "set", "address", f"name={nic_name}", "source=dhcp"]) + _run_netsh(["interface", "ipv4", "set", "dnsservers", f"name={nic_name}", "source=dhcp"]) + + +def get_current_ipv4(if_index: int) -> list[dict[str, Any]]: + """Return non-link-local IPv4 addresses for the interface as + [{ip, prefix_len, prefix_origin}, ...].""" + cmd = ( + f"Get-NetIPAddress -InterfaceIndex {if_index} -AddressFamily IPv4 -ErrorAction SilentlyContinue | " + r"Where-Object PrefixOrigin -ne 'WellKnown' | " + r"ForEach-Object { [pscustomobject]@{ " + r" IP=$_.IPAddress; PrefixLength=$_.PrefixLength; PrefixOrigin=[string]$_.PrefixOrigin " + r"}} | ConvertTo-Json -Depth 3 -Compress" + ) + r = _run_ps(cmd) + if r.returncode != 0 or not r.stdout.strip(): + return [] + try: + data = json.loads(r.stdout) + except json.JSONDecodeError: + return [] + if isinstance(data, dict): + data = [data] + return [ + {"ip": x.get("IP"), "prefix_len": x.get("PrefixLength"), "prefix_origin": x.get("PrefixOrigin")} + for x in data + ] diff --git a/src/netswitch/platform_win.py b/src/netswitch/platform_win.py new file mode 100644 index 0000000..1b08b96 --- /dev/null +++ b/src/netswitch/platform_win.py @@ -0,0 +1,43 @@ +""" +Windows-specific bits: VT (ANSI) processing in the console, UAC self-elevation. +""" + +from __future__ import annotations +import ctypes +import os +import sys + + +def enable_vt() -> None: + """Enable virtual-terminal processing on the Windows console so that ESC + escape sequences (colours, OSC 8 hyperlinks) are interpreted rather than + printed as literal characters.""" + if os.name != "nt": + return + try: + k = ctypes.windll.kernel32 + STD_OUT, STD_ERR = -11, -12 + ENABLE_VT = 0x0004 + for std in (STD_OUT, STD_ERR): + h = k.GetStdHandle(std) + mode = ctypes.c_ulong() + if k.GetConsoleMode(h, ctypes.byref(mode)): + k.SetConsoleMode(h, mode.value | ENABLE_VT) + except Exception: + pass + + +def is_admin() -> bool: + try: + return ctypes.windll.shell32.IsUserAnAdmin() != 0 + except Exception: + return False + + +def require_admin() -> None: + """If we're not running elevated, relaunch ourselves through UAC and exit.""" + if is_admin(): + return + args = " ".join(f'"{a}"' for a in sys.argv) + ctypes.windll.shell32.ShellExecuteW(None, "runas", sys.executable, args, None, 1) + sys.exit(0) diff --git a/src/netswitch/update_check.py b/src/netswitch/update_check.py new file mode 100644 index 0000000..7499dc4 --- /dev/null +++ b/src/netswitch/update_check.py @@ -0,0 +1,36 @@ +"""Auto-update check. Returns the latest release tag if it is newer than the +currently running version; otherwise None. Silent on any error.""" + +from __future__ import annotations +import json +import urllib.request + +from . import __version__, GITHUB_REPO + + +def _parse_version(s: str) -> tuple[int, int, int]: + try: + s = (s or "").strip().lstrip("v") + parts = [int(x) for x in s.split(".")[:3]] + while len(parts) < 3: + parts.append(0) + return tuple(parts) # type: ignore[return-value] + except Exception: + return (0, 0, 0) + + +def check_for_update() -> str | None: + try: + url = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest" + req = urllib.request.Request(url, headers={ + "Accept": "application/vnd.github+json", + "User-Agent": f"netswitch/{__version__}", + }) + with urllib.request.urlopen(req, timeout=3) as r: + data = json.loads(r.read().decode("utf-8", errors="replace")) + latest = (data.get("tag_name") or "").strip() + if latest and _parse_version(latest) > _parse_version(__version__): + return latest + except Exception: + pass + return None