Improve VPN status reporting and recovery

This commit is contained in:
2026-10-13 06:53:52 +03:00
parent fdb15ae462
commit 7a716b4c9c
188 changed files with 6953 additions and 1911 deletions
@@ -33,7 +33,7 @@ tail -f ~/Library/Logs/LemanaVPN-openconnect-lite.log
tail -f ~/Library/Logs/LemanaVPN.log
```
Use `vpn --manual` for SSO diagnosis before automatic mode. Use `vpn-lemanapro.sh --patch-only` when only runtime patches need to be applied.
Manual or interactive SSO checks require an explicit request in the current prompt. Within that authorization, use `vpn --manual` for SSO diagnosis before automatic mode. Use `vpn-lemanapro.sh --patch-only` when only runtime patches need to be applied.
## Update Commands
@@ -59,7 +59,7 @@ vpn --configure-keychain
## Answer Format
Final answers must always include how to run, how to check, and how to update. Also state whether live VPN was tested or intentionally skipped because an existing connection should not be disturbed.
For behavior or installation changes, include relevant run, check, and update commands, and state whether live VPN was tested. For skill-only or documentation-only changes, report the result and structural checks without unrelated installation steps.
## Common Mistakes
+4 -2
View File
@@ -45,7 +45,7 @@ Prefer installed status when verifying what the menu-bar app will see, because `
## Live VPN Checks
Live checks are appropriate only for changes to connection start/stop, reconnect, SSO/autofill, Bitwarden/Keychain, `openconnect-lite` patching, status JSON consumed by the app, or DNS cleanup.
Manual or interactive live checks require an explicit request in the current prompt. Relevance alone is not authorization. Live checks are appropriate only for changes to connection start/stop, reconnect, SSO/autofill, Bitwarden/Keychain, `openconnect-lite` patching, status JSON consumed by the app, or DNS cleanup.
Before a live check:
@@ -93,9 +93,11 @@ Real install can modify Homebrew packages, pipx packages, sudoers, `/usr/local/s
## Final Report
Report exact commands and results. Always include:
Report exact commands and results relevant to the change. For behavior or installation changes, include:
- Run: command to use the changed repo/app.
- Check: command to verify state or behavior.
- Update: `sh install.sh` from checkout or the remote `curl ... | sh`.
- Not run: especially any skipped live VPN reconnect, real install, or real uninstall.
For skill-only or documentation-only changes, report the result and structural checks; omit unrelated run/update instructions.
+2 -2
View File
@@ -55,11 +55,11 @@ swift build -c release --package-path app
git diff --check
```
Run a live VPN check only when the change affects real connection behavior, credential flow, SSO/autofill, status reporting, reconnect, or DNS cleanup, and only after checking current status. If VPN is already connected, do not start a second session; report that live reconnect was intentionally skipped unless the user explicitly asks for it.
Manual or interactive live checks require an explicit request in the current prompt. Run a live VPN check only when the change affects real connection behavior, credential flow, SSO/autofill, status reporting, reconnect, or DNS cleanup, and only after checking current status. If VPN is already connected, do not start a second session; report that live reconnect was intentionally skipped unless the user explicitly asks for it.
## Answer Contract
Every final answer for this repository must include:
For behavior or installation changes, the final answer includes the relevant items below. For skill-only or documentation-only changes, report the result and structural checks without unrelated run/update steps:
- What changed, with file references when useful.
- How to run it.
+283 -514
View File
@@ -1,621 +1,390 @@
# Lemana VPN
CLI-установка корпоративного VPN `vpn.lemanapro.ru` для macOS.
macOS-пакет для подключения к корпоративному VPN `vpn.lemanapro.ru`.
**По умолчанию:** ручной вход без сохранения паролей. Откроется окно корпоративного входа, а логин, пароль и одноразовый код вводятся вручную. Bitwarden, Touch ID, сохранение пароля, подстановка 2FA и автоматический вход включаются только явно.
В приложении два способа входа:
**Режимы входа:** `manual` ничего не сохраняет и ничего не подставляет; `saved-login` хранит LDAP логин/пароль в macOS Keychain и оставляет одноразовый код ручным; `saved-totp` дополнительно хранит постоянный код настройки 2FA; `auto` запускается только явно через `vpn-auto`/`--auto`.
- **Вручную**: логин и пароль берутся с этого Mac, одноразовый код вводится вручную в видимом окне.
- **Автоматически**: данные подставляются и форма отправляется в скрытом браузере. Источник выбирается отдельно: **Ключ на этом Mac** или **Bitwarden**.
Репозиторий собирает в один воспроизводимый пакет то, что раньше было ручной локальной настройкой:
Кнопка **Подключить** всегда использует сохранённую настройку. Изменение способа входа не запускает VPN и применяется при следующем подключении. Свежая установка по умолчанию выбирает ручной ввод кода.
- `openconnect` как VPN-клиент;
- `openconnect-lite` для SAML SSO через Keycloak;
- опциональный Bitwarden CLI для LDAP-пароля и постоянного кода настройки 2FA;
- опциональный Touch ID helper для мастер-пароля Bitwarden;
- Swift Menu Bar app `LemanaVPN.app`;
- безопасный DNS cleanup через root-owned wrapper;
- алиасы `vpn`, `vpn-auto`, `vpn-manual`, `vpn-manual-full`, `vpn-debug`, `vpn-fix-dns`.
## Быстрая установка
## Быстрая Установка
```sh
curl -fsSL https://git.dokops.ru/dokril/lemana-vpn/raw/branch/main/install.sh | sh
```
Если установка запущена из терминала, скрипт работает как простой wizard: объяснит, что будет установлено, по Enter оставит безопасный ручной режим и предложит дополнительные удобства только отдельным выбором.
После установки открой новый shell или выполни:
После установки:
```sh
exec zsh
vpn --status
vpn
```
## Интерактивная установка
Обычный путь — запустить installer без флагов и ответить на вопросы:
```sh
curl -fsSL https://git.dokops.ru/dokril/lemana-vpn/raw/branch/main/install.sh | sh
```
Первый важный вопрос — как подключаться:
```text
Как подключаться по умолчанию?
1) Вводить всё вручную (рекомендуется)
Откроется окно входа. Вы сами введёте логин, пароль и одноразовый код.
2) Запомнить логин и пароль на этом Mac
При входе логин и пароль подставятся сами. Одноразовый код вы введёте вручную.
3) Настроить автоматический вход
Нужно заранее сохранить пароль и постоянный код настройки 2FA.
Выбор [1/2/3, Enter=1]:
```
Что означают варианты:
- `1` — ничего не сохранять. Это самый понятный старт: при подключении открывается окно входа, всё вводится руками.
- `2` — сохранить LDAP логин и пароль в macOS Keychain. Одноразовый код всё равно вводится руками.
- `3` — продвинутый путь: сохранить полный набор данных для подстановки или подключить Bitwarden. Если не уверены, выбирайте `1`.
Дальше installer спросит только про системные модули: sudoers, aliases, Swift Menu Bar app и автозапуск. Bitwarden и Touch ID появляются только если вы явно выбрали продвинутый путь.
Флаги остаются для CI, повторяемых установок и диагностики. Для обычной установки они не нужны.
Проверить действия без изменений:
```sh
curl -fsSL https://git.dokops.ru/dokril/lemana-vpn/raw/branch/main/install.sh | sh -s -- --dry-run
```
Пример неинтерактивного режима для автоматизации:
```sh
curl -fsSL https://git.dokops.ru/dokril/lemana-vpn/raw/branch/main/install.sh \
| sh -s -- --non-interactive --auth-profile saved-login --configure-login
```
Принудительно включить интерактивные вопросы:
```sh
curl -fsSL https://git.dokops.ru/dokril/lemana-vpn/raw/branch/main/install.sh | sh -s -- --interactive
```
Запустить без вопросов, с выбранными флагами и дефолтами:
```sh
curl -fsSL https://git.dokops.ru/dokril/lemana-vpn/raw/branch/main/install.sh | sh -s -- --non-interactive
```
Если raw URL отличается, переопредели базовый адрес:
```sh
curl -fsSL https://example.org/dokril/lemana-vpn/raw/branch/main/install.sh \
| LEMANA_VPN_RAW_BASE_URL=https://example.org/dokril/lemana-vpn/raw/branch/main sh
```
## Что ставится
| Путь | Назначение |
| --- | --- |
| `~/bin/vpn-lemanapro.sh` | Основной CLI для подключения, статуса и sync секретов |
| `~/bin/uninstall-lemana-vpn.sh` | Локальный uninstall helper |
| `~/bin/keychain-fingerprint` | Опциональный Touch ID helper для мастер-пароля Bitwarden |
| `~/Applications/LemanaVPN.app` | Swift Menu Bar app для подключения из status bar |
| `~/Library/LaunchAgents/ru.dokops.LemanaVPN.plist` | Автозапуск Menu Bar app при логине |
| `~/.config/lemana-vpn/env` | Локальная конфигурация модулей |
| `~/.config/lemana-vpn/patch-backups/` | Backup исходника `openconnect-lite` перед runtime-патчами |
| `~/.config/openconnect-lite/config.toml` | Профиль SSO и auto-fill правила Keycloak |
| `/usr/local/sbin/lemana-vpn-dns-cleanup` | Root-owned wrapper для сброса только корпоративных DNS |
| `/etc/sudoers.d/lemana-vpn-openconnect` | `NOPASSWD` только для `openconnect` |
| `/etc/sudoers.d/lemana-vpn-dns` | `NOPASSWD` только для DNS cleanup wrapper |
| `~/.zshrc` | Идемпотентный блок алиасов `vpn`, `vpn-auto`, `vpn-manual`, `vpn-manual-full`, `vpn-debug`, `vpn-fix-dns` |
## Статус модулей
`vpn` и `vpn --status` первой строкой показывают, какие модули включены в конфиге и реально установлены на машине:
```sh
vpn --status
Modules: ✅ core=ok, 🔐 auth_profile=manual, 🔐 credential_source=none, ⏭️ bitwarden=off, ⏭️ touchid=off, ✅ dns=on, ✅ app=on, ✅ autostart=on, ✅ patches=active, ✅ keychain=password:no/totp_seed:no
VPN disconnected
```
Emoji в human-выводе помогают быстро отличать норму, отключённый опциональный модуль и проблему:
- `✅` — модуль установлен или состояние готово;
- `⏭️` — модуль осознанно отключён;
- `⚠️` — модуль включён, но чего-то не хватает.
Значения:
| Поле | Значение |
| --- | --- |
| `core=ok` | Есть `openconnect`, `openconnect-lite` и config |
| `auth_profile=manual/saved-login/saved-totp/auto` | Как CLI будет открывать окно входа и использовать сохранённые данные |
| `credential_source=none/keychain/bitwarden` | Откуда брать сохранённые данные, если выбран режим с подстановкой |
| `bitwarden=on` | Модуль явно включён и `bw` установлен |
| `bitwarden=off` | Модуль отключён по умолчанию или через `--without-bitwarden` |
| `bitwarden=missing` | Модуль включён, но `bw` не найден |
| `touchid=on/off/missing` | Состояние Touch ID helper |
| `dns=on/missing` | Наличие DNS cleanup wrapper |
| `app=on/missing` | Установлен ли `~/Applications/LemanaVPN.app` |
| `autostart=on/off` | Есть ли LaunchAgent для запуска приложения при логине |
| `patches=active/pending` | Применены ли runtime-патчи `openconnect-lite` |
| `keychain=password:yes/totp_seed:yes` | Есть ли LDAP-пароль и постоянный код настройки 2FA в Keychain |
JSON-режим тоже отдаёт модульный статус:
```sh
vpn --status --json
```
## Интерактивная установка
Перед установкой `install.sh` печатает текущее состояние:
```text
Detected state:
openconnect: yes
pipx: yes
openconnect-lite: yes
Bitwarden CLI: no
Touch ID helper: no
DNS cleanup: no
sudoers: no/no
shell aliases: no
Swift: yes
Menu Bar app: no
LaunchAgent: no
Keychain password: no
Keychain 2FA setup: no
```
Если доступен терминал, скрипт ведёт установку вопросами:
- оставить ручной вход, сохранить только логин/пароль или настроить подстановку сохранённых данных;
- поставить Bitwarden CLI только если выбран Bitwarden;
- собрать Touch ID helper только если выбран Bitwarden;
- собрать ли Swift Menu Bar app, если `~/Applications/LemanaVPN.app` не найден;
- включить ли автозапуск Menu Bar app при логине;
- настроить ли sudoers для `openconnect` и DNS cleanup;
- добавить ли алиасы в `~/.zshrc`.
Флаги имеют приоритет над вопросами и нужны в основном для CI, диагностики или повторяемых unattended installs. Например, `--auth-profile saved-login --configure-login` сразу выберет сохранение логина/пароля, а `--no-shell` не будет предлагать алиасы.
В неинтерактивной среде скрипт не задаёт вопросов и использует выбранные флаги/дефолты. Для CI или повторяемой установки лучше явно указывать `--non-interactive`.
## Логи установщика и удаления
Установщик и uninstall script печатают пошаговый лог с emoji, цветом в интерактивном терминале и коротким пояснением, зачем нужен каждый шаг. Например, перед сборкой Swift-приложения установщик отдельно пишет, что `swift build` может занять время и что строки компилятора вида `[2/5] Write swift-version...` являются нормальным выводом. При удалении отдельно показывается откат runtime-патчей `openconnect-lite`, удаление sudoers/DNS wrapper, приложения, aliases и config.
Отключить цвет:
```sh
NO_COLOR=1 sh install.sh
NO_COLOR=1 sh uninstall.sh
```
Отключить emoji:
```sh
LEMANA_VPN_NO_EMOJI=1 sh install.sh
LEMANA_VPN_NO_EMOJI=1 sh uninstall.sh
```
## Модули
### Core
Всегда устанавливается:
- `openconnect` через Homebrew;
- `pipx` через Homebrew;
- `openconnect-lite` через `pipx`;
- CLI `vpn-lemanapro.sh`;
- `openconnect-lite` config;
- DNS cleanup wrapper.
### Как работают режимы входа
Keychain entries:
- service `openconnect-lite`, account `<LDAP username>` — корпоративный LDAP/domain пароль;
- service `openconnect-lite`, account `totp/<LDAP username>` — постоянный код настройки 2FA, нужен только для подстановки одноразового кода и auto mode.
`auth_profile=manual` — режим по умолчанию. CLI не проверяет Keychain и Bitwarden, открывает видимое окно входа и ничего не подставляет. Логин, пароль и одноразовый код вводятся вручную.
`auth_profile=saved-login` — CLI берёт из Keychain только LDAP пароль и подставляет логин/пароль в видимое окно входа. Одноразовый код вводится руками. Настройка:
```sh
vpn --configure-login
```
`auth_profile=saved-totp` — CLI берёт LDAP пароль и постоянный код настройки 2FA из Keychain, подставляет поля в видимое окно входа, но submit не нажимает. Настройка:
```sh
vpn --configure-keychain
```
`auth_profile=auto` — старый автоматический путь: скрытый браузер, подстановка всех полей и submit. Запускается только явно:
```sh
vpn-auto
vpn --auto
```
`credential_source=bitwarden` — это sync-provider. CLI открывает Bitwarden vault, читает item `LM LDAP`, берёт из него LDAP password и постоянный код настройки 2FA, нормализует `otpauth://...secret=...` если нужно, затем записывает оба секрета в macOS Keychain. Bitwarden не включён по умолчанию.
Важно: Lemana VPN не хранит и не принимает текущий 6-значный код как постоянную настройку. Такой код живёт около 30 секунд. Для автоматического SSO нужен именно постоянный secret: raw Base32 или `otpauth://totp/...?...secret=BASE32`.
Если запуск идёт из menu-bar app, интерактивного terminal prompt нет. Поэтому настройку сохранённого логина или полного набора данных нужно один раз выполнить в Terminal.
### Bitwarden
Отключён по умолчанию. Его стоит включать только если у пользователя уже есть запись Bitwarden с корпоративным LDAP-паролем и постоянным кодом настройки 2FA.
Постоянный код настройки 2FA — это секрет, который обычно показывается при ручной настройке authenticator. Сам одноразовый код меняется каждые 30 секунд и генерируется `openconnect-lite` в момент входа.
Если vault заблокирован и Touch ID helper не смог его открыть, CLI спросит `Bitwarden master password`. Это пароль от хранилища Bitwarden, а не корпоративный LDAP-пароль. Он нужен только чтобы достать LDAP password и постоянный код настройки 2FA из item `LM LDAP` и переложить их в macOS Keychain.
Включить Bitwarden можно через продвинутый путь в installer wizard или флагами:
То же самое можно задать флагами для неинтерактивной установки:
```sh
sh install.sh --non-interactive --with-bitwarden --auth-profile saved-totp
```
Для Keychain-only режима без Bitwarden используйте:
```sh
sh install.sh --non-interactive --auth-profile saved-login --configure-login
```
Если сохранённые данные уже лежат в Keychain, подключение без Bitwarden не будет спрашивать пароль заново. Для режима `saved-login` одноразовый код всё равно вводится вручную.
### Если Bitwarden нет
Bitwarden не обязателен. Без него установка по умолчанию работает в полностью ручном режиме: откроется окно входа, а логин, пароль и одноразовый код вводятся руками.
Интерактивная установка:
```sh
curl -fsSL https://git.dokops.ru/dokril/lemana-vpn/raw/branch/main/install.sh | sh
```
В первом вопросе можно просто нажать Enter:
```text
1) Вводить всё вручную (рекомендуется)
```
Это встроенный бесплатный путь: не нужен Bitwarden account, платный Bitwarden TOTP или внешний password manager.
Если хочется, чтобы логин и пароль подставлялись сами, выбери пункт `2` или выполни позже:
```sh
vpn --configure-login
```
Что понадобится:
- LDAP username;
- LDAP password: корпоративный LDAP/domain пароль, не мастер-пароль Bitwarden;
- одноразовый код из корпоративной 2FA при каждом подключении.
Постоянный код настройки 2FA нужен только для `vpn --configure-keychain`, `saved-totp` и `vpn-auto`.
Не вставляй текущий 6-значный authenticator code в `vpn --configure-keychain`. Lemana VPN сохраняет в Keychain постоянный код настройки 2FA, а `openconnect-lite` по нему генерирует свежие одноразовые коды во время каждого SSO login.
Если запуск идёт из `LemanaVPN.app`, приложение не может безопасно показать интерактивный terminal prompt для сохранения пароля. Настройку saved-login или saved-totp нужно один раз выполнить в Terminal:
```sh
vpn --configure-login
vpn --configure-keychain
```
Если secret есть только в QR-коде:
1. Открой QR-код в приложении/на портале, где настраивалась 2FA.
2. Найди режим ручной настройки, где показывается secret.
3. Если доступен только QR, его нужно расшифровать любым локальным QR-сканером и взять параметр `secret`.
4. Вставь secret в prompt `2FA setup secret BASE32`.
Если постоянный 2FA secret получить нельзя, автоматический headless-вход невозможен: `openconnect-lite` не сможет сам генерировать свежий одноразовый код на каждом входе.
### Touch ID
Отключён по умолчанию и нужен только для Bitwarden-пути. Если пользователь выбрал Bitwarden, установщик может собрать `keychain-fingerprint` из `https://github.com/dss99911/keychain-fingerprint.git` и положить бинарник в `~/bin/keychain-fingerprint`.
Важно: этот helper показывает системный Touch ID prompt перед чтением мастер-пароля Bitwarden, но это не аппаратный Keychain ACL. Это удобный локальный гейт поверх записи Keychain.
Отключить:
```sh
sh install.sh --without-touchid
```
### Swift Menu Bar app
Включён по умолчанию. Установщик собирает Swift-приложение из исходников в репозитории и кладёт bundle в:
```sh
~/Applications/LemanaVPN.app
```
Приложение живёт в macOS status bar, запускает `~/bin/vpn-lemanapro.sh --json`, показывает состояние VPN, IP, оставшееся время сессии, health-check тоннеля и строку состояния модулей.
В отключённом состоянии меню показывает текущий режим установки:
- `Режим: полностью вручную` — доступна только обычная кнопка `Подключить`; логин, пароль и одноразовый код вводятся руками.
- `Режим: логин и пароль сохранены` — доступен вход с подстановкой логина/пароля и ручной ввод одноразового кода.
- `Режим: сохранённые данные без автоотправки` — доступны сохранённые данные, ручной fallback и явный automatic mode.
- `Режим: автоматический вход` — обычная кнопка `Подключить` запускает настроенный auto-flow, а ручные fallback-кнопки остаются доступны.
Кнопки, для которых текущая установка не готова, не показываются. Например, если не сохранён постоянный код настройки 2FA, в меню не будет автоматического входа. Если рядом с режимом есть подсказка `Настройте в Terminal`, выполните указанную команду в Terminal и нажмите `Обновить статус модулей`.
Строка состояния модулей в меню приложения использует те же маркеры, что CLI: `✅` для готового модуля, `⏭️` для отключённого опционального модуля и `⚠️` для проблемы. Иконка строки тоже меняется: `checkmark.circle` для полностью готового набора и `exclamationmark.triangle` для неполной установки.
Если в меню видно `modules unavailable: update CLI`, значит запущенное приложение обращается к старому `~/bin/vpn-lemanapro.sh`, который ещё не умеет отдавать модульный статус. Повтори установку через `curl`; установщик обновит CLI и перезапустит уже запущенное `LemanaVPN.app`.
Для сборки нужен Swift 5.9+ из Xcode Command Line Tools:
```sh
xcode-select --install
```
Отключить установку приложения:
```sh
curl -fsSL https://git.dokops.ru/dokril/lemana-vpn/raw/branch/main/install.sh | sh -s -- --without-app
```
Оставить приложение, но отключить автозапуск:
```sh
curl -fsSL https://git.dokops.ru/dokril/lemana-vpn/raw/branch/main/install.sh | sh -s -- --without-autostart
```
Ручной запуск:
Открыть приложение:
```sh
open ~/Applications/LemanaVPN.app
```
## Использование
## Режимы Установки
```sh
vpn # подключиться в настроенном режиме; fresh install = полностью вручную
vpn-auto # явный автоматический режим: скрытый браузер, auto-fill и submit
vpn --saved-login # подставить сохранённые логин/пароль, одноразовый код руками
vpn-manual # видимый браузер, сохранённые данные auto-fill без submit
vpn-manual-full # полностью ручной режим: видимый браузер без auto-fill и submit
vpn --manual # то же самое без alias
vpn --manual-full # то же самое без alias
vpn --status # статус без нового подключения
vpn --status --json # статус в JSON
vpn-debug # видимый браузер и debug-логи
vpn --manual --debug # ручной режим с debug-логами
vpn-fix-dns # сбросить корпоративные DNS после аварийного завершения
open ~/Applications/LemanaVPN.app # открыть Swift-приложение в menu bar
Интерактивный установщик сначала предлагает способ входа, затем источник для автоматического режима:
```text
Какой режим использовать?
1) Ручной вход: запомнить логин и пароль на этом Mac (рекомендуется)
Логин и пароль подставятся из macOS Keychain. Одноразовый код вводится руками каждый раз.
2) Автоматический вход
Пароль и код подставляются автоматически, форма отправляется без ручного ввода.
Источник данных: 1) Ключ на этом Mac, 2) Bitwarden [1/2, Enter=1]
```
Режимы подключения:
- `manual` — режим fresh install по умолчанию. Браузер видимый, auto-fill отключён полностью, проверки сохранённых LDAP/TOTP credentials пропускаются: поля Keycloak заполняешь и отправляешь сам.
- `saved-login` — браузер видимый, логин/пароль берутся из Keychain, одноразовый код вводится руками, submit не нажимается.
- `saved-totp` / `--manual` — браузер видимый, LDAP-пароль и код 2FA берутся из Keychain/Bitwarden и подставляются в поля, но кнопка входа не нажимается.
- `auto` / `vpn-auto` — явный автоматический режим. Браузер скрытый, LDAP-пароль и код 2FA берутся из Keychain/Bitwarden, форма заполняется и отправляется автоматически.
- `--manual-sso` оставлен как совместимый alias для `--manual`.
- `--manual-no-autofill` оставлен как совместимый alias для `--manual-full`.
Первый запуск с Bitwarden, если он выбран явно:
1. CLI проверит `bw`.
2. Если vault locked, попросит мастер-пароль.
3. Если установлен Touch ID helper, предложит сохранить мастер-пароль за Touch ID prompt.
4. Достанет `LM LDAP`, запишет LDAP-пароль и постоянный код настройки 2FA в Keychain.
5. Запустит `openconnect-lite` и пройдёт Keycloak SSO.
## Настройка
Файл `~/.config/lemana-vpn/env`:
Неинтерактивно:
```sh
LEMANA_VPN_USERNAME="<LDAP_USERNAME>"
LEMANA_VPN_AUTH_PROFILE="manual"
LEMANA_VPN_CREDENTIAL_SOURCE="none"
LEMANA_VPN_BW_ITEM="LM LDAP"
LEMANA_VPN_USE_BITWARDEN="0"
LEMANA_VPN_USE_TOUCHID="0"
LEMANA_VPN_DNS_CLEANUP="/usr/local/sbin/lemana-vpn-dns-cleanup"
sh install.sh --non-interactive --mode manual-keychain --configure-login
sh install.sh --non-interactive --mode keychain-auto --import-yandex-key
sh install.sh --non-interactive --mode bitwarden-auto
```
Для режима с сохранённым логином и паролем:
Проверить действия без изменений:
```sh
LEMANA_VPN_USERNAME="<LDAP_USERNAME>"
LEMANA_VPN_AUTH_PROFILE="saved-login"
LEMANA_VPN_CREDENTIAL_SOURCE="keychain"
LEMANA_VPN_USE_BITWARDEN="0"
LEMANA_VPN_USE_TOUCHID="0"
LEMANA_VPN_DNS_CLEANUP="/usr/local/sbin/lemana-vpn-dns-cleanup"
sh install.sh --dry-run --non-interactive --mode manual-keychain
sh install.sh --dry-run --non-interactive --mode bitwarden-auto
```
Если LDAP username не указан при установке, `vpn` спросит его при первом интерактивном запуске в Terminal и сохранит в `~/.config/lemana-vpn/env`. Для неинтерактивной установки можно указать его сразу:
## Ручной Режим
По умолчанию `manual-keychain` хранит только LDAP username/password:
- service `openconnect-lite`, account `<LDAP username>` - LDAP password;
- TOTP/current 6-digit code не сохраняется и не требуется.
Настроить или обновить логин и пароль:
```sh
curl -fsSL https://git.dokops.ru/dokril/lemana-vpn/raw/branch/main/install.sh \
| sh -s -- --username <LDAP_USERNAME>
vpn --configure-login
```
## Bitwarden item
Из меню LemanaVPN.app доступна команда `Логин и пароль…`; она открывает Terminal и запускает тот же CLI-путь, чтобы пароль вводился в нормальном TTY.
Нужна запись:
- название: `LM LDAP`;
- username: корпоративный LDAP логин;
- password: LDAP пароль;
- TOTP: `otpauth://...secret=BASE32...` или raw BASE32 secret.
Это не 6-значный одноразовый код. В Bitwarden должен лежать постоянный 2FA secret, из которого коды генерируются автоматически.
## Почему DNS wrapper, а не wildcard sudoers
Старый вариант давал `NOPASSWD` на `networksetup -setdnsservers *`. Это слишком широкое право: любой локальный процесс пользователя мог поменять DNS на произвольный сервер.
Новый вариант разрешает sudo только на `/usr/local/sbin/lemana-vpn-dns-cleanup`. Wrapper сбрасывает DNS только если текущий DNS начинается с `10.`, то есть похож на корпоративный VPN DNS.
## Runtime-патчи openconnect-lite
`openconnect-lite` работает, но для текущей macOS + Keycloak SSO цепочки ему нужны runtime-патчи. CLI применяет их перед подключением в файле:
Подключиться:
```sh
~/.local/pipx/venvs/openconnect-lite/lib/python*/site-packages/openconnect_lite/browser/webengine_process.py
vpn
```
Патчи:
Ожидаемое поведение: откроется видимое окно входа, логин и пароль подставятся, одноразовый код вводится руками, submit не нажимается автоматически.
| Патч | Что меняет | Зачем |
| --- | --- | --- |
| `minimal -> offscreen` | Меняет Qt platform mode для скрытого браузера | `minimal` падает с Qt WebEngine на macOS |
| `input/change events` | Оставляет старое прямое `value = ...`, но после него отправляет DOM events | Keycloak не реагирует на прямую запись value без событий |
| `legacy auto-fill` | Сохраняет старую рабочую схему `ApplicationWorld`, прямой `value = ...` и простой `click()` | Это ровно тот режим, на котором hidden SSO раньше стабильно проходил Keycloak |
| `URL guard` | Проверяет `location.href` через `new RegExp(...)` перед auto-fill | Qt игнорирует `@include`, без guard auto-fill может кликнуть Cisco ACS и сломать SAML |
| `auth redirect` | Читает 302 с `vpn.lemanapro.ru` без автоматического follow-redirect | Python `requests` может падать на TLS reset при открытии `/` на `sslvpna/b`, хотя для SAML нужен только конечный host |
| `manual submit gate` | Позволяет отключить только auto-click через `LEMANA_VPN_AUTOFILL_CLICK=0` | Ручной режим видит заполненную форму, но сам решает, когда нажать вход |
| `manual SSO disable` | Позволяет полностью отключить auto-fill через `vpn --manual-full` или `LEMANA_VPN_AUTOFILL_DISABLE=1` | Нужен для низкоуровневой диагностики без подстановки полей |
## Одноразовый код из Яндекс Ключа
Перед первым изменением CLI сохраняет оригинальный файл:
В Яндекс Ключе (Яндекс ID) откройте **Настройки → Резервная копия данных → Сохранить в файл** и перенесите файл на Mac. Это штатный [локальный экспорт Яндекса](https://yandex.ru/support/id/ru/authorization/twofa-copy). Он содержит секреты всех записей: не отправляйте его в чат и не загружайте в онлайн-конвертеры.
В обычном установщике есть отдельный вопрос об импорте. Запустить установку сразу с этим шагом:
```sh
~/.config/lemana-vpn/patch-backups/webengine_process.py.before-lemana-vpn
sh install.sh --mode keychain-auto --import-yandex-key
```
Откат патчей выполняет uninstall script. Если backup отсутствует, автоматического rollback нет: значит файл был уже патчен старой ручной установкой или `openconnect-lite` переустановили после backup.
## Диагностика
Проверить установку:
Для уже установленного CLI после обновления:
```sh
command -v vpn-lemanapro.sh
openconnect --version
~/.local/bin/openconnect-lite --help
sudo -n /usr/local/sbin/lemana-vpn-dns-cleanup
vpn --configure-login # если логин и пароль ещё не сохранены
vpn --import-yandex-key
vpn --status
```
Обычный `vpn` теперь пишет путь к подробному логу `openconnect-lite`:
Мастер спросит путь к файлу, покажет имена записей и предложит выбрать запись Lemana/VPN. Перед сохранением он покажет LDAP-аккаунт назначения и попросит текущий код с телефона. Проверка выполняется локально; введённый код скрыт. LDAP-логин в настройках CLI и профиле SSO должен совпадать.
После успешной проверки только выбранный секрет сохраняется в macOS Keychain: service `openconnect-lite`, account `totp/<LDAP username>`. В `env` выбирается `keychain-auto`: автоматический вход с ключом на этом Mac. Пароль остаётся прежним. Команда `vpn` подставит логин, пароль и код и отправит форму в скрытом браузере. Bitwarden для этого не нужен. Импорт не запускает VPN.
Поддерживается локальный JSON-экспорт (`key_backup` или `key_backup.txt`), TOTP / SHA1 / 6 цифр / 30 секунд. Другие алгоритмы, HOTP и собственные коды аккаунта Яндекса отклоняются. Лимиты импортёра: файл до 4 МиБ, до 1000 записей. Формат проверен по [импортёру GNOME Authenticator](https://world.pages.gitlab.gnome.org/Authenticator/src/authenticator/backup/yandex.rs.html); облачные копии и QR-коды этот мастер не читает.
Отмена или неверный код ничего не сохраняют. При ошибке включения настройки импортёр пытается вернуть прежнюю запись Keychain и сообщает, если откат не удался. Исходный файл не копируется и автоматически не удаляется. При выключении компьютера или принудительном завершении процесса между записями проверьте состояние через `vpn --status` и при необходимости повторите импорт.
Чтобы снова вводить код вручную, выберите «Вручную» в меню или выполните `vpn --set-auth=manual`. Сохранённый секрет останется в Keychain. `vpn --manual-full` по-прежнему отключает любую подстановку. Обновление сохраняет выбранную настройку; режим `bitwarden-auto` использует свой прежний путь.
## Автоматический вход через Bitwarden
`bitwarden-auto` использует Bitwarden как источник данных:
- Bitwarden item: `LM LDAP`;
- Bitwarden username - LDAP username;
- Bitwarden password - LDAP/domain password, не master password от vault;
- Bitwarden TOTP field - permanent 2FA setup secret или `otpauth://...`, не текущие 6 цифр;
- Touch ID helper может открыть master password для Bitwarden;
- runtime sync кладёт username в config, а пароль и permanent 2FA secret в macOS Keychain для `openconnect-lite`.
В этом режиме Lemana VPN не спрашивает LDAP username/password вручную. Если `~/.config/lemana-vpn/env` содержит пустой `LEMANA_VPN_USERNAME=""`, это нормально: username заполнится из `LM LDAP` после успешного Bitwarden unlock/sync.
Подключиться:
```sh
~/Library/Logs/LemanaVPN-openconnect-lite.log
vpn
```
Если после `Connecting to VPN (lemanapro)...` SSO завис или не видно, что происходит, смотри этот файл:
Разово запустить вход через Bitwarden из CLI:
```sh
vpn-auto
vpn bitwarden-auto
```
Если Bitwarden не залогинен:
```sh
bw login
```
Если автоматический вход не проходит, сначала проверь статус и логи:
```sh
vpn --status
tail -f ~/Library/Logs/LemanaVPN-openconnect-lite.log
```
В обычном режиме CLI также печатает heartbeat `Still waiting for SSO/openconnect-lite...` до успешного подключения, чтобы было понятно, что процесс живой. В `vpn-debug` дополнительно показываются raw-логи и видимый браузер.
## Статус
Если в логе повторяется один и тот же URL вида `employee.auth.lemanapro.ru/realms/employee/login-actions/authenticate`, значит hidden-браузер застрял на Keycloak до перехода в Cisco ACS. Сначала обнови и примени runtime-патчи без подключения:
Меню приложения открывается в компактном окне. Пункт **Импорт из Яндекс Ключа…** запускает существующий мастер в Terminal; приложение не читает секреты. В меню всегда видны сохранённый способ входа и источник. Стрелки с разовыми режимами у «Подключить» нет; настройки изменяются отдельными переключателями. Во время сохранения нельзя запустить подключение или второй запрос изменения.
В разделе **Значок в строке меню** можно выбрать:
- **Щит**: значок 16 pt с яркой заливкой оставшегося времени и прогрессом по контуру; вариант по умолчанию.
- **Кольцо**: круг с такой же заливкой оставшегося времени и прогрессом по контуру.
- **Часы**: щит с циферблатом и движущейся стрелкой.
- **Время**: щит и компактное оставшееся время `H:MM`.
Выбор сохраняется между запусками, в том числе ранее выбранные кольцо, часы и время. Значки зелёные, пока остаётся больше часа; янтарные в последний час, красные в последние 15 минут. Нейтральная подложка и более плотная цветная заливка делают прогресс различимым в строке меню. Точное время видно в меню и подсказке значка. Начало и конец берутся из CLI, фиксированной длительности нет. Со старым CLI начало отсчитывается от первого события подключения. Если срок неизвестен или некорректен, прогресс не выдумывается. Истечение таймера само по себе не запускает новое подключение. Значки состояний, действий и источников входа используют единый векторный набор.
Индикаторы «Щит» и «Кольцо» начинают с полной яркой заливки. По мере расхода времени яркая часть уменьшается сверху вниз, а израсходованная остаётся тусклой и видимой.
«Подключено» появляется после выдачи адреса туннеля, независимо от наличия срока сессии. CLI понимает оба формата даты OpenConnect, включая числовой часовой пояс. Срок может прийти до или после адреса; последующие сообщения не сдвигают начало сессии. До получения срока меню показывает «Срок сессии неизвестен». `vpn --status` также различает подключение и переподключение.
### Переподключение и защита входа
В автоматическом режиме (Keychain или Bitwarden) после завершения поднятого туннеля CLI проверяет остановку процессов и восстановление DNS, затем повторяет подключение через 5 секунд. При неудачных попытках до поднятия туннеля допускаются пять повторов с паузами 5, 10, 20, 40 и 60 секунд. Приложение отображает эти состояния; собственного второго цикла повторов у него нет. Ручной режим и отмена входа не запускают автоматические повторы.
Каждый этап автоматического входа отправляется один раз: переход от пароля к одноразовому коду разрешён. Видимая ошибка формы или повторный запрос уже отправленного этапа останавливает вход и все автоматические повторы. Ошибка остаётся в меню после очистки DNS. Проверьте пароль и код, затем запустите подключение вручную. Защита не вводит локальную блокировку по времени и не меняет ручную отправку формы.
Если проверка доступности обнаружит зависший туннель, приложение просит helper завершить только `openconnect` своей сессии. Запоздалый запрос не прерывает уже восстановившийся туннель. CLI остаётся работать и выполняет ту же проверку DNS перед повтором. При активном чужом VPN, незавершённых процессах или ошибке восстановления новая попытка не запускается. «Отключить» и «Выход» отменяют и ожидающий повтор.
### Восстановление DNS после перезагрузки
При запуске приложение проверяет, не остались ли настройки DNS после аварийно завершённого VPN. Перед подключением root-owned helper записывает исходные DNS сетевых сервисов и DNS, выданные VPN, в `/var/db/lemana-vpn-dns`. После обычного отключения или следующего запуска он возвращает прежние значения, включая пользовательские DNS; для автоматических настроек возвращается DHCP.
После «Отключить» root-helper отправляет `TERM` только `openconnect` зарегистрированной сессии. Проверяются пользователь, идентификатор сессии, загрузка системы, PID и время запуска; чужие VPN не останавливаются. До появления туннеля отменяются только пользовательские процессы входа этой сессии. Запись об отмене не позволяет запоздавшему дочернему процессу настроить сеть.
Меню показывает «Отключение», пока завершаются процессы VPN и проверяется восстановление DNS. Если дочерние процессы ещё выходят, CLI повторяет проверку до 30 раз с интервалом в секунду. «Выход» и Cmd+Q ждут завершения отключения и проверки DNS. Повторное нажатие не прерывает очистку. При ошибке или ожидании дольше 45 секунд приложение остаётся открытым с кнопкой «Повторить отключение»; принудительный `KILL` не применяется. Для этого должны быть обновлены вместе CLI, helper и приложение через `sh install.sh`.
Восстановление не запускает VPN, не трогает активное подключение и не сбрасывает произвольные адреса `10.x`. Проверяются процессы, сохранённая сетевая конфигурация и совпадение текущих DNS с записанными. Изменённые пользователем DNS сохраняются. Ошибка восстановления видна в меню; «Обновить статус» повторяет проверку. Пока проверка идёт или требуется исправление, «Подключить» недоступна.
```sh
curl -fsSL https://git.dokops.ru/dokril/lemana-vpn/raw/branch/main/install.sh | sh
vpn-lemanapro.sh --patch-only
vpn --repair-dns # Проверить и восстановить без подключения
vpn --repair-dns --json # Результат для приложения
```
Если лог падает раньше браузера с `SSLEOFError` / `UNEXPECTED_EOF_WHILE_READING` на `sslvpna.lemanapro.ru` или `sslvpnb.lemanapro.ru`, это ломается этап определения конечного Cisco headend. Актуальный runtime-патч `auth redirect` не открывает `/` на `sslvpna/b`, а только берёт `Location` из 302 ответа `vpn.lemanapro.ru` и продолжает штатный SAML init через POST.
`vpn-fix-dns` вызывает ту же проверку. Обычный `sh install.sh` обновляет helper, CLI и приложение; без обновления старого helper новое подключение заблокировано. Функция восстановления требует установленного правила sudoers и работает без запроса пароля.
Если SSO ломается после обновления `openconnect-lite`, запусти:
Настройки подключения, сделанного **до этого обновления**, ещё не имеют сохранённого исходного DNS. Такие остатки автоматически не удаляются: по одному адресу нельзя отличить настройки VPN от пользовательских. Если DNS уже зависли от старой версии, сначала нужно один раз вернуть обычные настройки DNS, затем подключаться обновлённой версией. После этого защита сохраняет их для будущих восстановлений. При смене сетевой конфигурации (Location) незавершённая запись сохраняется до возврата к ней; новая сессия не перезаписывает её.
```sh
vpn-debug
vpn --status
vpn --status --json
```
Если нужно самому посмотреть форму Keycloak, но оставить подстановку LDAP/TOTP:
Human output показывает активный режим:
```text
Modules: ✅ core=ok, 🔐 mode=manual-keychain, ⏭️ bitwarden=off, ⏭️ touchid=off, ...
VPN disconnected
```
JSON содержит публичный mode contract:
```json
{
"modules": {
"mode": {
"id": "manual-keychain",
"label": "Вручную",
"ready": false,
"setup_hint": "Настройте логин и пароль: vpn --configure-login"
},
"auth": {
"mode": "manual",
"source": "keychain",
"auto_source": "keychain",
"label": "Вручную",
"detail": "Логин и пароль с этого Mac, код вводится вручную",
"ready": false,
"setup_hint": "Настройте логин и пароль: vpn --configure-login"
}
}
}
```
Swift-приложение не вычисляет режим само. Оно читает `vpn --status --json` и сохраняет выбор через CLI. Унаследованные от запускающего shell переменные режима не подменяют настройку приложения.
## Что Ставится
| Путь | Назначение |
| --- | --- |
| `~/bin/vpn-lemanapro.sh` | основной CLI |
| `~/bin/uninstall-lemana-vpn.sh` | локальный uninstall helper |
| `~/bin/keychain-fingerprint` | optional Touch ID helper для Bitwarden |
| `~/Applications/LemanaVPN.app` | menu-bar приложение |
| `~/Library/LaunchAgents/ru.dokops.LemanaVPN.plist` | автозапуск приложения |
| `~/.config/lemana-vpn/env` | активный выбранный режим |
| `~/.config/openconnect-lite/config.toml` | профиль SSO и Keycloak autofill rules |
| `/usr/local/sbin/lemana-vpn-dns-cleanup` | root-owned DNS cleanup wrapper |
| `/var/db/lemana-vpn-dns` | Защищённый журнал исходных DNS и запись владельца VPN-сессии |
| `/etc/sudoers.d/lemana-vpn-openconnect` | `NOPASSWD` только для `openconnect` |
| `/etc/sudoers.d/lemana-vpn-dns` | `NOPASSWD` только для DNS cleanup wrapper |
Source-of-truth профили в репозитории:
- `templates/manual-keychain.env`
- `templates/keychain-auto.env`
- `templates/bitwarden-auto.env`
Installer рендерит один из них в `~/.config/lemana-vpn/env`.
## Настройки и журналы
Пользовательские параметры находятся в `~/.config/lemana-vpn/env`. Это shell-файл: используйте простые присваивания в кавычках, без команд. Пароли и код настройки 2FA остаются в Keychain.
| Параметр | Значение и назначение |
| --- | --- |
| `LEMANA_VPN_MODE` | `manual-keychain`, `keychain-auto` или `bitwarden-auto` |
| `LEMANA_VPN_BW_ITEM` | Имя записи Bitwarden, по умолчанию `LM LDAP` |
| `LEMANA_VPN_USE_TOUCHID` | `1` или `0`; используется в `bitwarden-auto` |
| `LEMANA_VPN_AUTO_SOURCE` | Последний источник автоматического входа: `keychain` или `bitwarden` |
| `LEMANA_VPN_LOG_MAX_BYTES` | Размер каждого файла журнала в байтах, по умолчанию `5242880` (5 МиБ) |
| `LEMANA_VPN_CONNECT_WAIT_SECONDS` | Интервал сообщения об ожидании SSO, по умолчанию `20` секунд |
Например, чтобы уменьшить журналы до 1 МиБ на файл, добавьте или измените строку:
```sh
vpn --manual
LEMANA_VPN_LOG_MAX_BYTES="1048576"
```
В этом режиме браузер видимый, `openconnect-lite` заполняет поля из Keychain/Bitwarden, но не нажимает submit.
Лимит — целое число от `1` до `999999999`. CLI применяет его при следующем запуске; приложение получает его из CLI при обновлении статуса. У каждого журнала в `~/Library/Logs` остаются текущий файл и один предыдущий `.1`: `LemanaVPN.log` и `LemanaVPN-openconnect-lite.log`. По умолчанию это до 20 МиБ суммарно. Ограничение действует при каждой записи, в том числе внутри долгого сеанса; от старого большого файла сохраняется только ограниченный хвост. Само изменение исходников существующие журналы не очищает — нужен обновлённый CLI и приложение.
Если нужно проверить SSO полностью вручную, без подстановки LDAP-пароля и TOTP:
Обновление сохраняет выбранный режим, логин, имя записи Bitwarden, Touch ID и дополнительные параметры в `env`. Флаги установщика имеют приоритет над переменными окружения, затем используются сохранённые значения и значения по умолчанию. Для CLI явно заданные переменные окружения режима, логина, Bitwarden, Touch ID и лимита журнала также имеют приоритет над `env`.
Существующий `~/.config/openconnect-lite/config.toml` сохраняет адрес, профиль и правила SSO; установщик обновляет только имя пользователя в `[credentials]`. Изменения шаблона SSO при обновлении автоматически не заменяют ваш профиль. Для логина и пароля используйте `vpn --configure-login`.
## Сохранение способа входа из CLI
```sh
vpn --set-auth=manual # ручной ввод кода
vpn --set-auth=keychain # автоматический вход с ключом на этом Mac
vpn --set-auth=bitwarden # автоматический вход через Bitwarden
vpn --set-auth=auto # последний выбранный автоматический источник
```
Команды только сохраняют настройку. Пароль и секреты не считываются и не изменяются, подключение не начинается. Готовность выбранного источника и нужный шаг настройки видны в `vpn --status --json`.
Импорт и синхронизация Bitwarden используют существующую запись Keychain `totp/<LDAP username>`. Поэтому название «Ключ на этом Mac» обозначает место хранения, а не гарантированное происхождение ключа.
## Совместимость
Ранее сохранённое сочетание `manual-keychain` + `LEMANA_VPN_LOCAL_OTP=1` теперь означает **полный автоматический вход** и при обновлении переводится в `keychain-auto`. Для ручной отправки и ввода кода выберите «Вручную» или `vpn --set-auth=manual`. Явные `vpn --manual` и `vpn --manual-full` принудительно отключают автоматический код независимо от сохранённого режима.
Старые команды оставлены как shims, чтобы существующие shell привычки не ломались:
```sh
vpn-auto # bitwarden-auto
vpn --auto # bitwarden-auto
vpn-manual # manual-keychain
vpn --manual # manual-keychain
vpn --saved-login # manual-keychain
```
Диагностический fallback без подстановки полей остаётся, но это не отдельный режим установки:
```sh
vpn --manual-full
vpn-manual-full
```
Этот режим не требует заранее сохранённого LDAP username/password/TOTP и не запускает Bitwarden/Keychain preflight.
Он нужен только когда надо проверить, что проблема не в autofill.
Если установка падает на строке `install: /usr/local/sbin/...: No such file or directory`, значит на машине не было `/usr/local/sbin`. Актуальный `install.sh` создаёт эту директорию сам; достаточно повторить установку свежей командой `curl`.
## Обновление
CLI перед подключением патчит `openconnect-lite`:
Из checkout:
- `minimal` -> `offscreen`, чтобы Qt WebEngine не падал на macOS;
- добавляет `input` и `change` events для Keycloak auto-fill, сохраняя старое прямое присваивание `value = ...`;
- оставляет auto-fill в старом `ApplicationWorld` и не добавляет stateful click guards/native setters;
- добавляет URL guard, чтобы auto-fill не кликал submit на Cisco ACS;
- добавляет auth redirect patch, чтобы Python не падал на TLS reset при follow-redirect к `sslvpna/b`;
- добавляет manual submit gate для видимой ручной диагностики с auto-fill, но без auto-submit.
```sh
sh install.sh
vpn --status
open ~/Applications/LemanaVPN.app
```
Удалённо:
```sh
curl -fsSL https://git.dokops.ru/dokril/lemana-vpn/raw/branch/main/install.sh | sh
```
После изменения Swift app важно убедиться, что установленный бинарь обновился:
```sh
shasum app/.build/release/LemanaVPN ~/Applications/LemanaVPN.app/Contents/MacOS/LemanaVPN
strings ~/Applications/LemanaVPN.app/Contents/MacOS/LemanaVPN | rg 'manual-keychain|bitwarden-auto|import-yandex-key'
```
## Удаление
Рекомендуемый способ:
```sh
curl -fsSL https://git.dokops.ru/dokril/lemana-vpn/raw/branch/main/uninstall.sh | sh
~/bin/uninstall-lemana-vpn.sh
```
Или локально:
Посмотреть действия без удаления:
```sh
uninstall-lemana-vpn.sh
~/bin/uninstall-lemana-vpn.sh --dry-run
```
Что делает uninstall:
## Разработка И Проверка
- восстанавливает `openconnect-lite` из backup, если backup есть;
- удаляет `vpn-lemanapro.sh` и `uninstall-lemana-vpn.sh`;
- удаляет sudoers rules и DNS cleanup wrapper;
- удаляет блок `lemana-vpn` из `~/.zshrc`;
- удаляет `~/.config/openconnect-lite/config.toml`;
- останавливает уже запущенный процесс `LemanaVPN`, удаляет `~/Applications/LemanaVPN.app` и LaunchAgent автозапуска;
- удаляет `~/.config/lemana-vpn`, если не передан `--keep-config`.
Опциональные режимы:
Для проверок нужен Python 3.11+ (`tomllib`), для изолированного исполнения JavaScript автозаполнения — Node.js. Браузер и настоящее подключение тесты не запускают; Node.js самому VPN не нужен.
```sh
uninstall-lemana-vpn.sh --dry-run
uninstall-lemana-vpn.sh --keep-config
uninstall-lemana-vpn.sh --keep-app
uninstall-lemana-vpn.sh --remove-keychain
uninstall-lemana-vpn.sh --remove-touchid-helper
uninstall-lemana-vpn.sh --remove-openconnect-lite
sh -n install.sh
sh -n uninstall.sh
bash -n bin/vpn-lemanapro.sh
sh -n tests/smoke.sh
tests/smoke.sh
python3 tests/configuration.py
python3 tests/logging.py
python3 tests/menu.py
python3 tests/dns_cli.py
python3 tests/dns_recovery.py
python3 tests/session_stop.py
python3 tests/connection_runtime.py
python3 tests/auth_retry.py
python3 tests/auth_settings.py
"${LEMANA_VPN_OC_PYTHON:-$HOME/.local/pipx/venvs/openconnect-lite/bin/python}" tests/yandex_import.py
swift build -c release --package-path app
git diff --check
```
Ручной вариант, если нужен полный контроль:
Live VPN проверяется только после статуса:
```sh
rm -f ~/bin/vpn-lemanapro.sh ~/bin/uninstall-lemana-vpn.sh
rm -rf ~/.config/lemana-vpn
rm -f ~/.config/openconnect-lite/config.toml
sudo rm -f /usr/local/sbin/lemana-vpn-dns-cleanup
sudo rm -f /etc/sudoers.d/lemana-vpn-openconnect /etc/sudoers.d/lemana-vpn-dns
vpn --status
```
Из `~/.zshrc` удалить блок:
Если VPN уже connected, connecting или reconnecting, не запускай вторую сессию. Смотри логи:
```sh
# >>> lemana-vpn
...
# <<< lemana-vpn
tail -f ~/Library/Logs/LemanaVPN-openconnect-lite.log
tail -f ~/Library/Logs/LemanaVPN.log
```
+38
View File
@@ -0,0 +1,38 @@
import Foundation
/// One current file and one bounded archive. The owning process serializes writes.
struct BoundedLog {
let url: URL
var maxBytes = 5 * 1024 * 1024
func append(_ line: String) throws {
let files = FileManager.default
try files.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
if !files.fileExists(atPath: url.path) {
guard files.createFile(atPath: url.path, contents: nil, attributes: [.posixPermissions: 0o600]) else {
throw CocoaError(.fileWriteUnknown)
}
}
try files.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
let handle = try FileHandle(forUpdating: url)
defer { try? handle.close() }
let size = try handle.seekToEnd()
let data = Data(line.utf8).suffix(maxBytes)
let archive = url.appendingPathExtension("1")
var archiveSize: UInt64 = 0
if files.fileExists(atPath: archive.path) {
try files.setAttributes([.posixPermissions: 0o600], ofItemAtPath: archive.path)
archiveSize = (try files.attributesOfItem(atPath: archive.path)[.size] as? NSNumber)?.uint64Value ?? 0
}
if size + UInt64(data.count) > UInt64(maxBytes) || archiveSize > UInt64(maxBytes) {
try handle.seek(toOffset: size > UInt64(maxBytes) ? size - UInt64(maxBytes) : 0)
let tail = try handle.read(upToCount: maxBytes) ?? Data()
guard files.createFile(atPath: archive.path, contents: tail, attributes: [.posixPermissions: 0o600]) else {
throw CocoaError(.fileWriteUnknown)
}
try handle.truncate(atOffset: 0)
try handle.seek(toOffset: 0)
}
try handle.write(contentsOf: data)
}
}
+333 -125
View File
@@ -3,190 +3,398 @@ import SwiftUI
@main
struct LemanaVPNApp: App {
@StateObject private var vpnManager = VPNManager()
@NSApplicationDelegateAdaptor(LemanaAppDelegate.self) private var appDelegate
@AppStorage("menuBarIndicatorStyle") private var indicatorStyle = VPNIndicatorStyle.shield
private var vpnManager: VPNManager { appDelegate.vpnManager }
var body: some Scene {
MenuBarExtra {
VPNMenuView(vpnManager: vpnManager)
VPNMenuView(vpnManager: vpnManager, indicatorStyle: $indicatorStyle)
} label: {
StatusBarLabel(vpnManager: vpnManager)
StatusBarLabel(vpnManager: vpnManager, style: indicatorStyle)
}
.menuBarExtraStyle(.window)
}
}
@MainActor
final class LemanaAppDelegate: NSObject, NSApplicationDelegate {
let vpnManager = VPNManager()
private var terminationPending = false
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
guard vpnManager.needsDisconnectBeforeQuit else { return .terminateNow }
if !terminationPending {
terminationPending = true
vpnManager.quit { [weak self] success in
self?.terminationPending = false
sender.reply(toApplicationShouldTerminate: success)
}
}
return .terminateLater
}
}
private extension SessionTiming.Urgency {
var color: NSColor {
switch self {
case .normal: return .systemGreen
case .warning: return .systemOrange
case .critical: return .systemRed
case .unknown: return .secondaryLabelColor
}
}
}
private extension VPNManager {
var statusIcon: VPNIcon {
if state == .disconnecting { return .connecting }
if isCheckingConnection { return .connecting }
if connectionCheckFailed { return .setup }
if isExternallyActive { return .unknown }
switch state {
case .disconnected:
if moduleStatusSystemImage == "exclamationmark.triangle" { return .setup }
if moduleStatusSystemImage == "questionmark.circle" { return .unknown }
return .off
case .unlocking: return .unlock
case .connecting: return .connecting
case .disconnecting: return .connecting
case .connected:
if !tunnelHealthy { return .network }
return sessionTiming.remaining(at: Date()) == 0 ? .expired : .connected
case .reconnecting: return .reconnect
case .error: return .error
}
}
}
private struct VPNIconView: View {
@Environment(\.colorScheme) private var colorScheme
let icon: VPNIcon
var body: some View {
Image(nsImage: VPNIcons.image(icon, dark: colorScheme == .dark))
.frame(width: 16, height: 16)
.accessibilityHidden(true)
}
}
struct StatusBarLabel: View {
@ObservedObject var vpnManager: VPNManager
@Environment(\.colorScheme) private var colorScheme
let style: VPNIndicatorStyle
var body: some View {
HStack(spacing: 4) {
Image(systemName: iconName)
if !vpnManager.statusBarTitle.isEmpty {
Text(vpnManager.statusBarTitle)
.monospacedDigit()
if case .connected = vpnManager.state, vpnManager.tunnelHealthy {
Image(nsImage: VPNIcons.sessionImage(
style: style,
progress: vpnManager.sessionTiming.progress(at: Date()),
urgency: vpnManager.sessionTiming.urgency(at: Date()),
dark: colorScheme == .dark
))
.renderingMode(.original)
.frame(width: 16, height: 16)
if style == .time, !vpnManager.remainingTime.isEmpty {
Text(vpnManager.remainingTime)
.font(.system(size: 11, weight: .medium, design: .monospaced))
.fixedSize()
}
} else {
VPNIconView(icon: vpnManager.statusIcon)
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityText)
.help(accessibilityText)
}
private var iconName: String {
private var accessibilityText: String {
if vpnManager.state == .disconnecting { return "Lemana VPN отключается и восстанавливает DNS" }
if vpnManager.isCheckingConnection || vpnManager.connectionCheckFailed || vpnManager.isExternallyActive,
let message = vpnManager.connectionCheckMessage { return "Lemana VPN: \(message)" }
switch vpnManager.state {
case .disconnected:
if vpnManager.moduleStatusSystemImage == "exclamationmark.triangle" {
return "exclamationmark.shield"
}
if vpnManager.moduleStatusSystemImage == "questionmark.circle" {
return "questionmark.circle"
}
return "shield.slash"
case .unlocking, .connecting:
return "shield.lefthalf.filled"
case .connected:
return vpnManager.tunnelHealthy ? "checkmark.shield" : "exclamationmark.shield"
case .reconnecting:
return "arrow.trianglehead.2.clockwise"
case .error:
return "exclamationmark.shield"
return "Lemana VPN: \(vpnManager.tunnelHealthy ? "подключён" : "проблема соединения"), \(vpnManager.sessionTiming.remainingLabel(at: Date()).lowercased())"
case .disconnected: return "Lemana VPN отключён"
case .connecting: return "Lemana VPN подключается"
case .disconnecting: return "Lemana VPN отключается и восстанавливает DNS"
case .unlocking: return "Lemana VPN ожидает вход в Bitwarden"
case .reconnecting: return "Lemana VPN переподключается"
case .error(let message): return "Lemana VPN: \(message)"
}
}
}
struct VPNMenuView: View {
@ObservedObject var vpnManager: VPNManager
@Binding var indicatorStyle: VPNIndicatorStyle
var body: some View {
switch vpnManager.state {
case .disconnected:
disconnectedMenu
case .unlocking(let tier):
unlockingMenu(tier: tier)
case .connecting:
connectingMenu
case .connected(let ip, _, let remainingSec):
connectedMenu(ip: ip, remainingSec: remainingSec)
case .reconnecting(let attempt):
reconnectingMenu(attempt: attempt)
case .error(let message):
errorMenu(message: message)
VStack(alignment: .leading, spacing: 0) {
HStack {
Text("Lemana VPN").fontWeight(.semibold)
Spacer(minLength: 8)
HStack(spacing: 5) {
VPNIconView(icon: vpnManager.statusIcon)
Text(statusTitle).font(.system(size: 11)).foregroundColor(statusColor)
}
}
.padding(.bottom, 10)
Divider()
Label(vpnManager.moduleSummary, systemImage: vpnManager.moduleStatusSystemImage)
.disabled(true)
Button("Обновить статус модулей") {
vpnManager.refreshStatus()
connectionDetails.padding(.bottom, 9)
connectionActions.padding(.bottom, 3)
separator
authenticationSettings
if let hint = vpnManager.installSetupHint { warning(hint) }
ForEach(moduleWarnings, id: \.self) { warning($0) }
if let error = additionalError { warning(error) }
separator
menuButton("Импорт из Яндекс Ключа…", image: .importKey) {
vpnManager.importYandexKey()
}
Button("Изменить логин и пароль") {
vpnManager.configureLogin()
.help("Мастер импорта откроется в Terminal")
.disabled(!vpnManager.canConfigureCredentials)
menuButton("Логин и пароль…", image: .password) { vpnManager.configureLogin() }
.disabled(!vpnManager.canConfigureCredentials)
separator
Text("Значок в строке меню")
.font(.system(size: 11))
.foregroundColor(.secondary)
.padding(.bottom, 6)
Picker("Значок в строке меню", selection: $indicatorStyle) {
ForEach(VPNIndicatorStyle.allCases) { style in Text(style.title).tag(style) }
}
Divider()
Button("Открыть логи") {
let logPath = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Logs/LemanaVPN.log")
NSWorkspace.shared.open(logPath)
.labelsHidden()
.pickerStyle(.segmented)
.controlSize(.small)
.padding(.bottom, 3)
separator
menuButton("Открыть логи", image: .logs) {
let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Logs/LemanaVPN.log")
NSWorkspace.shared.open(url)
}
Button("Выход") {
vpnManager.quit()
HStack {
Button { vpnManager.refreshConnectionStatus() } label: {
iconLabel("Обновить статус", icon: .refresh)
}
.disabled(vpnManager.isCheckingConnection || vpnManager.isSavingAuth)
Spacer()
Button {
NSApplication.shared.terminate(nil)
} label: {
iconLabel("Выход", icon: .quit)
}
.keyboardShortcut("q")
}
.buttonStyle(.plain)
.font(.system(size: 11))
.foregroundColor(.secondary)
.padding(.top, 7)
}
.font(.system(size: 13))
.padding(12)
.frame(width: 298)
.background(Color(nsColor: .windowBackgroundColor))
.onAppear { vpnManager.refreshStatus() }
}
private var disconnectedMenu: some View {
Group {
Label("VPN отключён", systemImage: "circle")
.disabled(true)
Label(vpnManager.installModeLabel, systemImage: "slider.horizontal.3")
.disabled(true)
if let setupHint = vpnManager.installSetupHint {
Label(setupHint, systemImage: "info.circle")
.disabled(true)
@ViewBuilder private var authenticationSettings: some View {
if let auth = vpnManager.moduleStatus?.auth {
Text("Способ входа")
.font(.system(size: 11)).foregroundColor(.secondary).padding(.bottom, 6)
Picker("Способ входа", selection: Binding(
get: { auth.mode },
set: { vpnManager.setAuth($0) }
)) {
iconLabel("Вручную", icon: .manual).tag("manual")
iconLabel("Автоматически", icon: .auto).tag("auto")
}
Divider()
ForEach(vpnManager.availableLaunchModes) { mode in
Button(mode.menuTitle) {
vpnManager.connect(mode: mode)
.pickerStyle(.segmented)
.labelsHidden()
.controlSize(.small)
.disabled(!vpnManager.canSetAuth)
if auth.mode == "auto" {
Picker("Источник", selection: Binding(
get: { auth.auto_source },
set: { vpnManager.setAuth($0) }
)) {
iconLabel("Ключ на этом Mac", icon: .local).tag("keychain")
iconLabel("Bitwarden", icon: .vault).tag("bitwarden")
}
.pickerStyle(.menu)
.controlSize(.small)
.padding(.top, 7)
.disabled(!vpnManager.canSetAuth)
}
Text(vpnManager.isSavingAuth ? "Сохраняем настройку…" : auth.detail)
.font(.system(size: 11)).foregroundColor(.secondary).fixedSize(horizontal: false, vertical: true)
.padding(.top, 6)
if vpnManager.isRunning {
Text("Изменения применятся при следующем подключении")
.font(.system(size: 11)).foregroundColor(.secondary).padding(.top, 4)
}
} else {
Text(vpnManager.installModeLabel).font(.system(size: 11)).foregroundColor(.secondary)
if !vpnManager.isCheckingConnection {
warning("Обновите CLI Lemana VPN, чтобы настраивать способ входа здесь.")
}
}
}
private func unlockingMenu(tier: String) -> some View {
Group {
Label("Bitwarden: \(tier)...", systemImage: "key")
.disabled(true)
Divider()
Button("Отменить") {
vpnManager.disconnect()
@ViewBuilder private var connectionDetails: some View {
switch vpnManager.state {
case .connected(let ip, _, _):
HStack(alignment: .firstTextBaseline) {
Text(vpnManager.sessionTiming.remainingLabel(at: Date())).monospacedDigit()
Spacer(minLength: 6)
if let expiry = vpnManager.sessionTiming.expiresAt, vpnManager.sessionTiming.remaining(at: Date()) != nil {
Text("до \(expiry.formatted(date: .omitted, time: .shortened))")
.font(.system(size: 11)).foregroundColor(.secondary)
}
}
}
private var connectingMenu: some View {
Group {
Label("Подключение...", systemImage: "circle.dotted")
.disabled(true)
Divider()
Button("Отменить") {
vpnManager.disconnect()
}
}
}
private func connectedMenu(ip: String, remainingSec: Int) -> some View {
let hours = remainingSec / 3600
let mins = (remainingSec % 3600) / 60
return Group {
Label("VPN подключён", systemImage: "circle.fill")
.disabled(true)
if !vpnManager.tunnelHealthy {
Label("Проблемы с подключением", systemImage: "exclamationmark.triangle")
.disabled(true)
}
if !ip.isEmpty {
Text("IP: \(ip)")
.foregroundColor(.secondary)
}
if remainingSec > 0 {
Text("Сессия: \(hours)ч \(mins)м")
.foregroundColor(.secondary)
}
Divider()
Button("Копировать IP") {
HStack(spacing: 7) {
Text(ip).font(.system(size: 11)).foregroundColor(.secondary)
Button {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(ip, forType: .string)
} label: {
VPNIconView(icon: .copy)
}
.buttonStyle(.plain)
.keyboardShortcut("c", modifiers: [.command])
.disabled(ip.isEmpty)
Button("Отключить") {
vpnManager.disconnect()
.accessibilityLabel("Копировать IP")
.help("Копировать IP")
}
.padding(.top, 4)
}
if !vpnManager.tunnelHealthy { warning("Проблема соединения: VPN не отвечает") }
case .disconnected:
Text(vpnManager.connectionCheckMessage ?? "Готов к подключению")
.font(.system(size: 11)).foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
case .disconnecting:
Text("Завершаем VPN и восстанавливаем DNS…").foregroundColor(.secondary)
case .unlocking:
Text("Подтвердите вход в Bitwarden").foregroundColor(.secondary)
case .connecting:
Text("Ожидаем VPN-соединение").foregroundColor(.secondary)
case .reconnecting(let attempt):
Text("Попытка \(attempt)").foregroundColor(.secondary)
case .error(let message):
warning(message)
if let checkMessage = vpnManager.connectionCheckMessage { warning(checkMessage) }
}
}
@ViewBuilder private var connectionActions: some View {
if vpnManager.canRetryDisconnect {
Button { vpnManager.disconnect() } label: { iconLabel("Повторить отключение", icon: .disconnect).frame(maxWidth: .infinity) }
.buttonStyle(.bordered)
.controlSize(.small)
} else {
switch vpnManager.state {
case .disconnected, .error:
Button { vpnManager.connect() } label: { iconLabel("Подключить", icon: .connect).frame(maxWidth: .infinity) }
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(!vpnManager.canConnect)
case .connected:
Button { vpnManager.disconnect() } label: { iconLabel("Отключить", icon: .disconnect).frame(maxWidth: .infinity) }
.buttonStyle(.bordered)
.controlSize(.small)
.keyboardShortcut("d")
}
}
private func reconnectingMenu(attempt: Int) -> some View {
Group {
Label("Переподключение (\(attempt)/3)...", systemImage: "arrow.clockwise")
case .disconnecting:
Button {} label: { iconLabel("Отключение…", icon: .disconnect).frame(maxWidth: .infinity) }
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(true)
Divider()
Button("Отменить") {
vpnManager.disconnect()
case .unlocking, .connecting, .reconnecting:
Button { vpnManager.disconnect() } label: { iconLabel("Отменить", icon: .cancel).frame(maxWidth: .infinity) }
.buttonStyle(.bordered)
.controlSize(.small)
}
}
}
private func errorMenu(message: String) -> some View {
Group {
Label("Ошибка", systemImage: "exclamationmark.triangle")
.disabled(true)
Text(message)
private var separator: some View { Divider().padding(.vertical, 7) }
private func menuButton(_ title: String, image: VPNIcon, action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack(spacing: 9) {
VPNIconView(icon: image).foregroundColor(.secondary)
Text(title)
Spacer(minLength: 0)
}
.padding(.vertical, 6)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
private func iconLabel(_ title: String, icon: VPNIcon) -> some View {
Label { Text(title) } icon: { VPNIconView(icon: icon) }
}
private func warning(_ message: String) -> some View {
iconLabel(message, icon: .setup)
.font(.system(size: 11))
.foregroundColor(.secondary)
.lineLimit(3)
Divider()
Button("Подключить заново") {
vpnManager.connect()
.fixedSize(horizontal: false, vertical: true)
.padding(.top, 6)
}
private var additionalError: String? {
if case .error(let message) = vpnManager.state, message == vpnManager.lastError { return nil }
return vpnManager.lastError
}
private var moduleWarnings: [String] {
if vpnManager.isCheckingConnection { return [] }
guard let modules = vpnManager.moduleStatus else { return ["Не удалось проверить установку. Обновите статус."] }
var warnings: [String] = []
if !modules.core.openconnect || !modules.core.openconnect_lite || !modules.core.openconnect_lite_config {
warnings.append("Установка не завершена. Запустите установщик Lemana VPN.")
}
if modules.bitwarden.enabled && !modules.bitwarden.installed { warnings.append("Bitwarden не установлен.") }
if modules.touchid.enabled && !modules.touchid.installed { warnings.append("Touch ID не настроен.") }
if !modules.dns_cleanup.installed { warnings.append("Очистка DNS не настроена. Обновите установку.") }
if !modules.patches.active { warnings.append("Подготовка SSO завершится при следующем подключении.") }
if modules.app?.installed == false { warnings.append("Приложение не установлено. Запустите установщик Lemana VPN.") }
return warnings
}
private var statusTitle: String {
if vpnManager.state == .disconnecting { return "Отключение" }
if vpnManager.isCheckingConnection { return "Проверка" }
if vpnManager.connectionCheckFailed { return "Нужна проверка" }
if vpnManager.isExternallyActive { return "VPN ещё запущен" }
switch vpnManager.state {
case .disconnected: return "Отключён"
case .disconnecting: return "Отключение"
case .unlocking: return "Ожидаем вход"
case .connecting: return "Подключение"
case .connected:
if !vpnManager.tunnelHealthy { return "Проблема сети" }
return vpnManager.sessionTiming.remaining(at: Date()) == 0 ? "Срок истёк" : "Подключён"
case .reconnecting: return "Переподключение"
case .error: return "Ошибка"
}
}
private var statusColor: Color {
if vpnManager.connectionCheckFailed { return .orange }
switch vpnManager.state {
case .connected:
return vpnManager.tunnelHealthy ? Color(nsColor: vpnManager.sessionTiming.urgency(at: Date()).color) : .red
case .error: return .red
default: return .secondary
}
}
}
+71
View File
@@ -0,0 +1,71 @@
import Foundation
enum VPNIndicatorStyle: String, CaseIterable, Identifiable {
case shield, ring, clock, time
var id: String { rawValue }
var title: String {
switch self {
case .shield: return "Щит"
case .ring: return "Кольцо"
case .clock: return "Часы"
case .time: return "Время"
}
}
}
struct SessionTiming {
enum Urgency { case normal, warning, critical, unknown }
private(set) var startedAt: Date?
private(set) var expiresAt: Date?
private var duration: TimeInterval? {
guard let start = startedAt, let end = expiresAt, end > start else { return nil }
return end.timeIntervalSince(start)
}
mutating func observe(startedAt: String?, expires: String?, now: Date = Date()) {
let formatter = ISO8601DateFormatter()
if let start = startedAt.flatMap({ formatter.date(from: $0) }) {
// The CLI timestamp is authoritative, including after a legacy fallback.
self.startedAt = start
} else if self.startedAt == nil {
self.startedAt = now
}
if let expires = expires {
expiresAt = formatter.date(from: expires)
}
}
func remaining(at now: Date) -> TimeInterval? {
guard duration != nil, let end = expiresAt else { return nil }
return max(0, end.timeIntervalSince(now))
}
func progress(at now: Date) -> Double? {
guard let duration = duration, let start = startedAt else { return nil }
return min(1, max(0, now.timeIntervalSince(start) / duration))
}
func urgency(at now: Date) -> Urgency {
guard let remaining = remaining(at: now) else { return .unknown }
if remaining <= 15 * 60 { return .critical }
if remaining <= 60 * 60 { return .warning }
return .normal
}
func compactRemaining(at now: Date) -> String? {
guard let seconds = remaining(at: now) else { return nil }
let minutes = Int(ceil(seconds / 60))
return String(format: "%d:%02d", minutes / 60, minutes % 60)
}
func remainingLabel(at now: Date) -> String {
guard let seconds = remaining(at: now) else { return "Срок сессии неизвестен" }
if seconds <= 0 { return "Время сессии истекло" }
let minutes = Int(ceil(seconds / 60))
if minutes < 60 { return "Осталось \(minutes) мин" }
return "Осталось \(minutes / 60) ч \(minutes % 60) мин"
}
}
+215
View File
@@ -0,0 +1,215 @@
import AppKit
enum VPNIcon: CaseIterable {
case off, connecting, unlock, connected, reconnect, network, error, setup, unknown, noTime, expired, clock
case connect, disconnect, cancel, importKey, password, copy, logs, refresh, quit, manual, auto, local, vault
var isAction: Bool {
switch self {
case .connect, .disconnect, .cancel, .importKey, .password, .copy, .logs, .refresh, .quit, .manual, .auto, .local, .vault: return true
default: return false
}
}
}
enum VPNIcons {
// Geometry and palettes from the approved artifacts/icon-family-v1/draw_icons.py.
static let shield: CGPath = {
let p = CGMutablePath()
p.move(to: CGPoint(x: 12, y: 2))
p.addCurve(to: CGPoint(x: 20, y: 5), control1: CGPoint(x: 14.6, y: 3.7), control2: CGPoint(x: 17.3, y: 4.7))
p.addLine(to: CGPoint(x: 20, y: 11.2))
p.addCurve(to: CGPoint(x: 12, y: 22), control1: CGPoint(x: 20, y: 16.1), control2: CGPoint(x: 16.7, y: 19.6))
p.addCurve(to: CGPoint(x: 4, y: 11.2), control1: CGPoint(x: 7.3, y: 19.6), control2: CGPoint(x: 4, y: 16.1))
p.addLine(to: CGPoint(x: 4, y: 5))
p.addCurve(to: CGPoint(x: 12, y: 2), control1: CGPoint(x: 6.7, y: 4.7), control2: CGPoint(x: 9.4, y: 3.7))
p.closeSubpath()
return p
}()
struct Palette {
let ink, muted, green, amber, red: NSColor
init(dark: Bool) {
func color(_ hex: UInt32) -> NSColor {
NSColor(srgbRed: CGFloat((hex >> 16) & 255) / 255, green: CGFloat((hex >> 8) & 255) / 255, blue: CGFloat(hex & 255) / 255, alpha: 1)
}
ink = color(dark ? 0xE5EAF0 : 0x27333E)
muted = color(dark ? 0xACB7C3 : 0x657381)
green = color(dark ? 0x78CAA2 : 0x278055)
amber = color(dark ? 0xF0BB68 : 0xA56A13)
red = color(dark ? 0xF58C95 : 0xBE4149)
}
func color(_ urgency: SessionTiming.Urgency) -> NSColor {
switch urgency {
case .normal: return green
case .warning: return amber
case .critical: return red
case .unknown: return muted
}
}
}
static func image(_ icon: VPNIcon, dark: Bool) -> NSImage {
let result = image { draw(icon, in: $0, dark: dark) }
result.isTemplate = icon.isAction || [.off, .connecting, .unlock, .reconnect, .unknown, .noTime].contains(icon)
return result
}
static func sessionImage(style: VPNIndicatorStyle, progress: Double?, urgency: SessionTiming.Urgency, dark: Bool) -> NSImage {
image { drawSession(style: style, progress: progress, urgency: urgency, in: $0, dark: dark) }
}
private static func image(_ draw: @escaping (CGContext) -> Void) -> NSImage {
NSImage(size: NSSize(width: 16, height: 16), flipped: true) { _ in
guard let context = NSGraphicsContext.current?.cgContext else { return false }
context.saveGState()
context.scaleBy(x: 16 / 24, y: 16 / 24)
draw(context)
context.restoreGState()
return true
}
}
static func draw(_ icon: VPNIcon, in context: CGContext, dark: Bool) {
let palette = Palette(dark: dark)
let color: NSColor
switch icon {
case .connected, .clock: color = palette.green
case .network, .setup: color = palette.amber
case .error, .expired: color = palette.red
default: color = palette.ink
}
context.saveGState()
if !icon.isAction { stroke(shield, in: context, color: palette.ink) }
stroke(mark(icon), in: context, color: color)
context.setFillColor(color.cgColor)
if icon == .connecting {
for x in [9.0, 12.0, 15.0] { context.fillEllipse(in: CGRect(x: x - 0.7, y: 11.3, width: 1.4, height: 1.4)) }
} else if icon == .setup || icon == .unknown {
context.fillEllipse(in: CGRect(x: 11.35, y: (icon == .setup ? 15.5 : 15.9) - 0.65, width: 1.3, height: 1.3))
}
context.restoreGState()
}
static func drawSession(style: VPNIndicatorStyle, progress: Double?, urgency: SessionTiming.Urgency, in context: CGContext, dark: Bool) {
guard let value = progress, value.isFinite else {
draw(.noTime, in: context, dark: dark)
return
}
let progress = min(1, max(0, value))
let remaining = 1 - progress
let palette = Palette(dark: dark)
let accent = palette.color(urgency)
if style == .time {
stroke(shield, in: context, color: palette.ink)
stroke(mark(progress == 1 ? .expired : .connected), in: context, color: accent)
return
}
if style == .clock {
stroke(shield, in: context, color: palette.ink)
if progress == 1 {
stroke(mark(.expired), in: context, color: accent)
} else {
let hand = CGMutablePath()
hand.move(to: CGPoint(x: 12, y: 7.5))
hand.addLine(to: CGPoint(x: 12, y: 12))
let angle = progress * .pi * 2
hand.addLine(to: CGPoint(x: 12 + sin(angle) * 4.5, y: 12 - cos(angle) * 4.5))
stroke(hand, in: context, color: accent)
}
return
}
let ring = style == .ring
let shape = ring ? CGPath(ellipseIn: CGRect(x: 3, y: 3, width: 18, height: 18), transform: nil) : shield
let height: CGFloat = ring ? 18 : 20
let bottom: CGFloat = ring ? 21 : 22
context.saveGState()
context.addPath(shape)
context.clip()
context.setFillColor(palette.muted.withAlphaComponent(0.30).cgColor)
context.fill(CGRect(x: 0, y: 0, width: 24, height: 24))
context.setFillColor(accent.withAlphaComponent(0.80).cgColor)
context.fill(CGRect(x: 0, y: bottom - height * remaining, width: 24, height: height * remaining))
context.restoreGState()
stroke(shape, in: context, color: palette.muted.withAlphaComponent(ring ? 0.65 : 0.68))
if remaining > 0 {
context.saveGState()
context.setLineCap(.butt)
context.setLineWidth(1.75)
context.setLineJoin(.round)
context.setStrokeColor(accent.cgColor)
if ring {
context.addArc(center: CGPoint(x: 12, y: 12), radius: 9, startAngle: -.pi / 2, endAngle: -.pi / 2 + remaining * .pi * 2, clockwise: false)
} else {
context.setLineDash(phase: 0, lengths: [remaining * 58.04152102, 58.04152102])
context.addPath(shape)
}
context.strokePath()
context.restoreGState()
}
if progress == 1 { stroke(mark(.expired), in: context, color: palette.ink) }
}
private static func stroke(_ path: CGPath, in context: CGContext, color: NSColor) {
context.setLineWidth(1.75)
context.setLineCap(.round)
context.setLineJoin(.round)
context.setStrokeColor(color.cgColor)
context.addPath(path)
context.strokePath()
}
private static func mark(_ icon: VPNIcon) -> CGPath {
let p = CGMutablePath()
func m(_ x: CGFloat, _ y: CGFloat) { p.move(to: CGPoint(x: x, y: y)) }
func l(_ x: CGFloat, _ y: CGFloat) { p.addLine(to: CGPoint(x: x, y: y)) }
func c(_ x1: CGFloat, _ y1: CGFloat, _ x2: CGFloat, _ y2: CGFloat, _ x: CGFloat, _ y: CGFloat) {
p.addCurve(to: CGPoint(x: x, y: y), control1: CGPoint(x: x1, y: y1), control2: CGPoint(x: x2, y: y2))
}
func circle(_ x: CGFloat, _ y: CGFloat, _ r: CGFloat) { p.addEllipse(in: CGRect(x: x - r, y: y - r, width: 2 * r, height: 2 * r)) }
func rect(_ x: CGFloat, _ y: CGFloat, _ w: CGFloat, _ h: CGFloat, _ r: CGFloat) {
p.addRoundedRect(in: CGRect(x: x, y: y, width: w, height: h), cornerWidth: r, cornerHeight: r)
}
switch icon {
case .off: m(8.7, 8.7); l(15.3, 15.3)
case .connecting: break
case .unlock: circle(10, 10.5, 1.8); m(11.4, 11.9); l(15.4, 15.9); m(13.5, 14); l(15, 12.5)
case .connected: m(7.8, 11.8); l(10.8, 14.7); l(16.2, 9.3)
case .reconnect:
p.addArc(center: CGPoint(x: 12.107508, y: 12.15), radius: 4, startAngle: -0.509224, endAngle: 0.509224, clockwise: true)
m(15.6, 7.3); l(15.6, 10.2); l(12.7, 10.2)
case .network:
m(10, 10); l(9, 10); p.addArc(center: CGPoint(x: 9, y: 12), radius: 2, startAngle: -.pi / 2, endAngle: -.pi * 1.5, clockwise: true); l(10, 14)
m(14, 10); l(15, 10); p.addArc(center: CGPoint(x: 15, y: 12), radius: 2, startAngle: -.pi / 2, endAngle: .pi / 2, clockwise: false); l(14, 14)
case .error: m(9, 9); l(15, 15); m(15, 9); l(9, 15)
case .setup: m(12, 7.8); l(12, 12.3)
case .unknown: m(9.7, 9.2); c(9.8, 6.7, 14.6, 6.7, 14.6, 9.4); c(14.6, 11.3, 12, 11.3, 12, 13)
case .expired: m(9, 8); l(15, 8); m(9, 16); l(15, 16); m(9.7, 8); c(9.7, 10.3, 14.3, 13.7, 14.3, 16); m(14.3, 8); c(14.3, 10.3, 9.7, 13.7, 9.7, 16)
case .noTime: m(9, 12); l(15, 12)
case .clock: m(12, 7.5); l(12, 12); l(15, 13.7)
case .connect:
m(8, 3); l(8, 8); m(16, 3); l(16, 8); m(6, 8); l(18, 8); l(18, 11); c(18, 14.3, 15.3, 17, 12, 17); c(8.7, 17, 6, 14.3, 6, 11); p.closeSubpath(); m(12, 17); l(12, 21)
case .disconnect:
m(8, 2.5); l(8, 6); m(16, 2.5); l(16, 6); m(6, 6); l(18, 6); l(18, 8); c(18, 10.2, 15.3, 11.5, 12, 11.5); c(8.7, 11.5, 6, 10.2, 6, 8); p.closeSubpath()
m(7.5, 15.5); p.addQuadCurve(to: CGPoint(x: 16.5, y: 15.5), control: CGPoint(x: 12, y: 19.5)); m(12, 17.5); l(12, 21.5)
case .cancel: m(6, 6); l(18, 18); m(18, 6); l(6, 18)
case .importKey: m(5, 13); l(5, 20); l(19, 20); l(19, 13); m(12, 3); l(12, 15); m(8, 11); l(12, 15); l(16, 11)
case .password: circle(8, 8, 4); m(11, 11); l(20, 20); m(15, 15); l(17, 13); m(18, 18); l(20, 16)
case .copy: m(9, 4); l(19, 4); l(19, 15); m(5, 8); l(15, 8); l(15, 21); l(5, 21); p.closeSubpath()
case .logs: m(6, 3); l(14, 3); l(19, 8); l(19, 21); l(6, 21); p.closeSubpath(); m(14, 3); l(14, 8); l(19, 8); m(9, 12); l(16, 12); m(9, 16); l(16, 16)
case .refresh:
p.addArc(center: CGPoint(x: 12.174321, y: 11.291312), radius: 7.2, startAngle: -0.323870, endAngle: -2.413483, clockwise: true)
l(4, 9); m(4, 4); l(4, 9); l(9, 9)
m(5, 15); p.addArc(center: CGPoint(x: 11.825679, y: 12.708688), radius: 7.2, startAngle: 2.817723, endAngle: 0.728110, clockwise: true)
l(20, 15); m(20, 20); l(20, 15); l(15, 15)
case .quit: m(11, 3); l(4, 3); l(4, 21); l(11, 21); m(10, 12); l(21, 12); m(17, 8); l(21, 12); l(17, 16)
case .manual: rect(3, 6, 18, 12, 2); m(6.5, 10); l(7, 10); m(11.5, 10); l(12, 10); m(16.5, 10); l(17, 10); m(7, 14); l(17, 14)
case .auto: m(13, 3); l(6, 13); l(11, 13); l(10, 21); l(18, 10); l(13, 10); p.closeSubpath()
case .local: rect(5, 4, 14, 12, 1.5); m(2.5, 19); l(21.5, 19); l(20, 21); l(4, 21); p.closeSubpath()
case .vault: rect(4.5, 3, 15, 18, 2.5); circle(12, 12, 3.8); m(12, 8.2); l(12, 10); m(15.8, 12); l(14, 12); m(12, 15.8); l(12, 14); m(8.2, 12); l(10, 12)
}
return p
}
}
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
# Lemana VPN: единая семья значков
Проект набора для согласования. Значки работающего приложения не заменены.
34 варианта: состояния VPN, ход сессии, варианты времени, действия меню и источники входа. Один контур щита, сетка 24 единицы, основная линия 1,75 единицы; рабочий размер — 16 pt. Цвет всегда дополнен формой или подписью.
- `svg/`: векторные версии для светлой и тёмной темы.
- `png/`: прозрачные превью высотой 96 px для просмотра в разных размерах.
- `icon-sheet-light.png` и `icon-sheet-dark.png`: весь набор крупно и в 16 pt.
- `lemana-vpn-icons-v1.zip`: SVG обеих тем и manifest.
- `draw_icons.py`: исходник геометрии и воспроизводимая сборка (Python 3 + rsvg-convert).
Индикатор времени заполняется по часовой стрелке от верхушки щита. Внутри остаётся нейтральная подложка (14% цвета основного текста), поверх которой снизу вверх растёт заливка (60% текущего цвета). Высота заливки пропорциональна прошедшей доле сессии. Контур нейтральный, пройденная часть зелёная; последний час янтарный, последние 15 минут красные. На полном красном щите песочные часы контрастные, а не красные. Кольцо использует тот же принцип внутренней заливки.
Образцы последних минут показаны для условной восьмичасовой сессии; в приложении доля должна вычисляться по реальным started_at/expires. Неизвестный срок имеет отдельную метку, а не нулевое заполнение. Обычные состояния, статические часы и действия меню не изменены.
Bitwarden обозначен нейтральным значком хранилища, это не копия фирменного логотипа.
Проверены структура SVG, доли заполнения и рендер контактных листов. Приложение/GUI не запускались.
+168
View File
@@ -0,0 +1,168 @@
"""Deterministic review assets for Lemana VPN; does not modify the application."""
from pathlib import Path
import html
import json
import math
import subprocess
import zipfile
ROOT = Path(__file__).resolve().parent
SHIELD = 'M12 2 C14.6 3.7 17.3 4.7 20 5 V11.2 C20 16.1 16.7 19.6 12 22 C7.3 19.6 4 16.1 4 11.2 V5 C6.7 4.7 9.4 3.7 12 2 Z'
def curve_length(points):
previous=points[0]; length=0.0
for i in range(1,513):
t=i/512; u=1-t
point=tuple(u**3*points[0][k]+3*u*u*t*points[1][k]+3*u*t*t*points[2][k]+t**3*points[3][k] for k in (0,1))
length+=math.dist(previous,point); previous=point
return length
SHIELD_LENGTH=2*(6.2+curve_length(((12,2),(14.6,3.7),(17.3,4.7),(20,5)))+curve_length(((20,11.2),(20,16.1),(16.7,19.6),(12,22))))
PALETTES = {
'light': dict(bg='#F6F7F8', surface='#ECEFF2', ink='#27333E', muted='#657381', line='#D6DDE3', green='#278055', amber='#A56A13', red='#BE4149'),
'dark': dict(bg='#202429', surface='#2B3037', ink='#E5EAF0', muted='#ACB7C3', line='#444D57', green='#78CAA2', amber='#F0BB68', red='#F58C95'),
}
def path(d, **attrs):
extra = ''.join(f' {key.replace("_", "-")}="{value}"' for key,value in attrs.items())
return f'<path d="{d}"{extra}/>'
def circle(x,y,r,**attrs):
extra = ''.join(f' {key.replace("_", "-")}="{value}"' for key,value in attrs.items())
return f'<circle cx="{x}" cy="{y}" r="{r}"{extra}/>'
def progress_interior(shape,progress,bottom,height,palette,accent):
body=shape(fill=palette['ink'],fill_opacity='.14',stroke='none')
if progress:
level=height*progress
top=bottom-level
# A nested viewport clips the fill level without shared SVG clip-path IDs.
body+=f'<svg x="0" y="{top:.5f}" width="24" height="{level:.5f}" viewBox="0 {top:.5f} 24 {level:.5f}" overflow="hidden">'
body+=shape(fill=accent,fill_opacity='.60',stroke='none')+'</svg>'
return body
MARKS = {
'off': path('M8.7 8.7 L15.3 15.3'),
'connecting': ''.join(circle(x,12,.7,fill='currentColor',stroke='none') for x in (9,12,15)),
'unlock': circle(10,10.5,1.8)+path('M11.4 11.9 L15.4 15.9 M13.5 14 L15 12.5'),
'check': path('M7.8 11.8 L10.8 14.7 L16.2 9.3'),
'reconnect': path('M15.6 10.2 A4 4 0 1 0 15.6 14.1 M15.6 7.3 V10.2 H12.7'),
'network': path('M10 10 H9 A2 2 0 0 0 9 14 H10 M14 10 H15 A2 2 0 0 1 15 14 H14'),
'error': path('M9 9 L15 15 M15 9 L9 15'),
'setup': path('M12 7.8 V12.3')+circle(12,15.5,.65,fill='currentColor',stroke='none'),
'unknown': path('M9.7 9.2 C9.8 6.7 14.6 6.7 14.6 9.4 C14.6 11.3 12 11.3 12 13')+circle(12,15.9,.65,fill='currentColor',stroke='none'),
'expired': path('M9 8 H15 M9 16 H15 M9.7 8 C9.7 10.3 14.3 13.7 14.3 16 M14.3 8 C14.3 10.3 9.7 13.7 9.7 16'),
'no-time': path('M9 12 H15'),
'clock': path('M12 7.5 V12 L15 13.7'),
}
ACTIONS = {
'connect': path('M8 3 V8 M16 3 V8 M6 8 H18 V11 C18 14.3 15.3 17 12 17 C8.7 17 6 14.3 6 11 Z M12 17 V21'),
'disconnect': path('M8 2.5 V6 M16 2.5 V6 M6 6 H18 V8 C18 10.2 15.3 11.5 12 11.5 C8.7 11.5 6 10.2 6 8 Z M7.5 15.5 Q12 19.5 16.5 15.5 M12 17.5 V21.5'),
'cancel': path('M6 6 L18 18 M18 6 L6 18'),
'import': path('M5 13 V20 H19 V13 M12 3 V15 M8 11 L12 15 L16 11'),
'password': circle(8,8,4)+path('M11 11 L20 20 M15 15 L17 13 M18 18 L20 16'),
'copy': path('M9 4 H19 V15 M5 8 H15 V21 H5 Z'),
'logs': path('M6 3 H14 L19 8 V21 H6 Z M14 3 V8 H19 M9 12 H16 M9 16 H16'),
'refresh': path('M19 9 A7.2 7.2 0 0 0 6.8 6.5 L4 9 M4 4 V9 H9 M5 15 A7.2 7.2 0 0 0 17.2 17.5 L20 15 M20 20 V15 H15'),
'quit': path('M11 3 H4 V21 H11 M10 12 H21 M17 8 L21 12 L17 16'),
'manual': '<rect x="3" y="6" width="18" height="12" rx="2"/>'+path('M6.5 10 H7 M11.5 10 H12 M16.5 10 H17 M7 14 H17'),
'auto': path('M13 3 L6 13 H11 L10 21 L18 10 H13 Z'),
'local': '<rect x="5" y="4" width="14" height="12" rx="1.5"/>'+path('M2.5 19 H21.5 L20 21 H4 Z'),
'vault': '<rect x="4.5" y="3" width="15" height="18" rx="2.5"/>'+circle(12,12,3.8)+path('M12 8.2 V10 M15.8 12 H14 M12 15.8 V14 M8.2 12 H10'),
}
ENTRIES=[]
def add(key,label,section,kind,mark='',tone='ink',progress=None):
ENTRIES.append(dict(key=key,label=label,section=section,kind=kind,mark=mark,tone=tone,progress=progress))
for args in [
('vpn-off','Отключён','off','ink'),('vpn-connecting','Подключение','connecting','ink'),
('vpn-unlock','Ожидаем вход','unlock','ink'),('vpn-connected','Подключён','check','green'),
('vpn-reconnect','Переподключение','reconnect','ink'),('vpn-network','Проблема сети','network','amber'),
('vpn-error','Ошибка','error','red'),('vpn-setup','Нужна настройка','setup','amber'),
('vpn-unknown','Статус неизвестен','unknown','ink')]:
add(args[0],args[1],'Состояния VPN','shield',args[2],args[3])
for key,label,progress,tone in [
('time-start','Начало',0,'green'),('time-quarter','25% прошло',.25,'green'),
('time-half','50% прошло',.5,'green'),('time-three-quarter','75% прошло',.75,'green'),
('time-hour','Остался час',.875,'amber'),('time-quarter-hour','Осталось 15 мин',.96875,'red'),
('time-expired','Срок истёк',1,'red')]:
add(key,label,'Время сессии','progress','expired' if progress==1 else '',tone,progress)
add('time-unknown','Срок неизвестен','Время сессии','shield','no-time')
add('format-shield','Щит с прогрессом','Форматы времени','progress','', 'green',.58)
add('format-clock','Щит-часы','Форматы времени','shield','clock','green')
add('format-time','Щит + время','Форматы времени','digital','check','green')
add('format-ring','Кольцо','Форматы времени','ring','', 'green',.58)
for key,label in [('connect','Подключить'),('disconnect','Отключить'),('cancel','Отменить'),('import','Импорт ключа'),('password','Логин и пароль'),('copy','Копировать IP'),('logs','Логи'),('refresh','Обновить статус'),('quit','Выход')]:
add('action-'+key,label,'Действия меню','action',key)
for key,label in [('manual','Вручную'),('auto','Автоматически'),('local','Ключ на Mac'),('vault','Bitwarden')]:
add('source-'+key,label,'Источники и режимы','action',key)
def content(entry,palette):
ink=palette['ink'];accent=palette[entry['tone']]
if entry['kind']=='action': return ACTIONS[entry['mark']]
if entry['kind']=='ring':
body=progress_interior(lambda **attrs:circle(12,12,9,**attrs),entry['progress'],21,18,palette,accent)
return body+circle(12,12,9,stroke=palette['muted'],stroke_opacity='.65')+circle(12,12,9,stroke=accent,stroke_dasharray=f'{entry["progress"]*math.tau*9:.5f} {math.tau*9:.5f}',transform='rotate(-90 12 12)',stroke_linecap='butt')
shape=path(SHIELD,stroke=ink)
if entry['kind']=='progress':
shape=progress_interior(lambda **attrs:path(SHIELD,**attrs),entry['progress'],22,20,palette,accent)
shape+=path(SHIELD,stroke=palette['muted'],stroke_opacity='.68')
if entry['progress']:
shape+=path(SHIELD,stroke=accent,stroke_dasharray=f'{entry["progress"]*SHIELD_LENGTH:.5f} {SHIELD_LENGTH:.5f}',stroke_linecap='butt')
if entry['mark']:
mark_color=ink if entry['kind']=='progress' else accent
shape+=f'<g stroke="{mark_color}" color="{mark_color}">{MARKS[entry["mark"]]}</g>'
if entry['kind']=='digital':
shape+='<text x="29" y="17.2" stroke="none" fill="'+ink+'" font-family="Menlo,monospace" font-size="16.5" font-weight="500">2:18</text>'
return shape
def svg(entry,theme):
p=PALETTES[theme];width=74 if entry['kind']=='digital' else 24
return f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="24" viewBox="0 0 {width} 24" fill="none" stroke="{p["ink"]}" color="{p["ink"]}" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>{html.escape(entry["label"])}</title>'+content(entry,p)+'</svg>'
def text(x,y,value,size=14,color='#657381',weight=400):
return f'<text x="{x}" y="{y}" fill="{color}" font-family="Helvetica Neue,Arial,sans-serif" font-size="{size}" font-weight="{weight}">{html.escape(value)}</text>'
def sheet(theme):
p=PALETTES[theme];width=1120;elements=[]
elements.append(text(42,53,'Lemana VPN',27,p['ink'],600))
elements.append(text(42,82,'Единая семья значков',16,p['muted']))
elements.append(text(824,53,'Щит / 16 pt',14,p['ink'],500))
elements.append(text(824,78,'Контур 1,75 · сетка 24',12,p['muted']))
y=130
for section in dict.fromkeys(e['section'] for e in ENTRIES):
elements.append(text(42,y,section,16,p['ink'],500));y+=24
subset=[e for e in ENTRIES if e['section']==section]
for index,e in enumerate(subset):
col=index%6;row=index//6;x=42+col*174;top=y+row*119
logical_width=74 if e['kind']=='digital' else 24
large_x=x+(160-logical_width*1.5)/2
small_x=x+(160-logical_width*2/3)/2
elements.append(f'<g transform="translate({large_x} {top+4}) scale(1.5)" fill="none" stroke="{p["ink"]}" color="{p["ink"]}" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">'+content(e,p)+'</g>')
elements.append(f'<g transform="translate({small_x} {top+48}) scale(.6666667)" fill="none" stroke="{p["ink"]}" color="{p["ink"]}" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">'+content(e,p)+'</g>')
elements.append(text(x+7,top+91,e['label'],12,p['ink']))
y+=((len(subset)+5)//6)*119+14
elements.append(f'<path d="M42 {y-17} H1078" stroke="{p["line"]}" stroke-width="1"/>')
elements.append(text(42,y+10,'Увеличенный вид + 16 pt. Цвет дополнен формой и подписью.',12,p['muted']))
elements.append(text(42,y+32,'Пример сессии: 8 ч. Контур и внутренняя заливка растут к её концу. Набор пока не установлен.',12,p['muted']))
height=y+65
return f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}"><rect width="{width}" height="{height}" fill="{p["bg"]}"/>'+''.join(elements)+'</svg>'
for theme in PALETTES:
for e in ENTRIES:
source=ROOT/'svg'/f'{e["key"]}-{theme}.svg'
source.write_text(svg(e,theme))
subprocess.run(['rsvg-convert',str(source),'-o',str(ROOT/'png'/f'{e["key"]}-{theme}.png'),'-h','96'],check=True)
source=ROOT/f'icon-sheet-{theme}.svg';source.write_text(sheet(theme))
subprocess.run(['rsvg-convert',str(source),'-o',str(ROOT/f'icon-sheet-{theme}.png'),'-w','2240'],check=True)
(ROOT/'manifest.json').write_text(json.dumps(ENTRIES,ensure_ascii=False,indent=2)+'\n')
with zipfile.ZipFile(ROOT/'lemana-vpn-icons-v1.zip','w',zipfile.ZIP_DEFLATED) as archive:
for file in sorted((ROOT/'svg').glob('*.svg')):
archive.write(file,file.relative_to(ROOT))
archive.write(ROOT/'manifest.json','manifest.json')
print(f'{len(ENTRIES)} icon entries, two themes, SVG assets and preview sheets created.')
Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.
+308
View File
@@ -0,0 +1,308 @@
[
{
"key": "vpn-off",
"label": "Отключён",
"section": "Состояния VPN",
"kind": "shield",
"mark": "off",
"tone": "ink",
"progress": null
},
{
"key": "vpn-connecting",
"label": "Подключение",
"section": "Состояния VPN",
"kind": "shield",
"mark": "connecting",
"tone": "ink",
"progress": null
},
{
"key": "vpn-unlock",
"label": "Ожидаем вход",
"section": "Состояния VPN",
"kind": "shield",
"mark": "unlock",
"tone": "ink",
"progress": null
},
{
"key": "vpn-connected",
"label": "Подключён",
"section": "Состояния VPN",
"kind": "shield",
"mark": "check",
"tone": "green",
"progress": null
},
{
"key": "vpn-reconnect",
"label": "Переподключение",
"section": "Состояния VPN",
"kind": "shield",
"mark": "reconnect",
"tone": "ink",
"progress": null
},
{
"key": "vpn-network",
"label": "Проблема сети",
"section": "Состояния VPN",
"kind": "shield",
"mark": "network",
"tone": "amber",
"progress": null
},
{
"key": "vpn-error",
"label": "Ошибка",
"section": "Состояния VPN",
"kind": "shield",
"mark": "error",
"tone": "red",
"progress": null
},
{
"key": "vpn-setup",
"label": "Нужна настройка",
"section": "Состояния VPN",
"kind": "shield",
"mark": "setup",
"tone": "amber",
"progress": null
},
{
"key": "vpn-unknown",
"label": "Статус неизвестен",
"section": "Состояния VPN",
"kind": "shield",
"mark": "unknown",
"tone": "ink",
"progress": null
},
{
"key": "time-start",
"label": "Начало",
"section": "Время сессии",
"kind": "progress",
"mark": "",
"tone": "green",
"progress": 0
},
{
"key": "time-quarter",
"label": "25% прошло",
"section": "Время сессии",
"kind": "progress",
"mark": "",
"tone": "green",
"progress": 0.25
},
{
"key": "time-half",
"label": "50% прошло",
"section": "Время сессии",
"kind": "progress",
"mark": "",
"tone": "green",
"progress": 0.5
},
{
"key": "time-three-quarter",
"label": "75% прошло",
"section": "Время сессии",
"kind": "progress",
"mark": "",
"tone": "green",
"progress": 0.75
},
{
"key": "time-hour",
"label": "Остался час",
"section": "Время сессии",
"kind": "progress",
"mark": "",
"tone": "amber",
"progress": 0.875
},
{
"key": "time-quarter-hour",
"label": "Осталось 15 мин",
"section": "Время сессии",
"kind": "progress",
"mark": "",
"tone": "red",
"progress": 0.96875
},
{
"key": "time-expired",
"label": "Срок истёк",
"section": "Время сессии",
"kind": "progress",
"mark": "expired",
"tone": "red",
"progress": 1
},
{
"key": "time-unknown",
"label": "Срок неизвестен",
"section": "Время сессии",
"kind": "shield",
"mark": "no-time",
"tone": "ink",
"progress": null
},
{
"key": "format-shield",
"label": "Щит с прогрессом",
"section": "Форматы времени",
"kind": "progress",
"mark": "",
"tone": "green",
"progress": 0.58
},
{
"key": "format-clock",
"label": "Щит-часы",
"section": "Форматы времени",
"kind": "shield",
"mark": "clock",
"tone": "green",
"progress": null
},
{
"key": "format-time",
"label": "Щит + время",
"section": "Форматы времени",
"kind": "digital",
"mark": "check",
"tone": "green",
"progress": null
},
{
"key": "format-ring",
"label": "Кольцо",
"section": "Форматы времени",
"kind": "ring",
"mark": "",
"tone": "green",
"progress": 0.58
},
{
"key": "action-connect",
"label": "Подключить",
"section": "Действия меню",
"kind": "action",
"mark": "connect",
"tone": "ink",
"progress": null
},
{
"key": "action-disconnect",
"label": "Отключить",
"section": "Действия меню",
"kind": "action",
"mark": "disconnect",
"tone": "ink",
"progress": null
},
{
"key": "action-cancel",
"label": "Отменить",
"section": "Действия меню",
"kind": "action",
"mark": "cancel",
"tone": "ink",
"progress": null
},
{
"key": "action-import",
"label": "Импорт ключа",
"section": "Действия меню",
"kind": "action",
"mark": "import",
"tone": "ink",
"progress": null
},
{
"key": "action-password",
"label": "Логин и пароль",
"section": "Действия меню",
"kind": "action",
"mark": "password",
"tone": "ink",
"progress": null
},
{
"key": "action-copy",
"label": "Копировать IP",
"section": "Действия меню",
"kind": "action",
"mark": "copy",
"tone": "ink",
"progress": null
},
{
"key": "action-logs",
"label": "Логи",
"section": "Действия меню",
"kind": "action",
"mark": "logs",
"tone": "ink",
"progress": null
},
{
"key": "action-refresh",
"label": "Обновить статус",
"section": "Действия меню",
"kind": "action",
"mark": "refresh",
"tone": "ink",
"progress": null
},
{
"key": "action-quit",
"label": "Выход",
"section": "Действия меню",
"kind": "action",
"mark": "quit",
"tone": "ink",
"progress": null
},
{
"key": "source-manual",
"label": "Вручную",
"section": "Источники и режимы",
"kind": "action",
"mark": "manual",
"tone": "ink",
"progress": null
},
{
"key": "source-auto",
"label": "Автоматически",
"section": "Источники и режимы",
"kind": "action",
"mark": "auto",
"tone": "ink",
"progress": null
},
{
"key": "source-local",
"label": "Ключ на Mac",
"section": "Источники и режимы",
"kind": "action",
"mark": "local",
"tone": "ink",
"progress": null
},
{
"key": "source-vault",
"label": "Bitwarden",
"section": "Источники и режимы",
"kind": "action",
"mark": "vault",
"tone": "ink",
"progress": null
}
]
Binary file not shown.

After

Width:  |  Height:  |  Size: 923 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 995 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 692 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 703 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 969 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 966 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 875 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 970 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 976 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#E5EAF0" color="#E5EAF0" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>Отменить</title><path d="M6 6 L18 18 M18 6 L6 18"/></svg>

After

Width:  |  Height:  |  Size: 267 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#27333E" color="#27333E" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>Отменить</title><path d="M6 6 L18 18 M18 6 L6 18"/></svg>

After

Width:  |  Height:  |  Size: 267 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#E5EAF0" color="#E5EAF0" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>Подключить</title><path d="M8 3 V8 M16 3 V8 M6 8 H18 V11 C18 14.3 15.3 17 12 17 C8.7 17 6 14.3 6 11 Z M12 17 V21"/></svg>

After

Width:  |  Height:  |  Size: 333 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#27333E" color="#27333E" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>Подключить</title><path d="M8 3 V8 M16 3 V8 M6 8 H18 V11 C18 14.3 15.3 17 12 17 C8.7 17 6 14.3 6 11 Z M12 17 V21"/></svg>

After

Width:  |  Height:  |  Size: 333 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#E5EAF0" color="#E5EAF0" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>Копировать IP</title><path d="M9 4 H19 V15 M5 8 H15 V21 H5 Z"/></svg>

After

Width:  |  Height:  |  Size: 281 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#27333E" color="#27333E" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>Копировать IP</title><path d="M9 4 H19 V15 M5 8 H15 V21 H5 Z"/></svg>

After

Width:  |  Height:  |  Size: 281 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#E5EAF0" color="#E5EAF0" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>Отключить</title><path d="M8 2.5 V6 M16 2.5 V6 M6 6 H18 V8 C18 10.2 15.3 11.5 12 11.5 C8.7 11.5 6 10.2 6 8 Z M7.5 15.5 Q12 19.5 16.5 15.5 M12 17.5 V21.5"/></svg>

After

Width:  |  Height:  |  Size: 372 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#27333E" color="#27333E" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>Отключить</title><path d="M8 2.5 V6 M16 2.5 V6 M6 6 H18 V8 C18 10.2 15.3 11.5 12 11.5 C8.7 11.5 6 10.2 6 8 Z M7.5 15.5 Q12 19.5 16.5 15.5 M12 17.5 V21.5"/></svg>

After

Width:  |  Height:  |  Size: 372 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#E5EAF0" color="#E5EAF0" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>Импорт ключа</title><path d="M5 13 V20 H19 V13 M12 3 V15 M8 11 L12 15 L16 11"/></svg>

After

Width:  |  Height:  |  Size: 298 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#27333E" color="#27333E" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>Импорт ключа</title><path d="M5 13 V20 H19 V13 M12 3 V15 M8 11 L12 15 L16 11"/></svg>

After

Width:  |  Height:  |  Size: 298 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#E5EAF0" color="#E5EAF0" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>Логи</title><path d="M6 3 H14 L19 8 V21 H6 Z M14 3 V8 H19 M9 12 H16 M9 16 H16"/></svg>

After

Width:  |  Height:  |  Size: 292 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#27333E" color="#27333E" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>Логи</title><path d="M6 3 H14 L19 8 V21 H6 Z M14 3 V8 H19 M9 12 H16 M9 16 H16"/></svg>

After

Width:  |  Height:  |  Size: 292 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#E5EAF0" color="#E5EAF0" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>Логин и пароль</title><circle cx="8" cy="8" r="4"/><path d="M11 11 L20 20 M15 15 L17 13 M18 18 L20 16"/></svg>

After

Width:  |  Height:  |  Size: 324 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#27333E" color="#27333E" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>Логин и пароль</title><circle cx="8" cy="8" r="4"/><path d="M11 11 L20 20 M15 15 L17 13 M18 18 L20 16"/></svg>

After

Width:  |  Height:  |  Size: 324 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#E5EAF0" color="#E5EAF0" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>Выход</title><path d="M11 3 H4 V21 H11 M10 12 H21 M17 8 L21 12 L17 16"/></svg>

After

Width:  |  Height:  |  Size: 285 B

Some files were not shown because too many files have changed in this diff Show More