Compare commits

..

4 commits
v1.1.0 ... main

Author SHA1 Message Date
2634d43bcb chore: switch update-check and release URLs to self-hosted Forgejo
GitHub-репо заморожены; релизы теперь на git.engelgardt23.ru.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 03:46:56 +03:00
e4d62d1b94 v1.2.0: rewrite in Python (mirrors dhcpsrv); clickable update link 2026-05-18 12:51:42 +03:00
74549db869 docs: use US/RU flags in language switcher 2026-05-18 12:00:35 +03:00
40ed35760f docs: drop flag emojis from language switcher 2026-05-18 11:59:22 +03:00
16 changed files with 583 additions and 292 deletions

View file

@ -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"

20
.gitignore vendored
View file

@ -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

View file

@ -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

View file

@ -3,7 +3,7 @@
[![Latest release](https://img.shields.io/github/v/release/Engelgardt23/netswitch)](https://github.com/Engelgardt23/netswitch/releases/latest)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
🇬🇧 English | [🇷🇺 На русском](README.ru.md)
🇺🇸 English | [🇷🇺 Русский](README.ru.md)
A tiny portable tool to flip a Windows network adapter between a **static IP** and **DHCP** with a few keystrokes.
@ -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

View file

@ -3,7 +3,7 @@
[![Последний релиз](https://img.shields.io/github/v/release/Engelgardt23/netswitch)](https://github.com/Engelgardt23/netswitch/releases/latest)
[![Лицензия: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[🇬🇧 English](README.md) | 🇷🇺 На русском
[🇺🇸 English](README.md) | 🇷🇺 Русский
Маленький портативный инструмент, который за пару нажатий переключает сетевой адаптер Windows между **статическим IP** и **DHCP**.
@ -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
```
## Лицензия

12
netswitch-launcher.py Normal file
View file

@ -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()

26
pyproject.toml Normal file
View file

@ -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__" }

View file

@ -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

10
src/netswitch/__init__.py Normal file
View file

@ -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.1"
GITHUB_REPO = "engel/netswitch" # на Forgejo (git.engelgardt23.ru)

11
src/netswitch/__main__.py Normal file
View file

@ -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()

123
src/netswitch/app.py Normal file
View file

@ -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://git.engelgardt23.ru/{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()

88
src/netswitch/config.py Normal file
View file

@ -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}

83
src/netswitch/i18n.py Normal file
View file

@ -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

107
src/netswitch/network.py Normal file
View file

@ -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
]

View file

@ -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)

View file

@ -0,0 +1,35 @@
"""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://git.engelgardt23.ru/api/v1/repos/{GITHUB_REPO}/releases/latest"
req = urllib.request.Request(url, headers={
"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