From 2a127050ab6b75bd21ab234dcedfdf3b0a5d5509 Mon Sep 17 00:00:00 2001 From: q <301774981+audit0@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:58:34 +0300 Subject: [PATCH 1/3] =?UTF-8?q?Offload=20Pro:=20=D0=B1=D0=B5=D1=81=D0=BF?= =?UTF-8?q?=D0=BB=D0=B0=D1=82=D0=BD=D0=BE=D0=B5=20=D1=8F=D0=B4=D1=80=D0=BE?= =?UTF-8?q?=20=D0=B8=20=D1=80=D0=B0=D0=B7=D0=BE=D0=B2=D0=B0=D1=8F=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BA=D1=83=D0=BF=D0=BA=D0=B0=20=D1=81=20=D0=BA=D0=BB?= =?UTF-8?q?=D1=8E=D1=87=D0=BE=D0=BC,=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D1=8F=D0=B5=D0=BC=D1=8B=D0=BC=20=D0=B1=D0=B5=D0=B7=20?= =?UTF-8?q?=D1=81=D0=B5=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Бесплатно всегда: сейф, перенос со сверкой, возврат перенесённого и томов Docker, разбор (мусор, Docker, установщики, крупное в сейф), ключи в сейф, восстановление из iCloud. В Pro: лишние копии, привычки, бэкап проектов, тома Docker в сейф. Всё найденное видно и без ключа; ограничено только новое «да», начатое доделывается. - Ключ OFFLOAD-… подписан Ed25519, проверяется встроенным открытым ключом (CryptoKit) - 14 дней пробы; кто пользовался Offload раньше — всё открыто навсегда - Ключ открывает версии, вышедшие до конца года обновлений: день выхода пишется в Info.plist - Окно «Offload Pro» (меню и строка над панелью диска), ввод и удаление ключа - swift run OffloadLicense keygen/issue/verify — выпуск ключей с журналом выданных - Проверки: подделка, чужая подпись, мусор, переносы строк, сроки, проба - MONETIZATION.md — модель, цена, оплата, метрики, запуск; раздел Offload Pro в README --- MONETIZATION.md | 93 ++++++++++ Package.swift | 3 + README.md | 22 +++ SECURITY.md | 1 + Sources/Offload/AppModel.swift | 2 + Sources/Offload/CleanupModel.swift | 16 +- Sources/Offload/Models.swift | 3 + Sources/Offload/OffloadApp.swift | 14 ++ Sources/Offload/ProModel.swift | 123 +++++++++++++ Sources/Offload/Views/BackupView.swift | 5 +- Sources/Offload/Views/CleanupView.swift | 29 ++- Sources/Offload/Views/ContentView.swift | 9 +- Sources/Offload/Views/DockerView.swift | 9 +- Sources/Offload/Views/ProView.swift | 155 ++++++++++++++++ Sources/OffloadChecks/ChecksLicense.swift | 96 ++++++++++ Sources/OffloadChecks/main.swift | 1 + Sources/OffloadCore/CleanupQuestions.swift | 11 ++ Sources/OffloadCore/License.swift | 197 +++++++++++++++++++++ Sources/OffloadLicense/main.swift | 101 +++++++++++ scripts/build-app.sh | 5 + 20 files changed, 885 insertions(+), 10 deletions(-) create mode 100644 MONETIZATION.md create mode 100644 Sources/Offload/ProModel.swift create mode 100644 Sources/Offload/Views/ProView.swift create mode 100644 Sources/OffloadChecks/ChecksLicense.swift create mode 100644 Sources/OffloadCore/License.swift create mode 100644 Sources/OffloadLicense/main.swift diff --git a/MONETIZATION.md b/MONETIZATION.md new file mode 100644 index 0000000..35218cd --- /dev/null +++ b/MONETIZATION.md @@ -0,0 +1,93 @@ +# Монетизация Offload + +План от 27.09.2026. Что уже сделано в коде — в конце. + +## Исходные условия + +- **Продукт** — офлайн-утилита для Mac (и Windows): освободить место без риска потерять данные, зашифрованный сейф на внешнем диске, бэкап проектов и ключей. Никаких серверов и сетевых запросов, код под MIT, интерфейс на русском. +- **Кто платит.** Владельцы Mac с диском на 256–512 ГБ, которым тесно, в первую очередь разработчики: Docker, Xcode, проекты, ключи. Им важны деньги за время и за спокойствие, а не «ещё одна чистилка». +- **Конкуренты.** CleanMyMac — около $40 в год (в России официально не продаётся), DaisyDisk — $10 разово, Gemini 2 (поиск дубликатов) — около $20. Бесплатные анализаторы (GrandPerspective, OmniDiskSweeper) ничего не переносят и не проверяют. +- **Ограничения.** Сети в программе нет и не будет: это часть обещания в SECURITY.md. Продавец — частное лицо, гражданин РФ, живёт в Таиланде: Stripe напрямую недоступен, из MoR-сервисов реалистичен Polar (см. `AuditAI/LEGAL.md`). Покупатели в основном из России: иностранные карты у многих не работают. + +## Модель: бесплатное ядро + Offload Pro разовой покупкой + +**Почему не подписка.** Утилита работает офлайн и не несёт серверных расходов; подписка на программу, которую открывают раз в месяц, отпугивает, а русскоязычная аудитория подписки на утилиты не любит особенно. **Почему не пожертвования.** Обычно платит меньше 0,5% пользователей, этого не хватит даже на подпись Apple. **Почему не реклама и не данные** — очевидно: это программа, которой доверяют пароль от сейфа. + +**Разовая покупка с годом обновлений** (как у Sketch и Tower): ключ работает всегда; версии, вышедшие в течение года, открываются им навсегда; дальше — продление за полцены или остаётесь на последней версии того года. Это честно для покупателя и даёт повторную выручку от тех, кому нужны новые версии. + +### Что бесплатно всегда, а что в Pro + +Главный принцип: **Pro никогда не стоит между человеком и его данными.** Всё, что нужно, чтобы убрать данные в безопасное место и вернуть их обратно, — бесплатно навсегда, в том числе после конца пробы и с просроченным ключом. + +| Бесплатно всегда | Offload Pro | +|---|---| +| Сейф: создание, открытие, смена пароля, копия заголовка | **Лишние копии** — поиск одинаковых файлов по SHA-256 | +| Перенос в сейф со сверкой, «Освободить место» | **Привычки** — похожее само попадает в нужный вопрос | +| Возврат перенесённого, возврат тома Docker | **Бэкап проектов** — обновляемая копия в сейф | +| Разбор: мусор, Docker, установщики, крупное в сейф | **Тома Docker в сейф** — упаковка со сверкой | +| Ключи и токены в сейф, восстановление из iCloud | | + +Логика раздела: бесплатное освобождает место и держит данные в безопасности, Pro — это инструменты для тех, у кого Mac — рабочий инструмент. Всё найденное видно и без Pro: вопрос «Удалить лишние копии? 4,8 ГБ» показывается, но на «да» открывается окно Pro. Это и есть момент продажи, когда ценность видна в гигабайтах. + +Если распределение не понравится, его меняют в одном месте — `ProFeature` и `CleanupQuestion.Kind.proFeature` в `Sources/OffloadCore/License.swift` и `CleanupQuestions.swift`. + +### Цена + +- **1 490 ₽ или $19** разово, год обновлений. Продление — **750 ₽ / $9**. +- **14 дней пробы** всего Pro с первого запуска версии с Pro — без ключа и без регистрации. +- **Ранние пользователи** (у кого есть журнал, база решений или настройки от прежних версий) получают всё открытое навсегда: забирать то, чем человек уже пользовался бесплатно, нечестно, и это же лучшие распространители. +- На одного человека — на все его Mac. Команды и компании — тот же ключ на каждого, скидка по договорённости. + +Ориентиры: вдвое дешевле года CleanMyMac, на уровне Gemini 2. Поднять цену проще, чем опустить: пересмотреть после первых 50 продаж. + +### Защита от копирования — честная + +Ключ — строка `OFFLOAD-…`, подписанная Ed25519; программа проверяет подпись встроенным открытым ключом, без сети. Подделать ключ нельзя, но можно: поделиться своим ключом, сбросить пробу, удалив настройки, или собрать программу из исходников без проверки — код под MIT. Это сознательно: Offload продаётся тем, кому он полезен и кто готов заплатить автору, а DRM в программе для безопасности данных стоил бы доверия дороже, чем сэкономил бы. В ключе — имя покупателя, оно видно в окне «Offload Pro»: делиться ключом со своим именем охотников мало. + +Если распространение сборок без проверки станет заметным, есть рычаг, который решает владелец: будущие версии под Functional Source License (исходники открыты, коммерческое распространение запрещено два года, потом MIT). Уже выпущенные версии остаются под MIT. + +## Оплата и выдача ключа + +**Этап 1 — вручную, пока продаж меньше ~10 в день.** Выдача ключа — одна команда: + +```bash +swift run OffloadLicense issue "Имя покупателя" --note "USDT, заказ 17" +``` + +Ключ печатается и дописывается в `~/.config/offload-license/issued.tsv` (номер, имя, даты, заметка) — по нему возвраты и замены. Способы оплаты — решение владельца; реалистичные: + +1. **Криптовалюта** (USDT, TON) по счёту — так же, как услуги AuditAI. Работает для России и остального мира. +2. **Карты мира** — Polar: Таиланд в списке стран продавцов, софт разрешён (в отличие от ручных услуг). Заявка по AuditAI уже подаётся; Offload — отдельный продукт в той же организации или отдельная организация. Polar сам выдаёт ключи, но свои, неподписанные; нам нужен наш — выдаём вручную по письму о покупке, потом webhook. +3. **Рубли** — через Telegram-бота с оплатой в Stars или счётом; решить, какой канал для рублей у вас уже есть. + +**Этап 2 — автоматическая выдача**: webhook Polar или платёжного бота вызывает `OffloadLicense issue` и отправляет ключ письмом. Для этого закрытый ключ окажется на сервере — делать это стоит, только когда ручная выдача начнёт мешать, и с отдельным ревью безопасности. + +**Возврат** — 14 дней без вопросов. Отозвать ключ офлайн нельзя (программа никуда не ходит); это принимаем: возвратов у утилиты за $19 единицы. + +## Как понять, работает ли + +Телеметрии нет и не будет. Считаем снаружи программы: + +- скачивания релизов на GitHub (`gh api repos/audit0/Offload/releases` → `download_count`) — верх воронки; +- выданные ключи из `issued.tsv` — низ воронки; их отношение — конверсия; +- продления через год — удержание. + +Ориентир: 2–4% скачавших покупают. Меньше 1% через месяц после выхода — пересмотреть, что в Pro (скорее всего, в Pro должен уйти более частый сценарий) или цену. + +## Запуск + +1. Сделать копию закрытого ключа `~/.config/offload-license/signing.key` в сейф. Потеряется — выданные ключи продолжат работать, но новых не выпустить; утечёт — кто угодно выпустит ключи, и придётся менять открытый ключ в новой версии. +2. Выбрать способы оплаты и вписать их в раздел README «Offload Pro → Как купить» (кнопка «Купить…» в программе ведёт туда). +3. Поднять версию до 0.4.0, выпустить релиз. Анонс в Telegram-канале по шаблонам `.hub/templates/telegram`: главное — «всё, чем вы пользовались, остаётся бесплатным навсегда». +4. Перенести то же на Windows (формат ключа тот же; Ed25519 в .NET — через NSec или BouncyCastle), иначе ключ, купленный на Mac, на Windows не пригодится. +5. Через месяц — посмотреть конверсию и решить про цену и состав Pro. + +## Что сделано в коде (Mac) + +- `Sources/OffloadCore/License.swift` — формат ключа, проверка подписи Ed25519 (CryptoKit), что входит в Pro, расчёт статуса: ключ, проба, ранний пользователь, просроченные обновления. +- `Sources/OffloadLicense` — выпуск ключей: `keygen`, `issue`, `verify`, журнал выданных. +- `Sources/Offload/ProModel.swift`, `Views/ProView.swift` — статус на этом Mac, окно «Offload Pro» (меню «Offload» → «Offload Pro…» и строка над панелью диска), ввод и удаление ключа. +- Ограничения на «да»: лишние копии и проекты в бэкап в разборе, «Обновить бэкап», «Архивировать» в Docker, привычки. «Разрешить всё» вопросы из Pro пропускает. Начатое не прерывается; возврат не ограничен нигде. +- `scripts/build-app.sh` пишет в Info.plist день выхода версии (`OffloadReleaseDate`, по дате последнего коммита). +- `OffloadChecks` — проверки ключа: подделка, чужая подпись, мусор, переносы строк, сроки, проба. +- Демо-режим: `OFFLOAD_DEMO_PRO=free` или `trial` показывает программу без ключа; `OFFLOAD_SNAPSHOT_PRO=1` снимает окно «Offload Pro». diff --git a/Package.swift b/Package.swift index 184acf1..116f11f 100644 --- a/Package.swift +++ b/Package.swift @@ -14,6 +14,9 @@ let package = Package( // XCTest и swift-testing есть только в Xcode, поэтому проверки — отдельная программа: // swift run OffloadChecks .executableTarget(name: "OffloadChecks", dependencies: ["OffloadCore"]), + // Выпуск ключей Offload Pro — только у автора, с закрытым ключом вне репозитория: + // swift run OffloadLicense issue "Имя" + .executableTarget(name: "OffloadLicense", dependencies: ["OffloadCore"]), ], swiftLanguageModes: [.v5] ) diff --git a/README.md b/README.md index 61c240c..028429c 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,26 @@ less install.sh && zsh install.sh Подробности, модель угроз и известные ограничения — в [SECURITY.md](SECURITY.md). +## Offload Pro + +Всё, что нужно, чтобы освободить место и не потерять данные, бесплатно навсегда: сейф, перенос со сверкой, возврат перенесённого, разбор (мусор, Docker, установщики, крупное в сейф), ключи и токены в сейф, восстановление из iCloud. Вернуть своё Offload не мешает никогда — ни без ключа, ни после пробы. + +Offload Pro добавляет инструменты для тех, у кого Mac — рабочий инструмент: + +- **Лишние копии** — одинаковые файлы по SHA-256; одна копия остаётся всегда, лишние уходят в Корзину. +- **Привычки** — Offload учится на ваших ответах и сам кладёт похожее в нужный вопрос. +- **Бэкап проектов** — обновляемая копия папок с проектами в сейф. +- **Тома Docker в сейф** — упаковка неиспользуемых томов со сверкой каждого файла. + +**1 490 ₽ или $19, один раз.** Ключ работает всегда; новые версии — год, дальше продление за полцены, а не продлили — остаётся последняя версия того года. Первые 14 дней Pro открыт целиком, без ключа и регистрации. Если вы пользовались Offload до появления Pro, всё открыто навсегда. + +Ключ проверяется на самом Mac подписью, без сети: Offload по-прежнему не ходит в интернет. Ввести ключ — меню «Offload» → «Offload Pro…». + +### Как купить + + +Способы оплаты появятся здесь с выходом версии 0.4. + ## Полный доступ к диску Без него Offload не видит «Документы», «Рабочий стол», Почту и данные многих приложений. Откройте «Системные настройки → Конфиденциальность и безопасность → Полный доступ к диску», включите Offload и перезапустите его. Кнопка для перехода в настройки есть в самом приложении. @@ -192,6 +212,8 @@ GitHub Actions прогонит проверки, соберёт universal-сб Offload 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; Offload 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 — the app still makes no network requests. + 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. Install: diff --git a/SECURITY.md b/SECURITY.md index 3b01254..3e35a56 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -49,6 +49,7 @@ Offload переносит и удаляет файлы, поэтому безо - **Строки в Swift нельзя надёжно затереть в памяти**, поэтому пароль контейнера может оставаться в памяти процесса до его завершения. - **Расширенные атрибуты** (метки Finder, карантин) и жёсткие ссылки при переносе не сохраняются — иначе на exFAT появляются служебные файлы `._*`. Offload пересчитывает такие объекты заранее и показывает это оговоркой в окне переноса, до подтверждения удаления оригинала. - **Список контрольных сумм рядом с архивом** ловит порчу носителя, но не подмену: он лежит на том же недоверенном диске и правится вместе с архивом. +- **Ключ Offload Pro** проверяется на Mac подписью Ed25519 встроенным открытым ключом, без сети: при покупке и запуске Offload никуда не обращается. Ключ хранится в настройках программы открыто: в нём только номер, имя, которое назвал покупатель, и даты. Отсутствие ключа и конец пробы никогда не закрывают сейф, возврат перенесённого, возврат тома Docker и восстановление из iCloud — ограничивается только новое «да» на возможности Pro, начатое доделывается. Защита от копирования намеренно честная: пробу можно сбросить, а программу — собрать из исходников без проверки. - Offload не защищает от других программ, запущенных под той же учётной записью. --- diff --git a/Sources/Offload/AppModel.swift b/Sources/Offload/AppModel.swift index a565c6e..e89ca17 100644 --- a/Sources/Offload/AppModel.swift +++ b/Sources/Offload/AppModel.swift @@ -57,6 +57,8 @@ final class AppModel { var destinationID: String? private(set) var hasFullDiskAccess = FullDiskAccess.isGranted + /// Первым: он смотрит, пользовались ли Offload раньше, — до того как остальные модели заведут свои файлы. + let pro = ProModel() let rules = SafetyRules() let safe = SafeModel() let overview = OverviewModel() diff --git a/Sources/Offload/CleanupModel.swift b/Sources/Offload/CleanupModel.swift index eb61c0f..1d9fe05 100644 --- a/Sources/Offload/CleanupModel.swift +++ b/Sources/Offload/CleanupModel.swift @@ -201,6 +201,12 @@ final class CleanupModel { kind == .module(.safe) && app.safeVolume == nil && !Demo.isOn } + /// Вопрос из Offload Pro, а Pro на этом Mac нет: «да» открывает окно Pro, «не сейчас» работает как всегда. + func isLocked(_ kind: CleanupQuestion.Kind, app: AppModel) -> Bool { + guard let feature = kind.proFeature else { return false } + return !app.pro.allows(feature) + } + /// Ответ на вопрос. «Да» ставит его в очередь, и он выполняется, как только дойдёт черёд; /// «не сейчас» ничего не запоминает — в следующий раз спрошу снова. func answer(_ kind: CleanupQuestion.Kind, yes: Bool, app: AppModel) { @@ -212,6 +218,10 @@ final class CleanupModel { return } guard !needsSafe(kind, app: app) else { return } + if let feature = kind.proFeature, !app.pro.allows(feature) { + app.pro.offer(feature) + return + } answers[kind] = .queued queue.append(kind) pump(app: app) @@ -221,7 +231,8 @@ final class CleanupModel { /// Вопрос о сейфе ждёт, пока сейф закрыт; ответ — остался ли он ждать пароля. @discardableResult func answerAll(app: AppModel) -> Bool { - for question in asking where question.answeredTogether && !needsSafe(question.kind, app: app) { + for question in asking where question.answeredTogether && !needsSafe(question.kind, app: app) + && !isLocked(question.kind, app: app) { answer(question.kind, yes: true, app: app) } return asking.contains { $0.kind == .module(.safe) } @@ -573,7 +584,8 @@ final class CleanupModel { clear() let rules = app.rules let memory = (try? store?.lastDecisions()) ?? [:] - let habits = (try? store?.history()).map { HabitModel(history: $0, home: rules.home) } + // Привычки — в Pro. Решения запоминаются и без него: купил — привычки действуют сразу. + let habits = app.pro.allows(.habits) ? (try? store?.history()).map { HabitModel(history: $0, home: rules.home) } : nil let ignored = Set((try? store?.ignoredPaths()) ?? []) if Demo.isOn { show(Demo.cleanupSuggestions(memory: memory, habits: habits), docker: Demo.dockerUsage, idle: nil) diff --git a/Sources/Offload/Models.swift b/Sources/Offload/Models.swift index a2f3d55..e28ecac 100644 --- a/Sources/Offload/Models.swift +++ b/Sources/Offload/Models.swift @@ -433,6 +433,7 @@ final class BackupModel { } func run(on volume: VolumeInfo, app: AppModel) { + guard app.pro.allows(.projectBackup) else { return app.pro.offer(.projectBackup) } let token = CancelToken() let operationID = app.beginOperation { token.cancel() } tokens[operationID] = token @@ -677,6 +678,8 @@ final class DockerModel { func archiveSelected(to volume: VolumeInfo, app: AppModel) { let names = volumes.filter { selection.contains($0.name) }.map(\.name) guard !names.isEmpty else { return } + // Упаковать — в Pro; вернуть том из архива можно всегда. + guard app.pro.allows(.dockerVolumes) else { return app.pro.offer(.dockerVolumes) } guard !Demo.isOn else { messages = ["Демонстрация: тома не упакованы, Docker не тронут."] return diff --git a/Sources/Offload/OffloadApp.swift b/Sources/Offload/OffloadApp.swift index eb54cc3..09e6099 100644 --- a/Sources/Offload/OffloadApp.swift +++ b/Sources/Offload/OffloadApp.swift @@ -22,6 +22,7 @@ struct OffloadApp: App { .commands { CommandGroup(replacing: .appInfo) { Button("О программе Offload") { AboutPanel.show() } + Button("Offload Pro…") { model.pro.offer() } } // Как «Dismount All» в VeraCrypt: закрыть сейф из любого места одним сочетанием. CommandMenu("Сейф") { @@ -163,6 +164,19 @@ enum Snapshots { } log("снимок \(section.rawValue)") } + // OFFLOAD_SNAPSHOT_PRO=1 — ещё и окно «Offload Pro», открытое «да» на лишние копии. + if ProcessInfo.processInfo.environment["OFFLOAD_SNAPSHOT_PRO"] == "1" { + model.pro.offer(.duplicates) + try? await Task.sleep(for: .seconds(2)) + if let sheet = window.attachedSheet ?? NSApp.windows.first(where: { $0.sheetParent == window || ($0 != window && $0.isVisible && $0.title.isEmpty) }) { + save(sheet, to: directory.appendingPathComponent("pro.png")) + } else { + log("окно «Offload Pro» не нашлось") + } + // С открытым листом программа не завершается: сначала закрыть. + model.pro.isPresented = false + try? await Task.sleep(for: .seconds(1)) + } NSApp.terminate(nil) } diff --git a/Sources/Offload/ProModel.swift b/Sources/Offload/ProModel.swift new file mode 100644 index 0000000..fb707f1 --- /dev/null +++ b/Sources/Offload/ProModel.swift @@ -0,0 +1,123 @@ +import AppKit +import Foundation +import Observation +import OffloadCore + +/// Offload Pro на этом Mac: ключ, пробные две недели, ранние пользователи. +/// +/// Ключ проверяется подписью, без сети. Всё хранится в настройках программы; сбросить пробу, +/// удалив их, можно — Pro держится на честности тех, кому Offload полезен, а не на защите. +@MainActor +@Observable +final class ProModel { + /// Где купить: раздел README с ценами и способами оплаты. + static let purchaseURL = URL(string: "https://github.com/audit0/Offload#offload-pro")! + + private(set) var status: ProStatus = .free + private(set) var license: License? + /// Ключ не подошёл — почему, одной фразой. + var keyProblem: String? + /// Окно «Offload Pro» и то, ради чего его открыли (nil — просто из меню). + var isPresented = false + private(set) var reason: ProFeature? + + private static let licenseKey = "pro.license" + private static let trialKey = "pro.trialStarted" + private static let earlyKey = "pro.early" + + /// День выхода этой версии: ключ открывает версии, вышедшие до конца его обновлений. + /// Пишется в Info.plist при сборке; у сборки из исходников его нет — тогда ключ подходит любой. + static let releaseDate: Date? = (Bundle.main.infoDictionary?["OffloadReleaseDate"] as? String).flatMap(LicenseCodec.day) + + init() { + guard !Demo.isOn else { + // Снимки для README показывают всё, что умеет программа. OFFLOAD_DEMO_PRO=free или trial — + // как это выглядит без ключа. + switch ProcessInfo.processInfo.environment["OFFLOAD_DEMO_PRO"] { + case "free": status = .free + case "trial": status = .trial(daysLeft: 9) + default: status = .early + } + return + } + let defaults = UserDefaults.standard + if defaults.object(forKey: Self.earlyKey) == nil { + // Первый запуск версии с Pro. Кто пользовался Offload раньше, получает всё, что было, + // навсегда: забирать то, чем человек уже пользовался бесплатно, нечестно. + let early = Self.usedBefore() + defaults.set(early, forKey: Self.earlyKey) + if !early { defaults.set(Date(), forKey: Self.trialKey) } + } + if let text = defaults.string(forKey: Self.licenseKey) { + license = try? LicenseCodec.verify(text) + } + refresh() + } + + func allows(_ feature: ProFeature) -> Bool { status.isPro } + + /// Открыть окно «Offload Pro» — из-за конкретной возможности или просто так. + func offer(_ feature: ProFeature? = nil) { + reason = feature + keyProblem = nil + isPresented = true + } + + func refresh() { + guard !Demo.isOn else { return } + let defaults = UserDefaults.standard + status = ProStatus.resolve(license: license, release: Self.releaseDate, + early: defaults.bool(forKey: Self.earlyKey), + trialStarted: defaults.object(forKey: Self.trialKey) as? Date, now: Date()) + } + + /// Проверить и запомнить ключ. Ответ — подошёл ли. + @discardableResult + func activate(_ text: String) -> Bool { + do { + let checked = try LicenseCodec.verify(text) + guard checked.covers(release: Self.releaseDate) else { + keyProblem = "Ключ верный, но его обновления закончились \(Self.day(checked.updatesUntil)), а эта версия вышла позже. Продлите ключ — или поставьте версию, вышедшую до этой даты: с ней ключ работает всегда." + return false + } + license = checked + keyProblem = nil + if !Demo.isOn { UserDefaults.standard.set(text.filter { !$0.isWhitespace }, forKey: Self.licenseKey) } + refresh() + return true + } catch { + keyProblem = error.localizedDescription + return false + } + } + + /// Убрать ключ с этого Mac — например, перед продажей Mac. Ключ остаётся у владельца. + func removeLicense() { + license = nil + if !Demo.isOn { UserDefaults.standard.removeObject(forKey: Self.licenseKey) } + refresh() + } + + /// Одной строкой — для боковой колонки. + var summary: String { + switch status { + case .licensed: return "Offload Pro" + case .early: return "Pro — ранний пользователь" + case .trial(let days): return "Pro: пробный, \(days) \(pluralRu(days, "день", "дня", "дней"))" + case .expired: return "Pro: продлите ключ" + case .free: return "Бесплатная версия" + } + } + + static func day(_ date: Date) -> String { + date.formatted(.dateTime.day().month(.wide).year().locale(Locale(identifier: "ru_RU"))) + } + + /// Остались ли следы прежних версий: база решений, журнал переносов или настройки. + private static func usedBefore() -> Bool { + let fm = FileManager.default + if fm.fileExists(atPath: DecisionStore.defaultURL.path) || fm.fileExists(atPath: Journal.localURL.path) { return true } + let defaults = UserDefaults.standard + return ["storeMode", "backup.sources", "backup.destination"].contains { defaults.object(forKey: $0) != nil } + } +} diff --git a/Sources/Offload/Views/BackupView.swift b/Sources/Offload/Views/BackupView.swift index 865a7e3..40fc713 100644 --- a/Sources/Offload/Views/BackupView.swift +++ b/Sources/Offload/Views/BackupView.swift @@ -107,7 +107,10 @@ struct BackupView: View { } } else { Button { model.run(on: volume, app: app) } label: { - Label("Обновить бэкап", systemImage: "arrow.triangle.2.circlepath") + HStack(spacing: 6) { + Label("Обновить бэкап", systemImage: "arrow.triangle.2.circlepath") + if !app.pro.allows(.projectBackup) { ProTag() } + } } .prominentButton() .disabled(model.sources.isEmpty) diff --git a/Sources/Offload/Views/CleanupView.swift b/Sources/Offload/Views/CleanupView.swift index c321768..62832e2 100644 --- a/Sources/Offload/Views/CleanupView.swift +++ b/Sources/Offload/Views/CleanupView.swift @@ -319,6 +319,17 @@ struct CleanupView: View { let model = app.cleanup return CardSection(title: "Чему научился", footer: "Учусь только на этом Mac и только на ваших ответах. Что вы вернули из Корзины, больше не предлагаю; похожее на то, что вы обычно убираете в сейф, добавляю в вопрос о сейфе. Сам ничего не делаю — только спрашиваю.") { + if !app.pro.allows(.habits) { + HStack(spacing: 12) { + IconTile(systemImage: "sparkles", tone: .neutral) + Text("Привычки действуют в Offload Pro. Ваши ответы запоминаются и сейчас — с Pro похожее сразу начнёт попадать в нужный вопрос.") + .font(.callout).foregroundStyle(Theme.muted).fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 12) + Button("Подробнее…") { app.pro.offer(.habits) } + } + .rowPadding() + RowDivider(inset: 54) + } if model.habits.isEmpty { HStack(alignment: .top, spacing: 12) { IconTile(systemImage: "sparkles", tone: .neutral) @@ -505,7 +516,8 @@ struct CleanupView: View { private var summary: some View { let model = app.cleanup let open = model.asking - let together = open.filter(\.answeredTogether) + // Вопросы из Pro без ключа «Разрешить всё» не отвечает — и в его сумму они не входят. + let together = open.filter { $0.answeredTogether && !model.isLocked($0.kind, app: app) } let togetherBytes = together.reduce(Int64(0)) { $0 + $1.bytes } let freed = max(0, model.freed ?? 0) return Card(spacing: 12) { @@ -540,7 +552,7 @@ struct CleanupView: View { // Без Return: одно нажатие отвечало «да» на все вопросы сразу, включая безвозвратную // очистку Docker, — хотя человек мог нажать Return, ещё не прочитав их. .help(open.count > together.count - ? "Ответить «да» на все вопросы, кроме установщиков: их удаляю только по отдельному ответу" + ? "Ответить «да» на все вопросы, кроме установщиков (их удаляю только по отдельному ответу) и вопросов из Offload Pro" : "Ответить «да» на все вопросы") } if !model.isSettled { @@ -693,9 +705,16 @@ struct QuestionCard: View { Spacer(minLength: 12) Button(question.noTitle, action: no) .controlSize(.large) - Button(question.yesTitle, action: yes) - .prominentButton() - .controlSize(.large) + Button(action: yes) { + if app.cleanup.isLocked(question.kind, app: app) { + HStack(spacing: 6) { Text(question.yesTitle); ProTag() } + } else { + Text(question.yesTitle) + } + } + .prominentButton() + .controlSize(.large) + .help(app.cleanup.isLocked(question.kind, app: app) ? "Нужен Offload Pro — откроется окно с ценой и ключом" : "") } case .queued: HStack(spacing: 10) { diff --git a/Sources/Offload/Views/ContentView.swift b/Sources/Offload/Views/ContentView.swift index 6cefaa5..7a8f7c8 100644 --- a/Sources/Offload/Views/ContentView.swift +++ b/Sources/Offload/Views/ContentView.swift @@ -7,6 +7,7 @@ struct ContentView: View { var body: some View { @Bindable var app = app @Bindable var safe = app.safe + @Bindable var pro = app.pro NavigationSplitView { // Своя колонка вместо List: выделение у List macOS рисует системным синим, // а здесь оно чёрно-белое — светло-серая подложка и жирный текст. @@ -23,7 +24,10 @@ struct ContentView: View { .modifier(SidebarBackground()) .navigationSplitViewColumnWidth(min: 240, ideal: 260) .safeAreaInset(edge: .bottom) { - SafeStatusPanel().padding(10) + VStack(spacing: 4) { + ProSidebarRow().padding(.horizontal, 10) + SafeStatusPanel().padding([.horizontal, .bottom], 10) + } } } detail: { // GeometryReader: иначе NavigationSplitView на macOS берёт идеальную высоту содержимого @@ -53,6 +57,9 @@ struct ContentView: View { .glassButtons() // Сменили диск — перечитываем, есть ли на нём сейф и открыт ли он. .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() } // Закрыть сейф не дали открытые в нём файлы — откуда бы ни закрывали: из панели, меню или раздела. .alert("Сейф не закрывается", isPresented: $safe.closeBlocked) { Button("Закрыть принудительно", role: .destructive) { app.safe.close(app: app, force: true) } diff --git a/Sources/Offload/Views/DockerView.swift b/Sources/Offload/Views/DockerView.swift index 865453f..823a983 100644 --- a/Sources/Offload/Views/DockerView.swift +++ b/Sources/Offload/Views/DockerView.swift @@ -126,7 +126,14 @@ struct DockerView: View { } Button { model.reload(app: app) } label: { Label("Обновить", systemImage: "arrow.clockwise") } .disabled(model.busy != nil) - Button { confirmArchive = true } label: { Label("Архивировать на диск…", systemImage: "archivebox") } + Button { + if app.pro.allows(.dockerVolumes) { confirmArchive = true } else { app.pro.offer(.dockerVolumes) } + } label: { + HStack(spacing: 6) { + Label("Архивировать на диск…", systemImage: "archivebox") + if !app.pro.allows(.dockerVolumes) { ProTag() } + } + } .prominentButton() .disabled(model.selection.isEmpty || model.busy != nil || app.target == nil) .help(app.targetProblem ?? "Упаковать выбранные тома и убрать их из Docker") diff --git a/Sources/Offload/Views/ProView.swift b/Sources/Offload/Views/ProView.swift new file mode 100644 index 0000000..7545b2a --- /dev/null +++ b/Sources/Offload/Views/ProView.swift @@ -0,0 +1,155 @@ +import OffloadCore +import SwiftUI + +/// Окно «Offload Pro»: что в нём, что бесплатно всегда, цена, ключ. +struct ProSheet: View { + @Environment(AppModel.self) private var app + @Environment(\.dismiss) private var dismiss + @State private var key = "" + + /// Цена — одной строкой здесь и в README («Offload Pro»). + static let price = "1 490 ₽ или $19 — один раз" + static let terms = "Ключ работает всегда. Новые версии — год, дальше продление за полцены; не продлили — остаётся последняя версия того года." + + var body: some View { + let pro = app.pro + SheetLayout(systemImage: "sparkles", title: "Offload Pro", subtitle: subtitle, width: 540) { + if let reason = pro.reason, !pro.status.isPro { + Notice(.info, "«\(reason.title)» — в Offload Pro. \(reasonTail)") + } + VStack(spacing: 0) { + ForEach(ProFeature.allCases, id: \.self) { feature in + HStack(alignment: .top, spacing: 12) { + IconTile(systemImage: feature.symbol, tone: pro.status.isPro ? .good : .brand) + VStack(alignment: .leading, spacing: 2) { + Text(feature.title).fontWeight(.medium) + Text(feature.detail).font(.callout).foregroundStyle(Theme.muted) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 8) + if pro.status.isPro { + Image(systemName: "checkmark").foregroundStyle(Theme.ok).accessibilityLabel("Открыто") + } + } + .padding(.vertical, 8) + if feature != ProFeature.allCases.last { RowDivider(inset: 40) } + } + } + Text("Бесплатно всегда: сейф, перенос со сверкой, возврат перенесённого, очистка мусора и Docker, старые установщики, ключи и токены в сейф, восстановление из iCloud. Вернуть своё Offload не мешает никогда — ни без ключа, ни после пробы.") + .font(.callout).foregroundStyle(Theme.muted).fixedSize(horizontal: false, vertical: true) + licenseBlock + } actions: { + if pro.status.isPro { + if case .licensed = pro.status {} else { + Button("Купить…") { NSWorkspace.shared.open(ProModel.purchaseURL) } + } + Button("Готово") { dismiss() } + .prominentButton() + .keyboardShortcut(.defaultAction) + } else { + Button("Закрыть") { dismiss() } + .keyboardShortcut(.cancelAction) + Button("Купить…") { NSWorkspace.shared.open(ProModel.purchaseURL) } + .prominentButton() + } + } + .onAppear { pro.refresh() } + } + + private var subtitle: String { + switch app.pro.status { + case .licensed(let license): return "Ключ на имя «\(license.name)»" + case .early: return "Вы пользовались Offload до Pro — всё открыто навсегда" + case .trial(let days): return "Пробный период: осталось \(days) \(pluralRu(days, "день", "дня", "дней"))" + case .expired: return "Обновления по ключу закончились" + case .free: return Self.price + } + } + + private var reasonTail: String { + switch app.pro.status { + case .expired: return "Ключ не открывает эту версию — продлите его." + default: return "Пробные две недели закончились; всё найденное по-прежнему видно, а «не сейчас» работает как всегда." + } + } + + @ViewBuilder + private var licenseBlock: some View { + let pro = app.pro + switch pro.status { + case .licensed(let license): + HStack(alignment: .firstTextBaseline) { + Text("Ключ №\(license.id). Обновления до \(ProModel.day(license.updatesUntil)).") + .font(.callout).foregroundStyle(Theme.muted) + Spacer(minLength: 8) + Button("Убрать ключ с этого Mac") { pro.removeLicense() } + .buttonStyle(InkLinkStyle()) + .help("Например, перед продажей Mac. Ключ остаётся вашим — введите его на новом Mac.") + } + default: + VStack(alignment: .leading, spacing: 8) { + if case .free = pro.status { + Text(Self.terms).font(.caption).foregroundStyle(Theme.muted).fixedSize(horizontal: false, vertical: true) + } else { + Text("\(Self.price). \(Self.terms)").font(.caption).foregroundStyle(Theme.muted) + .fixedSize(horizontal: false, vertical: true) + } + HStack(spacing: 8) { + TextField("Ключ: OFFLOAD-…", text: $key) + .textFieldStyle(.roundedBorder) + .onSubmit(activate) + Button("Ввести ключ", action: activate) + .disabled(key.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + if let problem = pro.keyProblem { + Notice(.error, problem) + } + } + } + } + + private func activate() { + if app.pro.activate(key) { key = "" } + } +} + +/// Строка над панелью диска: что открыто на этом Mac. Щелчок — окно «Offload Pro». +struct ProSidebarRow: View { + @Environment(AppModel.self) private var app + @State private var hovering = false + + var body: some View { + let pro = app.pro + Button { pro.offer() } label: { + HStack(spacing: 8) { + Image(systemName: pro.status.isPro ? "sparkles" : "sparkle") + .foregroundStyle(Theme.faint) + .frame(width: 20) + Text(pro.summary).font(.callout).foregroundStyle(Theme.ink).lineLimit(1) + Spacer(minLength: 4) + if !pro.status.isPro { + Text("Pro").font(.system(size: 11, weight: .semibold)).foregroundStyle(.white) + .padding(.horizontal, 7).padding(.vertical, 2) + .background(Theme.ink, in: Capsule()) + } + } + .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("Offload Pro: что в нём и ключ") + } +} + +/// Пометка «Pro» у кнопки, для которой нужен ключ. +struct ProTag: View { + var body: some View { + Text("Pro").font(.system(size: 10, weight: .bold)) + .padding(.horizontal, 5).padding(.vertical, 1) + .overlay(Capsule().strokeBorder(lineWidth: 1).opacity(0.6)) + .accessibilityLabel("Нужен Offload Pro") + } +} diff --git a/Sources/OffloadChecks/ChecksLicense.swift b/Sources/OffloadChecks/ChecksLicense.swift new file mode 100644 index 0000000..617503e --- /dev/null +++ b/Sources/OffloadChecks/ChecksLicense.swift @@ -0,0 +1,96 @@ +import CryptoKit +import Foundation +import OffloadCore + +/// Offload Pro: ключ проверяется подписью без сети, чужой и испорченный не проходят, +/// проба и ранние пользователи считаются так, как обещано в окне «Offload Pro». +func checksLicense() { + let signer = Curve25519.Signing.PrivateKey() + let publicKey = signer.publicKey.rawRepresentation.base64EncodedString() + .replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "") + let issued = LicenseCodec.day("2026-09-27")! + let until = LicenseCodec.day("2027-09-27")! + let license = License(id: "a1b2c3d4", name: "Анна «тест» / QA", issued: issued, updatesUntil: until) + + section("Pro: ключ") { + let key = try LicenseCodec.issue(license, privateKey: signer) + check(key.hasPrefix("OFFLOAD-"), "ключ начинается с OFFLOAD-") + check(try LicenseCodec.verify(key, publicKey: publicKey) == license, "свой ключ проверяется и читается как выпущен") + // Письма и мессенджеры переносят длинные строки и добавляют пробелы по краям. + let wrapped = " " + stride(from: 0, to: key.count, by: 40).map { i -> String in + let start = key.index(key.startIndex, offsetBy: i) + return String(key[start..<(key.index(start, offsetBy: 40, limitedBy: key.endIndex) ?? key.endIndex)]) + }.joined(separator: "\n") + "\n" + check(try LicenseCodec.verify(wrapped, publicKey: publicKey) == license, "ключ с переносами строк и пробелами проходит") + + let stranger = Curve25519.Signing.PrivateKey() + let forged = try LicenseCodec.issue(license, privateKey: stranger) + expectError("ключ, подписанный чужим ключом, не проходит", { _ = try LicenseCodec.verify(forged, publicKey: publicKey) }, + matching: { $0 as? LicenseError == .badSignature }) + + // Подмена срока обновлений в содержимом при прежней подписи. + let parts = key.dropFirst(LicenseCodec.prefix.count).split(separator: ".") + let body = Data(base64Encoded: base64(String(parts[0])))! + let tampered = String(decoding: body, as: UTF8.self).replacingOccurrences(of: "2027-09-27", with: "2099-09-27") + let tamperedKey = LicenseCodec.prefix + base64URL(Data(tampered.utf8)) + "." + parts[1] + expectError("ключ с продлённым вручную сроком не проходит", { _ = try LicenseCodec.verify(tamperedKey, publicKey: publicKey) }, + matching: { $0 as? LicenseError == .badSignature }) + + for garbage in ["", "OFFLOAD-", "OFFLOAD-abc", "OFFLOAD-абв.где", "offload-" + key.dropFirst(8), "OFFLOAD-..", String(key.dropLast(4))] { + expectError("мусор «\(garbage.prefix(20))» не проходит", { _ = try LicenseCodec.verify(garbage, publicKey: publicKey) }) + } + check(LicenseCodec.publicKey.count == 43 && Data(base64Encoded: base64(LicenseCodec.publicKey))?.count == 32, + "в программе настоящий открытый ключ Ed25519") + } + + section("Pro: какие версии открывает ключ") { + check(license.covers(release: nil), "сборка из исходников без даты выхода — открывается") + check(license.covers(release: LicenseCodec.day("2027-09-27")), "версия, вышедшая в последний день обновлений, — открывается") + check(!license.covers(release: LicenseCodec.day("2027-09-28")), "версия, вышедшая позже, — нет") + } + + section("Pro: проба, ранние пользователи, продление") { + let start = LicenseCodec.day("2026-10-01")! + func at(_ days: Int) -> Date { start.addingTimeInterval(TimeInterval(days) * 86_400 + 3_600) } + let later = LicenseCodec.day("2028-01-01") + check(ProStatus.resolve(license: nil, release: nil, early: false, trialStarted: start, now: at(0)) == .trial(daysLeft: 14), + "в первый день проба — 14 дней") + check(ProStatus.resolve(license: nil, release: nil, early: false, trialStarted: start, now: at(13)) == .trial(daysLeft: 1), + "на 14-й день — остался один") + check(ProStatus.resolve(license: nil, release: nil, early: false, trialStarted: start, now: at(14)) == .free, + "на 15-й день — бесплатная версия") + check(ProStatus.resolve(license: nil, release: nil, early: true, trialStarted: nil, now: at(400)).isPro, + "ранний пользователь — Pro без срока") + check(ProStatus.resolve(license: license, release: nil, early: false, trialStarted: start, now: at(400)) == .licensed(license), + "ключ — Pro и после пробы") + check(ProStatus.resolve(license: license, release: later, early: false, trialStarted: start, now: at(400)) == .expired(license), + "версия новее конца обновлений — просит продлить, а не молчит") + check(ProStatus.resolve(license: license, release: later, early: false, trialStarted: start, now: at(3)) == .trial(daysLeft: 11), + "ключ, не подходящий к версии, пробу не отнимает") + check(ProStatus.resolve(license: license, release: later, early: true, trialStarted: nil, now: at(400)) == .early, + "ранний пользователь с просроченным ключом — всё открыто") + // Часы переведены назад — проба не становится длиннее 14 дней. + check(ProStatus.resolve(license: nil, release: nil, early: false, trialStarted: start, now: at(-30)) == .trial(daysLeft: 14), + "часы назад не удлиняют пробу") + } + + section("Pro: что закрыто, а что нет") { + check(CleanupQuestion.Kind.module(.duplicates).proFeature == .duplicates, "лишние копии — Pro") + check(CleanupQuestion.Kind.module(.projects).proFeature == .projectBackup, "проекты в бэкап — Pro") + // То, что освобождает место без риска и возвращает своё, — бесплатно всегда. + for kind: CleanupQuestion.Kind in [.module(.junk), .module(.safe), .module(.installers), .docker] { + check(kind.proFeature == nil, "\(kind) — бесплатно") + } + } +} + +private func base64(_ url: String) -> String { + var s = url.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") + s += String(repeating: "=", count: (4 - s.count % 4) % 4) + return s +} + +private func base64URL(_ data: Data) -> String { + data.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") +} diff --git a/Sources/OffloadChecks/main.swift b/Sources/OffloadChecks/main.swift index 1f48572..b81d19c 100644 --- a/Sources/OffloadChecks/main.swift +++ b/Sources/OffloadChecks/main.swift @@ -661,6 +661,7 @@ if env["OFFLOAD_SKIP_INTEGRATION"] != "1" { checksDuplicates() checksHabits() checksCloudRestore() +checksLicense() try? fm.removeItem(at: scratch) print("") diff --git a/Sources/OffloadCore/CleanupQuestions.swift b/Sources/OffloadCore/CleanupQuestions.swift index d7a5768..1599446 100644 --- a/Sources/OffloadCore/CleanupQuestions.swift +++ b/Sources/OffloadCore/CleanupQuestions.swift @@ -63,6 +63,17 @@ public struct CleanupQuestion: Sendable, Identifiable, Hashable { } } +extension CleanupQuestion.Kind { + /// Для «да» на этот вопрос нужен Offload Pro. «Не сейчас» и всё найденное видно и без него. + public var proFeature: ProFeature? { + switch self { + case .module(.duplicates): return .duplicates + case .module(.projects): return .projectBackup + default: return nil + } + } +} + public enum CleanupQuestions { /// О Docker спрашиваем, если он отдаст хотя бы столько. public static let dockerMinimumBytes: Int64 = 100_000_000 diff --git a/Sources/OffloadCore/License.swift b/Sources/OffloadCore/License.swift new file mode 100644 index 0000000..81464f4 --- /dev/null +++ b/Sources/OffloadCore/License.swift @@ -0,0 +1,197 @@ +import CryptoKit +import Foundation + +/// Offload Pro: что в нём и как проверяется ключ. +/// +/// Правила, из которых всё остальное: +/// - Ключ проверяется на этом Mac, без сети: подпись Ed25519 над содержимым ключа. +/// Offload не ходит на сервер ни при покупке, ни при запуске. +/// - Pro никогда не стоит между человеком и его данными. Сейф, перенос со сверкой, возврат +/// перенесённого, возврат тома Docker, восстановление из iCloud, ключи и токены в сейф, +/// очистка мусора — бесплатны всегда. Pro — это удобство и инструменты сверху. +/// - Кончилась пробная неделя посреди работы — начатое доделывается; ограничение действует +/// только на новое «да». +public enum ProFeature: String, CaseIterable, Sendable, Hashable { + /// «Удалить лишние копии?» в разборе. + case duplicates + /// Привычки: похожее само попадает в нужный вопрос. + case habits + /// Обновляемый бэкап проектов и вопрос «Добавить проекты в бэкап?». + case projectBackup + /// Упаковать тома Docker в сейф и убрать из Docker. Вернуть том — бесплатно. + case dockerVolumes + + public var title: String { + switch self { + case .duplicates: return "Лишние копии" + case .habits: return "Привычки" + case .projectBackup: return "Бэкап проектов" + case .dockerVolumes: return "Тома Docker в сейф" + } + } + + public var detail: String { + switch self { + case .duplicates: return "Одинаковые файлы находятся по SHA-256, одна копия остаётся всегда, лишние уходят в Корзину." + case .habits: return "Offload учится на ваших ответах и сам кладёт похожее в нужный вопрос." + case .projectBackup: return "Обновляемая копия папок с проектами в сейф: копируется только изменённое." + case .dockerVolumes: return "Неиспользуемые тома упаковываются в сейф со сверкой каждого файла." + } + } + + public var symbol: String { + switch self { + case .duplicates: return "doc.on.doc" + case .habits: return "sparkles" + case .projectBackup: return "externaldrive.badge.checkmark" + case .dockerVolumes: return "shippingbox" + } + } +} + +/// Проверенный ключ: кому выдан и до какой даты выходящие версии им открываются. +public struct License: Sendable, Hashable, Codable { + /// Номер ключа — по нему ключ находят в списке выданных (возврат денег, замена). + public var id: String + /// Как ключ подписан в окне «Offload Pro»: имя или ник покупателя. + public var name: String + public var issued: Date + /// Версии, вышедшие до этой даты, открываются ключом навсегда; вышедшие позже — нужно продлить. + public var updatesUntil: Date + + public init(id: String, name: String, issued: Date, updatesUntil: Date) { + self.id = id + self.name = name + self.issued = issued + self.updatesUntil = updatesUntil + } + + /// Открывает ли ключ версию, вышедшую в этот день. Без даты (сборка из исходников) — открывает. + public func covers(release: Date?) -> Bool { + guard let release else { return true } + return release <= updatesUntil + } +} + +public enum LicenseError: Error, LocalizedError, Equatable { + case malformed + case badSignature + case unsupportedVersion + + public var errorDescription: String? { + switch self { + case .malformed: return "Это не похоже на ключ Offload Pro. Скопируйте его целиком, вместе с «OFFLOAD-»." + case .badSignature: return "Ключ не подходит: подпись не сходится. Возможно, в нём опечатка — скопируйте его заново." + case .unsupportedVersion: return "Ключ выпущен для более новой версии Offload. Обновите программу." + } + } +} + +/// Формат ключа: `OFFLOAD-<содержимое>.<подпись>`, обе части — base64url без `=`. +/// Содержимое — JSON с полями v, id, name, issued, until (даты — ISO 8601, день). +/// Пробелы и переносы строк внутри ключа игнорируются: письма и мессенджеры любят их вставлять. +public enum LicenseCodec { + public static let prefix = "OFFLOAD-" + static let formatVersion = 1 + + /// Открытый ключ Offload: им проверяются все ключи Pro. Закрытый — только у автора, не в репозитории. + public static let publicKey = "s634ae2EoVpINNy36tMkbki6WfhiKR0l6BFw9flW78U" + + private struct Payload: Codable { + var v: Int + var id: String + var name: String + var issued: String + var until: String + } + + public static func verify(_ text: String, publicKey: String = publicKey) throws -> License { + let compact = text.filter { !$0.isWhitespace } + guard compact.hasPrefix(prefix) else { throw LicenseError.malformed } + let parts = compact.dropFirst(prefix.count).split(separator: ".", omittingEmptySubsequences: false) + guard parts.count == 2, + let body = Data(base64URL: String(parts[0])), + let signature = Data(base64URL: String(parts[1])), + let keyData = Data(base64URL: publicKey), + let key = try? Curve25519.Signing.PublicKey(rawRepresentation: keyData) + else { throw LicenseError.malformed } + // Подпись — над байтами содержимого как они есть, до разбора JSON. + guard key.isValidSignature(signature, for: body) else { throw LicenseError.badSignature } + guard let payload = try? JSONDecoder().decode(Payload.self, from: body) else { throw LicenseError.malformed } + guard payload.v == formatVersion else { throw LicenseError.unsupportedVersion } + guard let issued = day(payload.issued), let until = day(payload.until) else { throw LicenseError.malformed } + return License(id: payload.id, name: payload.name, issued: issued, updatesUntil: until) + } + + /// Выпуск ключа — только у автора, с закрытым ключом (`swift run OffloadLicense`). + public static func issue(_ license: License, privateKey: Curve25519.Signing.PrivateKey) throws -> String { + let payload = Payload(v: formatVersion, id: license.id, name: license.name, + issued: dayString(license.issued), until: dayString(license.updatesUntil)) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + let body = try encoder.encode(payload) + let signature = try privateKey.signature(for: body) + return prefix + body.base64URL + "." + signature.base64URL + } + + private static let dayFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "UTC") + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() + + public static func day(_ string: String) -> Date? { dayFormatter.date(from: string) } + public static func dayString(_ date: Date) -> String { dayFormatter.string(from: date) } +} + +/// Что открыто на этом Mac: ключ, пробный период, ранний пользователь или бесплатная версия. +public enum ProStatus: Sendable, Hashable { + case licensed(License) + /// Ключ есть, но эта версия вышла после конца его обновлений: работает прежняя, эту — продлить. + case expired(License) + /// Пользовался Offload до появления Pro: всё, что было, остаётся открытым. + case early + case trial(daysLeft: Int) + case free + + public var isPro: Bool { + switch self { + case .licensed, .early, .trial: return true + case .expired, .free: return false + } + } + + /// Сколько дней пробы, считая с первого запуска версии с Pro. + public static let trialDays = 14 + + /// Итог по ключу, дате выхода этой версии и началу пробы. Ключ, который не подходит к этой + /// версии, пробу не отнимает: человек заплатил и точно не хуже того, кто не платил. + public static func resolve(license: License?, release: Date?, early: Bool, trialStarted: Date?, now: Date) -> ProStatus { + if let license, license.covers(release: release) { return .licensed(license) } + if early { return .early } + if let trialStarted { + let days = Calendar(identifier: .gregorian).dateComponents([.day], from: trialStarted, to: now).day ?? 0 + let left = trialDays - max(days, 0) + if left > 0 { return .trial(daysLeft: left) } + } + if let license { return .expired(license) } + return .free + } +} + +extension Data { + init?(base64URL string: String) { + var base64 = string.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") + guard base64.allSatisfy({ $0.isASCII && ($0.isLetter || $0.isNumber || $0 == "+" || $0 == "/") }) else { return nil } + base64 += String(repeating: "=", count: (4 - base64.count % 4) % 4) + self.init(base64Encoded: base64) + } + + var base64URL: String { + base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} diff --git a/Sources/OffloadLicense/main.swift b/Sources/OffloadLicense/main.swift new file mode 100644 index 0000000..e0a03e7 --- /dev/null +++ b/Sources/OffloadLicense/main.swift @@ -0,0 +1,101 @@ +import CryptoKit +import Foundation +import OffloadCore + +// Выпуск ключей Offload Pro. Нужен только автору: закрытый ключ лежит у него, не в репозитории. +// +// swift run OffloadLicense keygen — завести пару ключей (один раз) +// swift run OffloadLicense issue "Имя покупателя" — выпустить ключ на год обновлений +// swift run OffloadLicense issue "Имя" --days 730 --note "USDT, заказ 17" +// swift run OffloadLicense verify OFFLOAD-… — проверить ключ +// +// Каждый выпущенный ключ дописывается в issued.tsv рядом с закрытым ключом: номер, имя, даты, заметка. + +let folder = URL(fileURLWithPath: ProcessInfo.processInfo.environment["OFFLOAD_LICENSE_HOME"] + ?? (NSHomeDirectory() + "/.config/offload-license"), isDirectory: true) +let privateKeyURL = folder.appendingPathComponent("signing.key") +let ledgerURL = folder.appendingPathComponent("issued.tsv") + +func fail(_ message: String) -> Never { + FileHandle.standardError.write(Data((message + "\n").utf8)) + exit(1) +} + +func option(_ name: String, in args: [String]) -> String? { + guard let index = args.firstIndex(of: name), index + 1 < args.count else { return nil } + return args[index + 1] +} + +func loadPrivateKey() -> Curve25519.Signing.PrivateKey { + guard let text = try? String(contentsOf: privateKeyURL, encoding: .utf8), + let data = Data(base64Encoded: text.trimmingCharacters(in: .whitespacesAndNewlines)), + let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: data) + else { fail("Нет закрытого ключа в \(privateKeyURL.path). Сначала: swift run OffloadLicense keygen") } + return key +} + +func publicKeyString(_ key: Curve25519.Signing.PrivateKey) -> String { + key.publicKey.rawRepresentation.base64EncodedString() + .replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "") +} + +let args = Array(CommandLine.arguments.dropFirst()) +switch args.first { +case "keygen": + guard !FileManager.default.fileExists(atPath: privateKeyURL.path) else { + fail("Закрытый ключ уже есть: \(privateKeyURL.path). Новый сделал бы недействительными все выданные ключи — не перезаписываю.") + } + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) + let key = Curve25519.Signing.PrivateKey() + guard FileManager.default.createFile(atPath: privateKeyURL.path, contents: Data(key.rawRepresentation.base64EncodedString().utf8), + attributes: [.posixPermissions: 0o600]) + else { fail("Не получилось записать \(privateKeyURL.path)") } + print("Закрытый ключ: \(privateKeyURL.path) — сделайте его копию в сейф; потеряете — старые ключи останутся рабочими, но новых не выпустить.") + print("Открытый ключ для LicenseCodec.publicKey:") + print(publicKeyString(key)) + +case "pubkey": + print(publicKeyString(loadPrivateKey())) + +case "issue": + guard args.count >= 2, !args[1].hasPrefix("--") else { fail("Использование: issue \"Имя покупателя\" [--days 365] [--note текст]") } + let name = args[1].trimmingCharacters(in: .whitespaces) + guard !name.isEmpty, name.count <= 80, !name.contains("\t"), !name.contains("\n") else { fail("Имя — одна строка до 80 знаков.") } + let days = Int(option("--days", in: args) ?? "365") ?? 365 + let note = (option("--note", in: args) ?? "").replacingOccurrences(of: "\t", with: " ").replacingOccurrences(of: "\n", with: " ") + let key = loadPrivateKey() + let now = Date() + let id = String(UUID().uuidString.prefix(8)).lowercased() + let license = License(id: id, name: name, issued: now, updatesUntil: now.addingTimeInterval(TimeInterval(days) * 86_400)) + let text = try LicenseCodec.issue(license, privateKey: key) + // Сразу проверяем тем же путём, что и программа: ключ, который не откроется у покупателя, не выдаём. + guard let checked = try? LicenseCodec.verify(text, publicKey: publicKeyString(key)), checked.id == id, checked.name == name, + LicenseCodec.dayString(checked.updatesUntil) == LicenseCodec.dayString(license.updatesUntil) else { + fail("Выпущенный ключ не прошёл проверку — не выдавайте его.") + } + if publicKeyString(key) != LicenseCodec.publicKey { + FileHandle.standardError.write(Data("⚠️ Этот закрытый ключ не совпадает с открытым в LicenseCodec.publicKey: программа такой ключ не примет.\n".utf8)) + } + let line = [id, name, LicenseCodec.dayString(license.issued), LicenseCodec.dayString(license.updatesUntil), note].joined(separator: "\t") + "\n" + if !FileManager.default.fileExists(atPath: ledgerURL.path) { + FileManager.default.createFile(atPath: ledgerURL.path, contents: Data("id\tname\tissued\tuntil\tnote\n".utf8), + attributes: [.posixPermissions: 0o600]) + } + guard let ledger = try? FileHandle(forWritingTo: ledgerURL) else { fail("Не открылся \(ledgerURL.path)") } + ledger.seekToEndOfFile() + ledger.write(Data(line.utf8)) + try ledger.close() + print(text) + +case "verify": + guard args.count >= 2 else { fail("Использование: verify OFFLOAD-…") } + do { + let license = try LicenseCodec.verify(args.dropFirst().joined()) + print("Ключ верный: \(license.name), №\(license.id), выдан \(LicenseCodec.dayString(license.issued)), обновления до \(LicenseCodec.dayString(license.updatesUntil))") + } catch { + fail(error.localizedDescription) + } + +default: + fail("Команды: keygen, pubkey, issue \"Имя\" [--days 365] [--note …], verify OFFLOAD-…") +} diff --git a/scripts/build-app.sh b/scripts/build-app.sh index f84367e..ec426d5 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -13,6 +13,10 @@ APP="$DIST/Offload.app" ASSETS="$ROOT/.build/app-assets" [[ "$VERSION" =~ '^[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$' ]] || { print -u2 "⚠️ Некорректная версия: $VERSION"; exit 1; } +# День выхода версии: ключ Offload Pro открывает версии, вышедшие до конца его обновлений. +# Берётся из последнего коммита, а не из часов сборки: пересборка той же версии через год не должна её «состарить». +RELEASE_DATE="${OFFLOAD_RELEASE_DATE:-$(TZ=UTC git -C "$ROOT" log -1 --date=format-local:%Y-%m-%d --format=%cd 2>/dev/null || true)}" +[[ "$RELEASE_DATE" =~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}$' ]] || RELEASE_DATE="$(date -u +%Y-%m-%d)" rm -rf "$APP" "$ASSETS" mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" "$ASSETS/AppIcon.iconset" @@ -65,6 +69,7 @@ cat > "$APP/Contents/Info.plist" <CFBundlePackageTypeAPPL CFBundleShortVersionString$VERSION CFBundleVersion$VERSION + OffloadReleaseDate$RELEASE_DATE CFBundleDevelopmentRegionru LSMinimumSystemVersion14.0 LSApplicationCategoryTypepublic.app-category.utilities From 3f62938924182311f90dde4e872e1b3da77ebfd3 Mon Sep 17 00:00:00 2001 From: q <301774981+audit0@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:10:25 +0300 Subject: [PATCH 2/3] =?UTF-8?q?Offload=20Pro:=20=D0=B2=D0=B5=D1=80=D1=81?= =?UTF-8?q?=D0=B8=D1=8F=200.4.0,=20=D0=BF=D0=BE=D0=BA=D1=83=D0=BF=D0=BA?= =?UTF-8?q?=D0=B0=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20Telegram-=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=20(Stars,=20=D0=BA=D1=80=D0=B8=D0=BF=D1=82=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BB=D1=8E=D1=82=D0=B0,=20=D0=A1=D0=91=D0=9F)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MONETIZATION.md | 2 ++ README.md | 3 +-- Sources/Offload/ProModel.swift | 4 ++-- VERSION | 2 +- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/MONETIZATION.md b/MONETIZATION.md index 35218cd..a8113c2 100644 --- a/MONETIZATION.md +++ b/MONETIZATION.md @@ -48,6 +48,8 @@ ## Оплата и выдача ключа +**Решение 27.09:** продаём через Telegram-бот SHILLVPN — там уже работают Stars, CryptoBot, xRocket, USDT TRC20/TON, TON и (скоро) СБП Platega. Бот выдаёт ключ сразу после оплаты; закрытый ключ подписи создаётся и хранится только на сервере бота, ручная выдача — командой админа `/offload_key Имя`. Кнопка «Купить…» ведёт на `t.me/SHILLVPN_bot?start=offload`. Ниже — как было задумано до этого решения; `OffloadLicense` остаётся для проверки ключей и на случай выдачи без бота. + **Этап 1 — вручную, пока продаж меньше ~10 в день.** Выдача ключа — одна команда: ```bash diff --git a/README.md b/README.md index 028429c..2d758f5 100644 --- a/README.md +++ b/README.md @@ -135,8 +135,7 @@ Offload Pro добавляет инструменты для тех, у кого ### Как купить - -Способы оплаты появятся здесь с выходом версии 0.4. +В Telegram-боте [@SHILLVPN_bot](https://t.me/SHILLVPN_bot?start=offload) (кнопка «Купить…» в окне «Offload Pro» ведёт туда же): оплата Telegram Stars, криптовалютой (CryptoBot, xRocket, USDT TRC20/TON, TON) или по СБП. Ключ приходит сообщением сразу после оплаты; вставьте его в окне «Offload Pro». Возврат — 14 дней, напишите в том же боте. ## Полный доступ к диску diff --git a/Sources/Offload/ProModel.swift b/Sources/Offload/ProModel.swift index fb707f1..e4429ab 100644 --- a/Sources/Offload/ProModel.swift +++ b/Sources/Offload/ProModel.swift @@ -10,8 +10,8 @@ import OffloadCore @MainActor @Observable final class ProModel { - /// Где купить: раздел README с ценами и способами оплаты. - static let purchaseURL = URL(string: "https://github.com/audit0/Offload#offload-pro")! + /// Где купить: бот в Telegram — оплата Stars, криптовалютой или по СБП, ключ приходит сообщением. + static let purchaseURL = URL(string: "https://t.me/SHILLVPN_bot?start=offload")! private(set) var status: ProStatus = .free private(set) var license: License? diff --git a/VERSION b/VERSION index d15723f..1d0ba9e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.2 +0.4.0 From 1c7b63f3f4f27be5197ff78e2a1a4efe903ac547 Mon Sep 17 00:00:00 2001 From: q <301774981+audit0@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:43:46 +0300 Subject: [PATCH 3/3] =?UTF-8?q?Offload=20Pro:=20=D0=BF=D0=BE=D0=BA=D1=83?= =?UTF-8?q?=D0=BF=D0=BA=D0=B0=20=D0=B2=20@OffLoadmg=5Fbot,=20=D0=BA=D0=B0?= =?UTF-8?q?=D0=BD=D0=B0=D0=BB=20@offload=5Fdmg=20=D0=B2=20=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=8E=20=C2=AB=D0=A1=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B0?= =?UTF-8?q?=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MONETIZATION.md | 2 +- README.md | 2 +- Sources/Offload/OffloadApp.swift | 1 + Sources/Offload/ProModel.swift | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/MONETIZATION.md b/MONETIZATION.md index a8113c2..5a77f62 100644 --- a/MONETIZATION.md +++ b/MONETIZATION.md @@ -48,7 +48,7 @@ ## Оплата и выдача ключа -**Решение 27.09:** продаём через Telegram-бот SHILLVPN — там уже работают Stars, CryptoBot, xRocket, USDT TRC20/TON, TON и (скоро) СБП Platega. Бот выдаёт ключ сразу после оплаты; закрытый ключ подписи создаётся и хранится только на сервере бота, ручная выдача — командой админа `/offload_key Имя`. Кнопка «Купить…» ведёт на `t.me/SHILLVPN_bot?start=offload`. Ниже — как было задумано до этого решения; `OffloadLicense` остаётся для проверки ключей и на случай выдачи без бота. +**Решение 27.09:** продаём через отдельный бот [@OffLoadmg_bot](https://t.me/OffLoadmg_bot) на платёжной части SHILLVPN — там уже работают Stars, CryptoBot, xRocket, USDT TRC20/TON, TON и (скоро) СБП Platega. Бот выдаёт ключ сразу после оплаты; закрытый ключ подписи создаётся и хранится только на сервере бота, ручная выдача — командой админа `/offload_key Имя`. Кнопка «Купить…» ведёт на `t.me/OffLoadmg_bot?start=pro`. Новости и релизы — канал [@offload_dmg](https://t.me/offload_dmg), бот в нём администратор. Ниже — как было задумано до этого решения; `OffloadLicense` остаётся для проверки ключей и на случай выдачи без бота. **Этап 1 — вручную, пока продаж меньше ~10 в день.** Выдача ключа — одна команда: diff --git a/README.md b/README.md index 2d758f5..f1f66f8 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ Offload Pro добавляет инструменты для тех, у кого ### Как купить -В Telegram-боте [@SHILLVPN_bot](https://t.me/SHILLVPN_bot?start=offload) (кнопка «Купить…» в окне «Offload Pro» ведёт туда же): оплата Telegram Stars, криптовалютой (CryptoBot, xRocket, USDT TRC20/TON, TON) или по СБП. Ключ приходит сообщением сразу после оплаты; вставьте его в окне «Offload Pro». Возврат — 14 дней, напишите в том же боте. +В Telegram-боте [@OffLoadmg_bot](https://t.me/OffLoadmg_bot?start=pro) (кнопка «Купить…» в окне «Offload Pro» ведёт туда же): оплата Telegram Stars, криптовалютой (CryptoBot, xRocket, USDT TRC20/TON, TON) или по СБП. Ключ приходит сообщением сразу после оплаты; вставьте его в окне «Offload Pro». Возврат — 14 дней, напишите в том же боте. Новости и релизы — в канале [@offload_dmg](https://t.me/offload_dmg). ## Полный доступ к диску diff --git a/Sources/Offload/OffloadApp.swift b/Sources/Offload/OffloadApp.swift index 09e6099..783cec0 100644 --- a/Sources/Offload/OffloadApp.swift +++ b/Sources/Offload/OffloadApp.swift @@ -34,6 +34,7 @@ struct OffloadApp: App { } CommandGroup(replacing: .help) { Link("Offload на GitHub", destination: Self.repositoryURL) + Link("Канал Offload в Telegram", destination: URL(string: "https://t.me/offload_dmg")!) Link("Сообщить о проблеме", destination: Self.repositoryURL.appendingPathComponent("issues")) } } diff --git a/Sources/Offload/ProModel.swift b/Sources/Offload/ProModel.swift index e4429ab..83e18a4 100644 --- a/Sources/Offload/ProModel.swift +++ b/Sources/Offload/ProModel.swift @@ -11,7 +11,7 @@ import OffloadCore @Observable final class ProModel { /// Где купить: бот в Telegram — оплата Stars, криптовалютой или по СБП, ключ приходит сообщением. - static let purchaseURL = URL(string: "https://t.me/SHILLVPN_bot?start=offload")! + static let purchaseURL = URL(string: "https://t.me/OffLoadmg_bot?start=pro")! private(set) var status: ProStatus = .free private(set) var license: License?