From b6752332942c4df50da956279fcfb612be028b8b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 13:08:00 +0000 Subject: [PATCH] =?UTF-8?q?=D0=A1=D0=BE=D0=BE=D0=B1=D1=89=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=BE=20=D0=BD=D0=BE=D0=B2=D1=8B=D1=85=20=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D1=81=D0=B8=D1=8F=D1=85=20=E2=80=94=20=D0=BF=D0=BE?= =?UTF-8?q?=20=D1=81=D0=BE=D0=B3=D0=BB=D0=B0=D1=81=D0=B8=D1=8E,=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20Mac=20=D0=B8=20Windows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Кто остался на старой версии, не узнавал об исправлениях безопасности. Теперь OffLoadAI один раз спрашивает на «Обзоре», сообщать ли о новых версиях. Пока человек не ответил или если отказался, программа в сеть не ходит. Включённые сообщения раз в сутки делают один запрос к api.github.com за номером последнего выпуска: без cookies и без номера версии программы. Из ответа берётся только номер (три числа), черновики и предварительные выпуски не считаются, страница выпуска строится из номера. Новая версия — строка «Вышла версия …» в боковой колонке: окно с командой установки (кнопка «Скопировать»), «Что нового» и «Не напоминать об этой». Сама программа ничего не скачивает и не ставит. Переключатель и «Проверить обновления…» — в меню OffLoadAI на Mac и в «О программе OffLoadAI» на Windows. README, SECURITY.md и экран согласия помощника говорят, что в сеть выходит и эта проверка. Проверки: разбор ответа, номера версий, раз в сутки — на Mac и Windows. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019g9zXsGLHyDxJzGtUAQUVY --- README.md | 8 +- SECURITY.md | 6 +- Sources/Offload/AppModel.swift | 1 + Sources/Offload/OffloadApp.swift | 4 + Sources/Offload/UpdatesModel.swift | 90 +++++++++++++++ Sources/Offload/Views/AssistantView.swift | 2 +- Sources/Offload/Views/ContentView.swift | 24 +++- Sources/Offload/Views/OverviewView.swift | 1 + Sources/Offload/Views/UpdateViews.swift | 93 +++++++++++++++ Sources/OffloadChecks/ChecksUpdate.swift | 37 ++++++ Sources/OffloadChecks/main.swift | 1 + Sources/OffloadCore/UpdateCheck.swift | 84 ++++++++++++++ windows/src/Offload.Checks/All.cs | 1 + windows/src/Offload.Checks/ChecksUpdate.cs | 45 ++++++++ windows/src/Offload.Core/UpdateCheck.cs | 95 ++++++++++++++++ windows/src/Offload/MainWindow.xaml | 1 + windows/src/Offload/MainWindow.xaml.cs | 12 +- windows/src/Offload/ViewModels/AppModel.cs | 1 + .../src/Offload/ViewModels/UpdatesModel.cs | 86 ++++++++++++++ windows/src/Offload/Views/AssistantView.cs | 2 +- windows/src/Offload/Views/Dialogs.cs | 34 +++++- windows/src/Offload/Views/OverviewView.xaml | 20 ++++ .../src/Offload/Views/OverviewView.xaml.cs | 9 ++ windows/src/Offload/Views/UpdateViews.cs | 107 ++++++++++++++++++ 24 files changed, 750 insertions(+), 14 deletions(-) create mode 100644 Sources/Offload/UpdatesModel.swift create mode 100644 Sources/Offload/Views/UpdateViews.swift create mode 100644 Sources/OffloadChecks/ChecksUpdate.swift create mode 100644 Sources/OffloadCore/UpdateCheck.swift create mode 100644 windows/src/Offload.Checks/ChecksUpdate.cs create mode 100644 windows/src/Offload.Core/UpdateCheck.cs create mode 100644 windows/src/Offload/ViewModels/UpdatesModel.cs create mode 100644 windows/src/Offload/Views/UpdateViews.cs diff --git a/README.md b/README.md index 71da7f8..d5bd217 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,9 @@ less install.sh && zsh install.sh **Docker.** Сколько внутри Docker занимают образы, кеш сборки, контейнеры и тома. «Освободить место…» сразу отмечает только кеш сборки и образы без имени (``, остатки пересборок). Все неиспользуемые образы — только если их отметить: собранный вами и никуда не отправленный образ не скачать заново. Остановленные контейнеры — тоже только по вашей отметке. Итог показывает, насколько после этого уменьшился `Docker.raw` на Mac. Тома очистка не трогает: в них данные. Список томов — с размером и контейнерами, которые их используют; неиспользуемые тома упаковываются в сейф (или на диск) со сверкой и возвращаются одной кнопкой. -**Помощник.** Выберите папку — и помощник скажет, что в ней важно, что менее важно, а что мусор, и почему. Сам он ничего не удаляет и не переносит: у каждого совета кнопка, которая делает то же, что и без помощника, — перенос в сейф со сверкой или Корзина с возвратом («Вернуть» — там же). Удалить помощник может предложить только то, что разрешают правила «Разобрать»: места, которые программы создают заново, и установщики старше недели. Личное — только в сейф, что бы ни ответила модель. Думает он там, где вы выберете: Claude Code на этом Mac (под вашей учётной записью Claude), по вашему ключу Anthropic API (ключ — в связке ключей macOS), в локальной модели Ollama (сведения не покидают Mac) или на сервере OffLoadAI (для Pro, пока не запущен; вместе с вопросом туда уходит ваш ключ Pro — номер и имя из него). Модели уходят пути от домашней папки, размеры, даты, пометки правил и несколько имён внутри папок. Начало небольших текстовых файлов (до 20 строк) — только если вы это включите, и никогда — таблицы (`.csv`), файлы с ключами и те, где видны пароль, токен, номер карты или фраза восстановления. Согласие даётся отдельно на каждый вариант и отзывается одной кнопкой. Остальной OffLoadAI в сеть не ходит. +**Помощник.** Выберите папку — и помощник скажет, что в ней важно, что менее важно, а что мусор, и почему. Сам он ничего не удаляет и не переносит: у каждого совета кнопка, которая делает то же, что и без помощника, — перенос в сейф со сверкой или Корзина с возвратом («Вернуть» — там же). Удалить помощник может предложить только то, что разрешают правила «Разобрать»: места, которые программы создают заново, и установщики старше недели. Личное — только в сейф, что бы ни ответила модель. Думает он там, где вы выберете: Claude Code на этом Mac (под вашей учётной записью Claude), по вашему ключу Anthropic API (ключ — в связке ключей macOS), в локальной модели Ollama (сведения не покидают Mac) или на сервере OffLoadAI (для Pro, пока не запущен; вместе с вопросом туда уходит ваш ключ Pro — номер и имя из него). Модели уходят пути от домашней папки, размеры, даты, пометки правил и несколько имён внутри папок. Начало небольших текстовых файлов (до 20 строк) — только если вы это включите, и никогда — таблицы (`.csv`), файлы с ключами и те, где видны пароль, токен, номер карты или фраза восстановления. Согласие даётся отдельно на каждый вариант и отзывается одной кнопкой. Кроме помощника, в сеть выходят только сообщения о новых версиях — если вы их включили. + +**Новые версии.** OffLoadAI один раз спросит, сообщать ли о них. Если да — раз в сутки он спрашивает у GitHub номер последней версии (в запросе нет ничего о компьютере и файлах, GitHub видит только адрес сети) и, когда вышла новая, пишет в боковой колонке «Вышла версия …» — с командой обновления и ссылкой на то, что нового. Сам он ничего не скачивает и не ставит. Включить или выключить — меню «OffLoadAI» → «Сообщать о новых версиях», проверить сразу — «Проверить обновления…»; на Windows — в «О программе OffLoadAI». ### Как VeraCrypt и чем отличается @@ -133,7 +135,7 @@ OffLoadAI Pro добавляет инструменты для тех, у ког **1 490 ₽ или $19, один раз.** Ключ работает всегда; новые версии — год, дальше продление за полцены, а не продлили — остаётся последняя версия того года. Первые 14 дней Pro открыт целиком, без ключа и регистрации. Если вы пользовались OffLoadAI до появления Pro, всё открыто навсегда. -Ключ проверяется на самом Mac подписью, без сети: ради ключа OffLoadAI в интернет не ходит. В сеть выходит только помощник — по вашему согласию и когда вы его спрашиваете (см. «Помощник»). Ввести ключ — меню «OffLoadAI» → «OffLoadAI Pro…». +Ключ проверяется на самом Mac подписью, без сети: ради ключа OffLoadAI в интернет не ходит. В сеть выходят только помощник — по вашему согласию и когда вы его спрашиваете (см. «Помощник») — и сообщения о новых версиях, если вы их включили. Ввести ключ — меню «OffLoadAI» → «OffLoadAI Pro…». ### Как купить @@ -244,7 +246,7 @@ powershell -ExecutionPolicy Bypass -File windows\scripts\install-local.ps1 OffLoadAI frees up space on a Mac without risking data loss. It shows what takes up disk space, moves selected items to an external drive, keeps an incremental backup of projects, stores keys and tokens in an encrypted container and restores files from restic backups in iCloud Drive. The original is deleted only after the copy has been re-read from disk and matched byte for byte (SHA-256). -Everything needed to free up space and keep data safe is free forever; OffLoadAI Pro (a one-time $19 with a year of updates, 14-day trial, free for users of earlier versions) adds duplicate removal, learned habits, project backup and Docker volume archiving. The license key is verified offline with an Ed25519 signature — checking it needs no network. The only part that goes online is the optional AI assistant, and only with your consent (given per backend and revocable) when you ask it: it sends file paths relative to your home folder, sizes, dates and rule marks — file contents only if you turn that on, and never spreadsheets, key files or text that looks like a password, token, card number or recovery phrase. It can only suggest deleting what the cleanup rules already allow (regenerable caches and old installers); anything personal goes to the vault. +Everything needed to free up space and keep data safe is free forever; OffLoadAI Pro (a one-time $19 with a year of updates, 14-day trial, free for users of earlier versions) adds duplicate removal, learned habits, project backup and Docker volume archiving. The license key is verified offline with an Ed25519 signature — checking it needs no network. Only two optional parts go online, each only with your consent: a daily check for a new version (one GitHub API request for the latest release number, nothing about your computer or files; you update yourself with the install command), and the AI assistant (consent given per backend and revocable) when you ask it: it sends file paths relative to your home folder, sizes, dates and rule marks — file contents only if you turn that on, and never spreadsheets, key files or text that looks like a password, token, card number or recovery phrase. It can only suggest deleting what the cleanup rules already allow (regenerable caches and old installers); anything personal goes to the vault. It refuses to move things that would break apps — virtual machines and media libraries (even nested deep inside a folder), app data in `~/Library`, `~/.ssh` — and handles exFAT pitfalls (permissions, AppleDouble `._*` files, sparse files) and a Docker trap where the archive stream fills `Docker.raw` via container logs. diff --git a/SECURITY.md b/SECURITY.md index f5c70c0..75c2248 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -36,7 +36,9 @@ OffLoadAI переносит и удаляет файлы, поэтому без «Разрешить всё» не срабатывает от клавиши Return: ответ «да» на все вопросы — только нажатием самой кнопки. -**Помощник только советует, и в сеть выходит только он.** Остальной OffLoadAI в сеть не ходит. Помощник работает, только если человек согласился — отдельно на каждый вариант, потому что сведения уходят в разные места: через Claude Code и по ключу Anthropic API — в Anthropic, в Ollama — никуда с этого Mac (только `127.0.0.1`), на сервер OffLoadAI — туда (только по https), и вместе с вопросом уходит ключ OffLoadAI Pro, а в нём номер и имя, которое назвал покупатель. Согласие отзывается одной кнопкой; начатый вопрос при этом прерывается. Модели уходят путь от домашней папки (без имени пользователя), размер, дата, пометка правил и до десяти имён внутри папки, кроме секретов по имени. Начало небольших текстовых файлов (до 20 строк) — только если человек это включил, и никогда — у таблиц (`.csv`, `.tsv`: так выглядят выгрузки паролей из браузеров), файлов, которые правила бэкапа считают секретами, и файлов, в начале которых видны пароль или PIN (по-русски и по-английски), ключ, токен (GitHub, Slack, OpenAI, Anthropic, Stripe, AWS, Google, Telegram-бот, JWT), пароль в адресе, пара «почта:пароль», номер карты, длинная строка, похожая на ключ, или фраза восстановления кошелька. Такой файл не показывается вовсе, а не по кусочку. Файлы, которые лежат только в облаке, не скачиваются. Всё, что уходит модели, — данные, а не указания, и ответ модели сам ничего не делает: каждый совет выполняет человек кнопкой, тем же переносом со сверкой или Корзиной, что и без помощника. Советы проверяются правилами OffLoadAI: запрещённое — только «оставить», с оговорками — не в Корзину, а в сейф, а удалить можно только то, что разрешает разбор (места, которые программы создают заново, и установщики старше недели), — остальное помощник может советовать только убрать в сейф, что бы ни ответила модель, поддавшись, например, имени или тексту файла. Перед удалением это проверяется ещё раз, по свежим сведениям с диска; отправленное в Корзину возвращается на прежнее место кнопкой «Вернуть». Ключ Anthropic API хранится в связке ключей macOS (на Windows — зашифрованным DPAPI) и уходит только заголовком запроса. Claude Code запускается без инструментов и доступа к файлам: сведения он получает только через stdin. +**Помощник только советует.** Кроме него, в сеть выходят только сообщения о новых версиях, если человек их включил (см. ниже); остальной OffLoadAI в сеть не ходит. Помощник работает, только если человек согласился — отдельно на каждый вариант, потому что сведения уходят в разные места: через Claude Code и по ключу Anthropic API — в Anthropic, в Ollama — никуда с этого Mac (только `127.0.0.1`), на сервер OffLoadAI — туда (только по https), и вместе с вопросом уходит ключ OffLoadAI Pro, а в нём номер и имя, которое назвал покупатель. Согласие отзывается одной кнопкой; начатый вопрос при этом прерывается. Модели уходят путь от домашней папки (без имени пользователя), размер, дата, пометка правил и до десяти имён внутри папки, кроме секретов по имени. Начало небольших текстовых файлов (до 20 строк) — только если человек это включил, и никогда — у таблиц (`.csv`, `.tsv`: так выглядят выгрузки паролей из браузеров), файлов, которые правила бэкапа считают секретами, и файлов, в начале которых видны пароль или PIN (по-русски и по-английски), ключ, токен (GitHub, Slack, OpenAI, Anthropic, Stripe, AWS, Google, Telegram-бот, JWT), пароль в адресе, пара «почта:пароль», номер карты, длинная строка, похожая на ключ, или фраза восстановления кошелька. Такой файл не показывается вовсе, а не по кусочку. Файлы, которые лежат только в облаке, не скачиваются. Всё, что уходит модели, — данные, а не указания, и ответ модели сам ничего не делает: каждый совет выполняет человек кнопкой, тем же переносом со сверкой или Корзиной, что и без помощника. Советы проверяются правилами OffLoadAI: запрещённое — только «оставить», с оговорками — не в Корзину, а в сейф, а удалить можно только то, что разрешает разбор (места, которые программы создают заново, и установщики старше недели), — остальное помощник может советовать только убрать в сейф, что бы ни ответила модель, поддавшись, например, имени или тексту файла. Перед удалением это проверяется ещё раз, по свежим сведениям с диска; отправленное в Корзину возвращается на прежнее место кнопкой «Вернуть». Ключ Anthropic API хранится в связке ключей macOS (на Windows — зашифрованным DPAPI) и уходит только заголовком запроса. Claude Code запускается без инструментов и доступа к файлам: сведения он получает только через stdin. + +**Сообщения о новых версиях — только по согласию.** Пока человек не ответил на вопрос или если выключил их, OffLoadAI в сеть не ходит. Включённые — раз в сутки делают один запрос `GET https://api.github.com/repos/audit0/Offload/releases/latest`: без cookies и без номера версии программы, GitHub видит только адрес сети. «Проверить обновления…» делает тот же запрос по нажатию. Из ответа берётся только номер выпуска — три числа; черновики и предварительные выпуски не считаются, а страница выпуска строится из номера, а не берётся из ответа. Ничего не скачивается и не ставится само: обновляется человек командой установки, которая сверяет архив. **Без повышения прав.** OffLoadAI работает от имени пользователя, не использует `sudo` и привилегированные помощники. Режим снимков экрана (`OFFLOAD_SNAPSHOT_DIR`) работает только вместе с демонстрационным: иначе любая программа под этой учётной записью запустила бы OffLoadAI с полным доступом к диску и получила бы снимки ваших папок. @@ -85,4 +87,4 @@ OffLoadAI переносит и удаляет файлы, поэтому без ## Security (English summary) -Report vulnerabilities privately via **Security → Report a vulnerability**. OffLoadAI deletes an original only after a byte-for-byte SHA-256 verified copy and a check that the source did not change; refuses to move app-registered bundles and app data; runs external tools without a shell, with only the environment they need (your own `RESTIC_PASSWORD` or `ANTHROPIC_API_KEY` never reaches restic or Claude Code), and system tools only from system directories; cross-checks two independent traversals (`readdir` and `fts`) before deleting anything; trashes a redundant duplicate only after re-comparing it byte-for-byte with the copy that stays (one copy of every file always stays, library media, bundles, git projects, APFS clones, not-downloaded iCloud files, encrypted .dmg and .iso images are never removed); cleanup acts only on your explicit "yes" to each question: deletion is offered only for regenerable caches from a fixed list (not caches of running apps), Docker build cache and dangling images (never tagged images, volumes or containers), redundant duplicate copies, and installers older than a week (only by their own answer, never by "Allow all"); UTM virtual machines are never deleted — that is done in UTM itself; everything it deletes except Docker images goes to the Trash with one-click "put back" (permanent deletion only on request, only of what this run trashed); learns habits from your own answers only on this Mac (a habit never adds anything to a deletion question; "Forget my decisions" clears them); never overwrites destination files or follows symlinks when reading sources (`O_EXCL | O_NOFOLLOW`); applies restored permissions only to paths that were actually restored; passes the vault password to `hdiutil` via stdin; treats the on-drive journal as untrusted; validates Docker volume names and runs helper containers with `--network none --log-driver none`; refuses to touch a non-local Docker context; verifies Docker volume archives by per-file content hashes; restores only into a volume it created itself; locks the vault root to the owner (`700`); flushes copies with `F_FULLFSYNC` and verifies them bypassing the page cache. The optional AI assistant is the only part that goes online: it runs only with consent given per backend (Anthropic via Claude Code or an API key, local Ollama, or the OffLoadAI server, which also receives the Pro key with the buyer's name) and revocable at any time; it sends paths relative to home, sizes, dates and rule marks — the beginning of small text files only if you turn that on, and never for spreadsheets, key files or text that looks like a password, token, card number or recovery phrase; its advice is re-checked by the same rules (it can suggest deleting only regenerable caches and installers older than a week, anything else only moving to the vault), re-checked again before trashing, and every action is your click. Known limitations: ad-hoc signing without notarization, checksum-from-same-release in the curl installer, heuristic secret detection for open backups and for assistant previews (off by default). The Windows version (`windows/`) keeps the same rules with Windows mechanisms: `CREATE_NEW` + reparse-point-safe opens, `FlushFileBuffers` and unbuffered verification, two traversals (`FindFirstFileEx` and .NET enumeration), Restart Manager for open files, a BitLocker (XTS-AES-256) VHDX vault whose encryption is verified from the image header before opening and by BitLocker itself after, password passed to BitLocker via WMI, administrator rights only for an elevated vault helper process reached over an ACL-restricted, peer-verified named pipe, and the Windows Recycle Bin with identity checks. +Report vulnerabilities privately via **Security → Report a vulnerability**. OffLoadAI deletes an original only after a byte-for-byte SHA-256 verified copy and a check that the source did not change; refuses to move app-registered bundles and app data; runs external tools without a shell, with only the environment they need (your own `RESTIC_PASSWORD` or `ANTHROPIC_API_KEY` never reaches restic or Claude Code), and system tools only from system directories; cross-checks two independent traversals (`readdir` and `fts`) before deleting anything; trashes a redundant duplicate only after re-comparing it byte-for-byte with the copy that stays (one copy of every file always stays, library media, bundles, git projects, APFS clones, not-downloaded iCloud files, encrypted .dmg and .iso images are never removed); cleanup acts only on your explicit "yes" to each question: deletion is offered only for regenerable caches from a fixed list (not caches of running apps), Docker build cache and dangling images (never tagged images, volumes or containers), redundant duplicate copies, and installers older than a week (only by their own answer, never by "Allow all"); UTM virtual machines are never deleted — that is done in UTM itself; everything it deletes except Docker images goes to the Trash with one-click "put back" (permanent deletion only on request, only of what this run trashed); learns habits from your own answers only on this Mac (a habit never adds anything to a deletion question; "Forget my decisions" clears them); never overwrites destination files or follows symlinks when reading sources (`O_EXCL | O_NOFOLLOW`); applies restored permissions only to paths that were actually restored; passes the vault password to `hdiutil` via stdin; treats the on-drive journal as untrusted; validates Docker volume names and runs helper containers with `--network none --log-driver none`; refuses to touch a non-local Docker context; verifies Docker volume archives by per-file content hashes; restores only into a volume it created itself; locks the vault root to the owner (`700`); flushes copies with `F_FULLFSYNC` and verifies them bypassing the page cache. Only two optional parts go online, each only with consent: a daily new-version check if you turn it on (one GitHub API request for the latest release number, no cookies, nothing about the computer or files; nothing is downloaded or installed automatically), and the AI assistant: it runs only with consent given per backend (Anthropic via Claude Code or an API key, local Ollama, or the OffLoadAI server, which also receives the Pro key with the buyer's name) and revocable at any time; it sends paths relative to home, sizes, dates and rule marks — the beginning of small text files only if you turn that on, and never for spreadsheets, key files or text that looks like a password, token, card number or recovery phrase; its advice is re-checked by the same rules (it can suggest deleting only regenerable caches and installers older than a week, anything else only moving to the vault), re-checked again before trashing, and every action is your click. Known limitations: ad-hoc signing without notarization, checksum-from-same-release in the curl installer, heuristic secret detection for open backups and for assistant previews (off by default). The Windows version (`windows/`) keeps the same rules with Windows mechanisms: `CREATE_NEW` + reparse-point-safe opens, `FlushFileBuffers` and unbuffered verification, two traversals (`FindFirstFileEx` and .NET enumeration), Restart Manager for open files, a BitLocker (XTS-AES-256) VHDX vault whose encryption is verified from the image header before opening and by BitLocker itself after, password passed to BitLocker via WMI, administrator rights only for an elevated vault helper process reached over an ACL-restricted, peer-verified named pipe, and the Windows Recycle Bin with identity checks. diff --git a/Sources/Offload/AppModel.swift b/Sources/Offload/AppModel.swift index eca4af1..ed7d095 100644 --- a/Sources/Offload/AppModel.swift +++ b/Sources/Offload/AppModel.swift @@ -71,6 +71,7 @@ final class AppModel { let cleanup = CleanupModel() let assistant = AssistantModel() let cloud = CloudRestoreModel() + let updates = UpdatesModel() /// Только что подключённый внешний диск: «Обзор» предлагает разобрать Mac одной кнопкой. var connectedPrompt: String? diff --git a/Sources/Offload/OffloadApp.swift b/Sources/Offload/OffloadApp.swift index 851d9bd..d33e998 100644 --- a/Sources/Offload/OffloadApp.swift +++ b/Sources/Offload/OffloadApp.swift @@ -22,6 +22,10 @@ struct OffloadApp: App { .commands { CommandGroup(replacing: .appInfo) { Button("О программе OffLoadAI") { AboutPanel.show() } + Button("Проверить обновления…") { model.updates.checkNow() } + .disabled(model.updates.isChecking) + Toggle("Сообщать о новых версиях", isOn: Binding(get: { model.updates.enabled == true }, + set: { model.updates.setEnabled($0) })) Button("OffLoadAI Pro…") { model.pro.offer() } } // Как «Dismount All» в VeraCrypt: закрыть сейф из любого места одним сочетанием. diff --git a/Sources/Offload/UpdatesModel.swift b/Sources/Offload/UpdatesModel.swift new file mode 100644 index 0000000..2bceabc --- /dev/null +++ b/Sources/Offload/UpdatesModel.swift @@ -0,0 +1,90 @@ +import AppKit +import Foundation +import Observation +import OffloadCore + +/// Сообщения о новых версиях. Выключены, пока человек их не включит: сам OffLoadAI в сеть не ходит. +/// Включённые — раз в сутки спрашивают у GitHub номер последнего выпуска (см. `UpdateCheck`). +/// Обновляется человек сам — той же командой установки, что сверяет архив. +@MainActor +@Observable +final class UpdatesModel { + /// Та же команда, что в README: скачивает выпуск, сверяет его и ставит на место этой версии. + static let installCommand = "curl -fsSL https://raw.githubusercontent.com/audit0/Offload/main/scripts/install.sh | zsh" + + /// Номер этой версии из Info.plist. У запуска без пакета (`swift run`) его нет — сообщать не о чем. + static let currentVersion: String? = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String + + private static let enabledKey = "updates.enabled" + private static let lastCheckKey = "updates.lastCheck" + private static let postponedKey = "updates.postponed" + + /// nil — человека ещё не спрашивали: тогда на «Обзоре» висит вопрос. + private(set) var enabled: Bool? + /// Вышла версия новее этой, и человек не отложил именно её. + private(set) var available: UpdateCheck.Release? + private(set) var isChecking = false + /// Окно «Вышла новая версия». + var isPresented = false + /// Итог проверки из меню: установлена последняя версия или почему проверить не вышло. + var checkResult: String? + + init() { + // Снимки для README: вопроса на «Обзоре» нет, в сеть программа не ходит. + enabled = Demo.isOn ? false : UserDefaults.standard.object(forKey: Self.enabledKey) as? Bool + } + + func setEnabled(_ on: Bool) { + enabled = on + UserDefaults.standard.set(on, forKey: Self.enabledKey) + if on { checkIfDue() } else { available = nil } + } + + /// При запуске и когда окно снова становится активным: раз в сутки и только с разрешения. + func checkIfDue() { + guard enabled == true, !Demo.isOn, !isChecking, + UpdateCheck.isDue(lastCheck: UserDefaults.standard.object(forKey: Self.lastCheckKey) as? Date) else { return } + Task { await check(manual: false) } + } + + /// «Проверить обновления…» из меню: один вопрос к GitHub по нажатию — само нажатие и есть согласие. + func checkNow() { + guard !Demo.isOn, !isChecking else { return } + Task { await check(manual: true) } + } + + /// Не напоминать об этой версии; о следующей — напомнить. + func postpone() { + if let available { UserDefaults.standard.set(available.version, forKey: Self.postponedKey) } + available = nil + isPresented = false + } + + private func check(manual: Bool) async { + isChecking = true + defer { isChecking = false } + // Время попытки запоминается и при неудаче: без сети программа не стучится в GitHub при каждом возврате к окну. + UserDefaults.standard.set(Date(), forKey: Self.lastCheckKey) + do { + let release = try await UpdateCheck.fetch() + guard let current = Self.currentVersion else { + if manual { checkResult = "У сборки для разработки нет номера версии. Последний выпуск — \(release.version)." } + return + } + guard UpdateCheck.isNewer(release.version, than: current) else { + available = nil + if manual { checkResult = "Установлена последняя версия — \(current)." } + return + } + if manual || UserDefaults.standard.string(forKey: Self.postponedKey) != release.version { available = release } + if manual { isPresented = true } + } catch { + if manual { checkResult = "Не удалось проверить: \(error.localizedDescription)" } + } + } + + func copyCommand() { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(Self.installCommand, forType: .string) + } +} diff --git a/Sources/Offload/Views/AssistantView.swift b/Sources/Offload/Views/AssistantView.swift index 378f58f..e2c31f9 100644 --- a/Sources/Offload/Views/AssistantView.swift +++ b/Sources/Offload/Views/AssistantView.swift @@ -102,7 +102,7 @@ struct AssistantView: View { .fixedSize(horizontal: false, vertical: true) Text("Куда: \(model.kind.destination)") .fontWeight(.medium).fixedSize(horizontal: false, vertical: true) - Text("Остальной OffLoadAI работает без сети. Согласие — отдельно для каждого варианта; отозвать его можно в любой момент.") + Text("Остальной OffLoadAI работает без сети — кроме сообщений о новых версиях, если вы их включили. Согласие — отдельно для каждого варианта; отозвать его можно в любой момент.") .font(.callout).foregroundStyle(Theme.muted).fixedSize(horizontal: false, vertical: true) Button("Согласен — включить «\(model.kind.title)»") { model.setConsent(true) } .prominentButton() diff --git a/Sources/Offload/Views/ContentView.swift b/Sources/Offload/Views/ContentView.swift index df450d7..1f3a7d4 100644 --- a/Sources/Offload/Views/ContentView.swift +++ b/Sources/Offload/Views/ContentView.swift @@ -8,6 +8,7 @@ struct ContentView: View { @Bindable var app = app @Bindable var safe = app.safe @Bindable var pro = app.pro + @Bindable var updates = app.updates NavigationSplitView { // Своя колонка вместо List: выделение у List macOS рисует системным синим, // а здесь оно чёрно-белое — светло-серая подложка и жирный текст. @@ -25,6 +26,9 @@ struct ContentView: View { .navigationSplitViewColumnWidth(min: 240, ideal: 260) .safeAreaInset(edge: .bottom) { VStack(spacing: 4) { + if let release = app.updates.available { + UpdateSidebarRow(release: release).padding(.horizontal, 10) + } ProSidebarRow().padding(.horizontal, 10) SafeStatusPanel().padding([.horizontal, .bottom], 10) } @@ -59,8 +63,19 @@ struct ContentView: View { // Сменили диск — перечитываем, есть ли на нём сейф и открыт ли он. .task(id: app.destinationID) { app.safe.refresh(app: app) } .sheet(isPresented: $pro.isPresented) { ProSheet() } - // Пробный период считается днями: окно могло простоять открытым со вчера. - .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in app.pro.refresh() } + .sheet(isPresented: $updates.isPresented) { UpdateSheet() } + .alert("Обновления", isPresented: updateResultShown) { + Button("Готово", role: .cancel) {} + } message: { + Text(updates.checkResult ?? "") + } + // Пробный период считается днями: окно могло простоять открытым со вчера. О новой версии — раз в сутки, + // если человек это включил. + .task { app.updates.checkIfDue() } + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in + app.pro.refresh() + app.updates.checkIfDue() + } // Закрыть сейф не дали открытые в нём файлы — откуда бы ни закрывали: из панели, меню или раздела. .alert("Сейф не закрывается", isPresented: $safe.closeBlocked) { Button("Закрыть принудительно", role: .destructive) { app.safe.close(app: app, force: true) } @@ -73,6 +88,11 @@ struct ContentView: View { .task(id: app.historyVolumes.map(\.id)) { app.history.reload(volumes: app.historyVolumes) } } + /// Итог проверки обновлений из меню: установлена последняя версия или почему проверить не вышло. + private var updateResultShown: Binding { + Binding(get: { app.updates.checkResult != nil }, set: { if !$0 { app.updates.checkResult = nil } }) + } + /// Сколько перенесённого лежит на дисках — видно, не заходя в раздел. Ноль не показывается. private func badge(for section: SidebarSection) -> Int { guard section == .history else { return 0 } diff --git a/Sources/Offload/Views/OverviewView.swift b/Sources/Offload/Views/OverviewView.swift index fef09f8..2d1112b 100644 --- a/Sources/Offload/Views/OverviewView.swift +++ b/Sources/Offload/Views/OverviewView.swift @@ -11,6 +11,7 @@ struct OverviewView: View { ConnectedPrompt(disk: disk) } if !app.hasFullDiskAccess { FullDiskAccessBanner() } + if app.updates.enabled == nil { UpdateQuestionCard() } // Три карточки одной высоты: fixedSize по вертикали отдаёт ряду высоту самой высокой, // а карточки с fillsHeight растягиваются до неё. HStack(alignment: .top, spacing: 16) { diff --git a/Sources/Offload/Views/UpdateViews.swift b/Sources/Offload/Views/UpdateViews.swift new file mode 100644 index 0000000..d54cfd4 --- /dev/null +++ b/Sources/Offload/Views/UpdateViews.swift @@ -0,0 +1,93 @@ +import AppKit +import OffloadCore +import SwiftUI + +/// Вопрос на «Обзоре», пока человек не решил, сообщать ли о новых версиях. +struct UpdateQuestionCard: View { + @Environment(AppModel.self) private var app + + var body: some View { + Card { + HStack(alignment: .top, spacing: 14) { + IconTile(systemImage: "arrow.down.circle", tone: .brand, size: 36) + VStack(alignment: .leading, spacing: 8) { + Text("Сообщать о новых версиях?").font(.headline) + Text("Раз в сутки OffLoadAI спросит у GitHub номер последней версии и скажет, если вышла новая: в новых версиях бывают исправления безопасности. В запросе нет ничего о компьютере и файлах — GitHub видит только адрес сети, как при открытии любой страницы. Обновление ставите вы сами. Передумать можно в меню OffLoadAI.") + .font(.callout).foregroundStyle(Theme.muted).fixedSize(horizontal: false, vertical: true) + HStack { + Button("Сообщать") { app.updates.setEnabled(true) } + .prominentButton() + Button("Не надо") { app.updates.setEnabled(false) } + } + .padding(.top, 2) + } + } + } + } +} + +/// Строка в боковой колонке: вышла новая версия. +struct UpdateSidebarRow: View { + @Environment(AppModel.self) private var app + let release: UpdateCheck.Release + @State private var hovering = false + + var body: some View { + Button { app.updates.isPresented = true } label: { + HStack(spacing: 8) { + Image(systemName: "arrow.down.circle.fill") + .foregroundStyle(Theme.ink) + .frame(width: 20) + Text("Вышла версия \(release.version)").font(.callout).fontWeight(.medium).foregroundStyle(Theme.ink).lineLimit(1) + Spacer(minLength: 4) + } + .padding(.horizontal, 10) + .frame(height: 30) + .contentShape(Rectangle()) + .background(hovering ? Color.white.opacity(0.3) : .clear, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + } + .buttonStyle(.plain) + .onHover { hovering = $0 } + .help("Что нового и как обновиться") + } +} + +/// Вышла новая версия: что нового — на странице выпуска, обновиться — командой в Терминале. +struct UpdateSheet: View { + @Environment(AppModel.self) private var app + @Environment(\.dismiss) private var dismiss + @State private var copied = false + + var body: some View { + let updates = app.updates + SheetLayout(systemImage: "arrow.down.circle", title: "Вышла версия \(updates.available?.version ?? "")", + subtitle: UpdatesModel.currentVersion.map { "У вас — \($0)" }, width: 540) { + Text("Чтобы обновиться, вставьте эту команду в Терминал. Она скачает новую версию, сверит её и поставит на место этой. Если идёт копирование, OffLoadAI сначала спросит, прервать ли его. Настройки, ключ Pro, журнал и сейф останутся как были.") + .font(.callout).foregroundStyle(Theme.muted).fixedSize(horizontal: false, vertical: true) + HStack(alignment: .top, spacing: 10) { + Text(UpdatesModel.installCommand) + .font(.callout.monospaced()) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + Button(copied ? "Скопировано" : "Скопировать") { + updates.copyCommand() + copied = true + } + } + .padding(12) + .background(Theme.soft, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + } actions: { + if let page = updates.available?.page { + Button("Что нового") { NSWorkspace.shared.open(page) } + } + Button("Не напоминать об этой") { + updates.postpone() + dismiss() + } + Button("Готово") { dismiss() } + .prominentButton() + .keyboardShortcut(.defaultAction) + } + } +} diff --git a/Sources/OffloadChecks/ChecksUpdate.swift b/Sources/OffloadChecks/ChecksUpdate.swift new file mode 100644 index 0000000..223bbd6 --- /dev/null +++ b/Sources/OffloadChecks/ChecksUpdate.swift @@ -0,0 +1,37 @@ +import Foundation +import OffloadCore + +// Сообщение о новой версии: разбор ответа GitHub, сравнение номеров, раз в сутки. +func checksUpdate() { + section("Обновления: ответ GitHub и номера версий") { + func release(_ fields: String) -> Data { Data("{\(fields),\"html_url\":\"https://evil.example/x\",\"body\":\"…\"}".utf8) } + let parsed = try UpdateCheck.parse(release(#""tag_name":"v0.4.1","draft":false,"prerelease":false"#)) + check(parsed.version == "0.4.1", "номер выпуска читается без «v»") + check(parsed.page.absoluteString == "https://github.com/audit0/Offload/releases/tag/v0.4.1", + "страница выпуска строится из номера, адрес из ответа не открывается") + check((try? UpdateCheck.parse(release(#""tag_name":"0.5.0""#)))?.version == "0.5.0", "номер и без «v»") + expectError("черновик не считается") { _ = try UpdateCheck.parse(release(#""tag_name":"v9.0.0","draft":true"#)) } + expectError("предварительный выпуск не считается") { _ = try UpdateCheck.parse(release(#""tag_name":"v9.0.0","prerelease":true"#)) } + for tag in ["latest", "v1.2", "v1.2.3.4", "v1.2.3-beta", "v1.x.3", "v١.٢.٣", "v1234567.0.0"] { + expectError("непонятный номер «\(tag)» не считается") { _ = try UpdateCheck.parse(release("\"tag_name\":\"\(tag)\"")) } + } + expectError("не JSON — ошибка") { _ = try UpdateCheck.parse(Data("".utf8)) } + + check(UpdateCheck.isNewer("0.4.1", than: "0.4.0"), "0.4.1 новее 0.4.0") + check(UpdateCheck.isNewer("0.10.0", than: "0.9.9"), "номера сравниваются числами, а не строками") + check(UpdateCheck.isNewer("1.0.0", than: "0.99.99"), "1.0.0 новее 0.99.99") + check(!UpdateCheck.isNewer("0.4.0", than: "0.4.0"), "та же версия — не новее") + check(!UpdateCheck.isNewer("0.3.9", than: "0.4.0"), "старая версия — не новее") + check(!UpdateCheck.isNewer("0.4.0", than: "0.4.0-ci"), "сборка для разработки той же версии — обновляться не нужно") + check(UpdateCheck.isNewer("0.4.1", than: "0.4.0-ci"), "а следующая версия новее и её") + check(!UpdateCheck.isNewer("9.9.9", than: "разработка"), "непонятный номер этой версии — молчим") + + let now = Date() + check(UpdateCheck.isDue(lastCheck: nil, now: now), "ни разу не спрашивали — пора") + check(!UpdateCheck.isDue(lastCheck: now.addingTimeInterval(-3600), now: now), "час назад спрашивали — рано") + check(UpdateCheck.isDue(lastCheck: now.addingTimeInterval(-25 * 3600), now: now), "больше суток назад — пора") + check(UpdateCheck.isDue(lastCheck: now.addingTimeInterval(3600), now: now), "часы перевели назад — проверка не застревает") + check(UpdateCheck.endpoint.host == "api.github.com" && UpdateCheck.endpoint.scheme == "https", + "спрашивается только GitHub, по https") + } +} diff --git a/Sources/OffloadChecks/main.swift b/Sources/OffloadChecks/main.swift index cb4e238..6f52aca 100644 --- a/Sources/OffloadChecks/main.swift +++ b/Sources/OffloadChecks/main.swift @@ -663,6 +663,7 @@ checksHabits() checksCloudRestore() checksLicense() checksAssistant() +checksUpdate() try? fm.removeItem(at: scratch) print("") diff --git a/Sources/OffloadCore/UpdateCheck.swift b/Sources/OffloadCore/UpdateCheck.swift new file mode 100644 index 0000000..ef3af1d --- /dev/null +++ b/Sources/OffloadCore/UpdateCheck.swift @@ -0,0 +1,84 @@ +import Foundation + +/// Сообщение о новой версии — только если человек его включил. +/// +/// Раз в сутки OffLoadAI спрашивает у GitHub номер последнего выпуска. В запросе нет ничего о компьютере, +/// файлах и версии программы: GitHub видит только адрес сети, как при открытии любой страницы. +/// Скачивает и ставит обновление сам человек — той же командой установки, что сверяет архив. +public enum UpdateCheck { + public static let endpoint = URL(string: "https://api.github.com/repos/audit0/Offload/releases/latest")! + public static let interval: TimeInterval = 24 * 60 * 60 + + public struct Release: Equatable, Sendable { + /// Три числа без «v»: 0.4.1. + public let version: String + /// Страница выпуска. Строится из номера, а не берётся из ответа: другой адрес программа не откроет. + public var page: URL { URL(string: "https://github.com/audit0/Offload/releases/tag/v" + version)! } + + public init(version: String) { self.version = version } + } + + public struct Failure: Error, LocalizedError { + public let message: String + public var errorDescription: String? { message } + } + + /// Разбор ответа GitHub. Черновики, предварительные выпуски и непонятные номера не считаются. + public static func parse(_ data: Data) throws -> Release { + guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw Failure(message: "GitHub ответил не так, как ожидалось.") + } + guard object["draft"] as? Bool != true, object["prerelease"] as? Bool != true, + let tag = object["tag_name"] as? String, let numbers = numbers(tag.hasPrefix("v") ? String(tag.dropFirst()) : tag, strict: true) + else { throw Failure(message: "У последнего выпуска на GitHub непонятный номер версии.") } + return Release(version: numbers.map(String.init).joined(separator: ".")) + } + + /// Новее ли `candidate`, чем `current`. Номер сборки для разработки (0.4.0-ci) сравнивается по трём числам; + /// непонятный номер — не новее: лучше промолчать, чем звать обновляться зря. + public static func isNewer(_ candidate: String, than current: String) -> Bool { + guard let new = numbers(candidate, strict: true), let old = numbers(current, strict: false) else { return false } + return old.lexicographicallyPrecedes(new) + } + + /// Пора ли спросить снова: раз в сутки. Часы, переведённые назад, проверку не останавливают. + public static func isDue(lastCheck: Date?, now: Date = Date()) -> Bool { + guard let lastCheck else { return true } + return now < lastCheck || now.timeIntervalSince(lastCheck) >= interval + } + + /// Спросить GitHub о последнем выпуске. Без cookies, кеша и номера этой версии в запросе. + public static func fetch() async throws -> Release { + var request = URLRequest(url: endpoint, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 20) + request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") + request.setValue("OffLoadAI", forHTTPHeaderField: "User-Agent") + request.setValue("2022-11-28", forHTTPHeaderField: "X-GitHub-Api-Version") + let session = URLSession(configuration: .ephemeral) + defer { session.finishTasksAndInvalidate() } + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(for: request) + } catch { + throw Failure(message: "Нет связи с GitHub: \(error.localizedDescription)") + } + guard let status = (response as? HTTPURLResponse)?.statusCode, status == 200 else { + throw Failure(message: "GitHub не ответил на вопрос о версии (код \((response as? HTTPURLResponse)?.statusCode ?? 0)).") + } + return try parse(data) + } + + /// «1.2.3» → [1, 2, 3]. Строго — только три числа; иначе допускается хвост сборки: 1.2.3-ci, 1.2.3.beta. + static func numbers(_ text: String, strict: Bool) -> [Int]? { + var head = Substring(text) + if !strict, let cut = text.firstIndex(where: { $0 == "-" || $0 == "+" }) { head = text[.. 3) else { return nil } + var result: [Int] = [] + for part in parts.prefix(3) { + guard !part.isEmpty, part.count <= 6, part.allSatisfy({ $0.isASCII && $0.isNumber }), let value = Int(part) else { return nil } + result.append(value) + } + return result + } +} diff --git a/windows/src/Offload.Checks/All.cs b/windows/src/Offload.Checks/All.cs index 69e4bf4..4e32e67 100644 --- a/windows/src/Offload.Checks/All.cs +++ b/windows/src/Offload.Checks/All.cs @@ -25,6 +25,7 @@ static readonly (string name, Action run)[] Sections = ("safe", ChecksSafe), ("license", ChecksLicense), ("assistant", ChecksAssistant), + ("updates", ChecksUpdate), ]; public static void Run() diff --git a/windows/src/Offload.Checks/ChecksUpdate.cs b/windows/src/Offload.Checks/ChecksUpdate.cs new file mode 100644 index 0000000..9a4de76 --- /dev/null +++ b/windows/src/Offload.Checks/ChecksUpdate.cs @@ -0,0 +1,45 @@ +using Offload.Core; +using static Offload.Checks.Harness; + +namespace Offload.Checks; + +/// Сообщение о новой версии: разбор ответа GitHub, сравнение номеров, раз в сутки — как на Mac. +static partial class All +{ + static void ChecksUpdate() + { + Section("Обновления: ответ GitHub и номера версий", () => + { + static string Release(string fields) => "{" + fields + ",\"html_url\":\"https://evil.example/x\",\"body\":\"…\"}"; + var parsed = UpdateCheck.Parse(Release("\"tag_name\":\"v0.4.1\",\"draft\":false,\"prerelease\":false")); + Check(parsed.Version == "0.4.1", "номер выпуска читается без «v»"); + Check(parsed.Page == "https://github.com/audit0/Offload/releases/tag/v0.4.1", "страница выпуска строится из номера, адрес из ответа не открывается"); + Check(UpdateCheck.Parse(Release("\"tag_name\":\"0.5.0\"")).Version == "0.5.0", "номер и без «v»"); + ExpectError("черновик не считается", () => UpdateCheck.Parse(Release("\"tag_name\":\"v9.0.0\",\"draft\":true")), + e => e is UpdateCheck.UpdateException); + ExpectError("предварительный выпуск не считается", () => UpdateCheck.Parse(Release("\"tag_name\":\"v9.0.0\",\"prerelease\":true")), + e => e is UpdateCheck.UpdateException); + foreach (var tag in new[] { "latest", "v1.2", "v1.2.3.4", "v1.2.3-beta", "v1.x.3", "v١.٢.٣", "v1234567.0.0" }) + ExpectError($"непонятный номер «{tag}» не считается", () => UpdateCheck.Parse(Release($"\"tag_name\":\"{tag}\"")), + e => e is UpdateCheck.UpdateException); + ExpectError("номер не строкой — не считается", () => UpdateCheck.Parse(Release("\"tag_name\":123")), e => e is UpdateCheck.UpdateException); + ExpectError("не JSON — ошибка", () => UpdateCheck.Parse(""), e => e is UpdateCheck.UpdateException); + + Check(UpdateCheck.IsNewer("0.4.1", "0.4.0"), "0.4.1 новее 0.4.0"); + Check(UpdateCheck.IsNewer("0.10.0", "0.9.9"), "номера сравниваются числами, а не строками"); + Check(UpdateCheck.IsNewer("1.0.0", "0.99.99"), "1.0.0 новее 0.99.99"); + Check(!UpdateCheck.IsNewer("0.4.0", "0.4.0"), "та же версия — не новее"); + Check(!UpdateCheck.IsNewer("0.3.9", "0.4.0"), "старая версия — не новее"); + Check(!UpdateCheck.IsNewer("0.4.0", "0.4.0-ci"), "сборка для разработки той же версии — обновляться не нужно"); + Check(UpdateCheck.IsNewer("0.4.1", "0.4.0-ci"), "а следующая версия новее и её"); + Check(!UpdateCheck.IsNewer("9.9.9", "разработка"), "непонятный номер этой версии — молчим"); + + var now = DateTime.UtcNow; + Check(UpdateCheck.IsDue(null, now), "ни разу не спрашивали — пора"); + Check(!UpdateCheck.IsDue(now.AddHours(-1), now), "час назад спрашивали — рано"); + Check(UpdateCheck.IsDue(now.AddHours(-25), now), "больше суток назад — пора"); + Check(UpdateCheck.IsDue(now.AddHours(1), now), "часы перевели назад — проверка не застревает"); + Check(UpdateCheck.Endpoint is { Scheme: "https", Host: "api.github.com" }, "спрашивается только GitHub, по https"); + }); + } +} diff --git a/windows/src/Offload.Core/UpdateCheck.cs b/windows/src/Offload.Core/UpdateCheck.cs new file mode 100644 index 0000000..8a3ad2b --- /dev/null +++ b/windows/src/Offload.Core/UpdateCheck.cs @@ -0,0 +1,95 @@ +using System.Net; +using System.Text.Json; + +namespace Offload.Core; + +/// +/// Сообщение о новой версии — только если человек его включил. +/// +/// Раз в сутки OffLoadAI спрашивает у GitHub номер последнего выпуска. В запросе нет ничего о компьютере, +/// файлах и версии программы: GitHub видит только адрес сети, как при открытии любой страницы. +/// Скачивает и ставит обновление сам человек — той же командой установки, что сверяет архив. +/// +public static class UpdateCheck +{ + public static readonly Uri Endpoint = new("https://api.github.com/repos/audit0/Offload/releases/latest"); + public static readonly TimeSpan Interval = TimeSpan.FromHours(24); + + /// Выпуск: три числа без «v». + public sealed record Release(string Version) + { + /// Страница выпуска строится из номера, а не берётся из ответа: другой адрес программа не откроет. + public string Page => "https://github.com/audit0/Offload/releases/tag/v" + Version; + } + + public sealed class UpdateException(string message) : Exception(message); + + /// Разбор ответа GitHub. Черновики, предварительные выпуски и непонятные номера не считаются. + public static Release Parse(string json) + { + JsonDocument document; + try { document = JsonDocument.Parse(json); } + catch (JsonException) { throw new UpdateException("GitHub ответил не так, как ожидалось."); } + using (document) + { + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object) throw new UpdateException("GitHub ответил не так, как ожидалось."); + bool Flag(string name) => root.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.True; + string? tag = root.TryGetProperty("tag_name", out var node) && node.ValueKind == JsonValueKind.String ? node.GetString() : null; + if (Flag("draft") || Flag("prerelease") || tag == null || Numbers(tag.StartsWith('v') ? tag[1..] : tag, strict: true) is not { } numbers) + throw new UpdateException("У последнего выпуска на GitHub непонятный номер версии."); + return new Release(string.Join('.', numbers)); + } + } + + /// Новее ли , чем . Номер сборки для разработки + /// (0.4.0-ci) сравнивается по трём числам; непонятный номер — не новее: лучше промолчать, чем звать обновляться зря. + public static bool IsNewer(string candidate, string current) + { + if (Numbers(candidate, strict: true) is not { } next || Numbers(current, strict: false) is not { } now) return false; + for (int i = 0; i < 3; i++) + if (next[i] != now[i]) return next[i] > now[i]; + return false; + } + + /// Пора ли спросить снова: раз в сутки. Часы, переведённые назад, проверку не останавливают. + public static bool IsDue(DateTime? lastCheck, DateTime now) => lastCheck is not { } last || now < last || now - last >= Interval; + + /// Спросить GitHub о последнем выпуске. Без cookies и номера этой версии в запросе. + public static async Task FetchAsync(CancellationToken cancel = default) + { + using var handler = new HttpClientHandler { UseCookies = false }; + using var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(20) }; + using var request = new HttpRequestMessage(HttpMethod.Get, Endpoint); + request.Headers.Accept.ParseAdd("application/vnd.github+json"); + request.Headers.UserAgent.ParseAdd("OffLoadAI"); + request.Headers.Add("X-GitHub-Api-Version", "2022-11-28"); + HttpResponseMessage response; + try { response = await http.SendAsync(request, cancel); } + catch (HttpRequestException e) { throw new UpdateException("Нет связи с GitHub: " + e.Message); } + catch (TaskCanceledException) when (!cancel.IsCancellationRequested) { throw new UpdateException("GitHub не ответил вовремя."); } + using (response) + { + if (response.StatusCode != HttpStatusCode.OK) + throw new UpdateException($"GitHub не ответил на вопрос о версии (код {(int)response.StatusCode})."); + return Parse(await response.Content.ReadAsStringAsync(cancel)); + } + } + + /// «1.2.3» → [1, 2, 3]. Строго — только три числа; иначе допускается хвост сборки: 1.2.3-ci, 1.2.3.4. + internal static int[]? Numbers(string text, bool strict) + { + var head = text; + if (!strict && text.IndexOfAny(['-', '+']) is var cut and >= 0) head = text[..cut]; + var parts = head.Split('.'); + if (parts.Length != 3 && (strict || parts.Length < 3)) return null; + var result = new int[3]; + for (int i = 0; i < 3; i++) + { + var part = parts[i]; + if (part.Length is 0 or > 6 || !part.All(char.IsAsciiDigit)) return null; + result[i] = int.Parse(part); + } + return result; + } +} diff --git a/windows/src/Offload/MainWindow.xaml b/windows/src/Offload/MainWindow.xaml index f09eb64..ce86fff 100644 --- a/windows/src/Offload/MainWindow.xaml +++ b/windows/src/Offload/MainWindow.xaml @@ -19,6 +19,7 @@ +